From 971185a8b7f8a7e3abc6918ea3956042c1b0ea55 Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 14:14:40 -0700 Subject: [PATCH 01/11] S2.1: Remove vestigial DecisionLogEntry.effects field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-authorized by freeze-v1.md §4.2. - Removed effects: Vec from DecisionLogEntry - Removed effects parameter from Supervisor::log_decision - Updated seven call sites - Migrated two in-crate readers and capture test coverage Plan: docs/ledger/decisions/s2.1-plan.md --- crates/kernel/supervisor/src/capture.rs | 15 +-- crates/kernel/supervisor/src/capture/tests.rs | 35 ++---- crates/kernel/supervisor/src/lib.rs | 40 +------ crates/kernel/supervisor/src/replay.rs | 18 +--- .../prod/core/host/src/capture_enrichment.rs | 4 +- docs/ledger/decisions/s2.1-plan.md | 101 ++++++++++++++++++ 6 files changed, 118 insertions(+), 95 deletions(-) create mode 100644 docs/ledger/decisions/s2.1-plan.md diff --git a/crates/kernel/supervisor/src/capture.rs b/crates/kernel/supervisor/src/capture.rs index 357cbd4..59b4db6 100644 --- a/crates/kernel/supervisor/src/capture.rs +++ b/crates/kernel/supervisor/src/capture.rs @@ -31,8 +31,7 @@ use ergo_adapter::capture::ExternalEventRecord; use ergo_adapter::{ExternalEvent, GraphId, RuntimeInvoker}; use crate::{ - CaptureBundle, CapturedActionEffect, Constraints, DecisionLog, DecisionLogEntry, - EpisodeInvocationRecord, Supervisor, + CaptureBundle, Constraints, DecisionLog, DecisionLogEntry, EpisodeInvocationRecord, Supervisor, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -189,17 +188,7 @@ impl DecisionLog for CapturingDecisionLog { fn log(&self, entry: DecisionLogEntry) { self.inner.log(entry.clone()); - let captured_effects: Vec = entry - .effects - .iter() - .map(|effect| CapturedActionEffect { - effect_hash: crate::compute_effect_hash(effect), - effect: effect.clone(), - }) - .collect(); - - let mut record = EpisodeInvocationRecord::from(&entry); - record.effects = captured_effects; + let record = EpisodeInvocationRecord::from(&entry); let mut guard = self.bundle.lock().expect("capture bundle poisoned"); guard.decisions.push(record); diff --git a/crates/kernel/supervisor/src/capture/tests.rs b/crates/kernel/supervisor/src/capture/tests.rs index 67b336d..584ba03 100644 --- a/crates/kernel/supervisor/src/capture/tests.rs +++ b/crates/kernel/supervisor/src/capture/tests.rs @@ -1,10 +1,12 @@ //! capture tests //! //! Purpose: -//! - Lock the supervisor capture write seam and capture-log hashing behavior. +//! - Lock the supervisor capture write seam and kernel-side decision-record +//! materialization behavior. //! //! Owns: -//! - Scenario-heavy tests for atomic file writing and captured effect hashing. +//! - Scenario-heavy tests for atomic file writing and default-empty decision +//! record effects before host enrichment. //! //! Does not own: //! - Production capture/write implementation logic in `capture.rs`. @@ -171,19 +173,7 @@ fn failed_write_leaves_existing_destination_untouched() { } #[test] -fn capturing_log_hashes_non_empty_effects_correctly() { - use ergo_runtime::common::{ActionEffect, EffectWrite, Value}; - use sha2::{Digest, Sha256}; - - let effect = ActionEffect { - kind: "set_context".to_string(), - writes: vec![EffectWrite { - key: "price".to_string(), - value: Value::Number(42.0), - }], - intents: vec![], - }; - +fn capturing_log_leaves_effects_empty_without_host_enrichment() { let bundle = Arc::new(Mutex::new(CaptureBundle { capture_version: crate::CAPTURE_FORMAT_VERSION.to_string(), graph_id: GraphId::new("hash_test"), @@ -211,7 +201,6 @@ fn capturing_log_hashes_non_empty_effects_correctly() { deadline: None, termination: Some(ergo_adapter::RunTermination::Completed), retry_count: 0, - effects: vec![effect.clone()], }; capturing_log.log(entry); @@ -219,16 +208,8 @@ fn capturing_log_hashes_non_empty_effects_correctly() { let guard = bundle.lock().expect("bundle poisoned"); assert_eq!(guard.decisions.len(), 1); let record = &guard.decisions[0]; - let captured_effects = &record.effects; - assert_eq!(captured_effects.len(), 1, "one effect expected"); - assert_eq!(captured_effects[0].effect, effect); - - let expected_bytes = serde_json::to_vec(&effect).unwrap(); - let mut hasher = Sha256::new(); - hasher.update(&expected_bytes); - let expected_hash = hex::encode(hasher.finalize()); - assert_eq!( - captured_effects[0].effect_hash, expected_hash, - "effect_hash must equal SHA-256 of serde_json::to_vec(&effect)" + assert!( + record.effects.is_empty(), + "kernel capture should leave effects empty until host enrichment runs" ); } diff --git a/crates/kernel/supervisor/src/lib.rs b/crates/kernel/supervisor/src/lib.rs index f14eb7b..88a261c 100644 --- a/crates/kernel/supervisor/src/lib.rs +++ b/crates/kernel/supervisor/src/lib.rs @@ -150,7 +150,6 @@ pub struct DecisionLogEntry { pub deadline: Option, pub termination: Option, pub retry_count: usize, - pub effects: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -279,15 +278,7 @@ impl Supervisor { if self.is_concurrency_saturated() { self.enqueue_deferred(now, episode_id, &event); - self.log_decision( - &event, - Decision::Defer, - Some(now), - episode_id, - None, - 0, - vec![], - ); + self.log_decision(&event, Decision::Defer, Some(now), episode_id, None, 0); return; } @@ -301,7 +292,6 @@ impl Supervisor { episode_id, None, 0, - vec![], ); return; } @@ -323,7 +313,6 @@ impl Supervisor { episode_id, Some(termination), retry_count, - vec![], ); } @@ -364,15 +353,7 @@ impl Supervisor { // CASE 1: Nothing due — log no-op let Some(key) = due_key else { let episode_id = self.next_episode_id(); - self.log_decision( - tick_event, - Decision::Defer, - None, - episode_id, - None, - 0, - vec![], - ); + self.log_decision(tick_event, Decision::Defer, None, episode_id, None, 0); return; }; @@ -383,15 +364,7 @@ impl Supervisor { if self.is_concurrency_saturated() { item.defer_count += 1; self.deferred_queue.insert((now, episode_id), item); - self.log_decision( - tick_event, - Decision::Defer, - Some(now), - episode_id, - None, - 0, - vec![], - ); + self.log_decision(tick_event, Decision::Defer, Some(now), episode_id, None, 0); return; } @@ -407,7 +380,6 @@ impl Supervisor { episode_id, None, 0, - vec![], ); return; } @@ -429,7 +401,6 @@ impl Supervisor { episode_id, Some(termination), retry_count, - vec![], ); } @@ -490,8 +461,7 @@ impl Supervisor { } } - // Supervisor decision logging intentionally passes full context - #[allow(clippy::too_many_arguments)] + // Supervisor decision logging records the scheduling outcome for a single event. fn log_decision( &self, event: &ExternalEvent, @@ -500,7 +470,6 @@ impl Supervisor { episode_id: EpisodeId, termination: Option, retry_count: usize, - effects: Vec, ) { let entry = DecisionLogEntry { graph_id: self.graph_id.clone(), @@ -512,7 +481,6 @@ impl Supervisor { deadline: self.constraints.deadline, termination, retry_count, - effects, }; self.decision_log.log(entry); } diff --git a/crates/kernel/supervisor/src/replay.rs b/crates/kernel/supervisor/src/replay.rs index 40887f1..882943f 100644 --- a/crates/kernel/supervisor/src/replay.rs +++ b/crates/kernel/supervisor/src/replay.rs @@ -48,23 +48,7 @@ impl DecisionLog for MemoryDecisionLog { impl MemoryDecisionLog { pub fn records(&self) -> Vec { let guard = self.entries.lock().expect("decision log poisoned"); - guard - .iter() - .map(|entry| { - let mut record = EpisodeInvocationRecord::from(entry); - // Hash effects for comparison with captured data. - let captured: Vec = entry - .effects - .iter() - .map(|effect| CapturedActionEffect { - effect_hash: hash_effect(effect), - effect: effect.clone(), - }) - .collect(); - record.effects = captured; - record - }) - .collect() + guard.iter().map(EpisodeInvocationRecord::from).collect() } } diff --git a/crates/prod/core/host/src/capture_enrichment.rs b/crates/prod/core/host/src/capture_enrichment.rs index 221ea72..8fa39b9 100644 --- a/crates/prod/core/host/src/capture_enrichment.rs +++ b/crates/prod/core/host/src/capture_enrichment.rs @@ -27,8 +27,8 @@ //! Safety notes: //! - Enrichment is by decision index, not `event_id`, per the host/supervisor //! orchestration contract. -//! - Host-enriched `effects` overwrite supervisor-populated `record.effects` -//! and are the authoritative canonical effect records for host captures. +//! - Host-enriched `effects` are the authoritative canonical effect records for +//! host captures. //! - If a sidecar slice has no entry for a decision index, enrichment leaves //! the existing bundle field untouched. //! - Sparse `record(...)` calls materialize explicit default gap entries diff --git a/docs/ledger/decisions/s2.1-plan.md b/docs/ledger/decisions/s2.1-plan.md new file mode 100644 index 0000000..9e56322 --- /dev/null +++ b/docs/ledger/decisions/s2.1-plan.md @@ -0,0 +1,101 @@ +--- +Authority: PROJECT +Date: 2026-04-19 +Decision-Owner: Sebastian (Architect) +Participants: Codex +Status: DRAFT +Scope: v1 +Parent-Decision: ../../system/freeze-v1.md +Resolves: S2.1 planning +--- + +# S2.1 Plan: Remove Vestigial `DecisionLogEntry.effects` + +## Scope + +Pre-authorized by [`freeze-v1.md` §4.2](../../system/freeze-v1.md). Remove +`DecisionLogEntry.effects: Vec` from +`crates/kernel/supervisor/src/lib.rs` and remove the dead +`effects: Vec` parameter from `Supervisor::log_decision(...)`. + +This is **not** a persisted-format change: `DecisionLogEntry` has no serde +derives, while persisted `EpisodeInvocationRecord.effects` remains unchanged. + +## Re-Verification at Current HEAD + +- `Supervisor::log_decision(...)` has seven call sites in + `crates/kernel/supervisor/src/lib.rs`: lines 282, 297, 319, 367, 386, 403, + and 425. All seven pass `vec![]`. +- The only in-crate readers of `entry.effects` are: + - `crates/kernel/supervisor/src/capture.rs:189-205` + - `crates/kernel/supervisor/src/replay.rs:49-65` +- The only non-empty `DecisionLogEntry { effects: ... }` constructor is + `crates/kernel/supervisor/src/capture/tests.rs:201-215`. +- Downstream audit: `crates/prod/core/host/src/runner.rs:131-152` stores + `DecisionLogEntry` opaquely and only reads `decision`, `termination`, and + `retry_count` later (`runner.rs:578-610`). No cross-crate `.effects` usage + was found. + +## Planned File Changes + +- `crates/kernel/supervisor/src/lib.rs` + - Remove `effects` from `DecisionLogEntry`. + - Remove the `effects` parameter from `Supervisor::log_decision(...)`. + - Remove `#[allow(clippy::too_many_arguments)]`. + - Remove the seven `vec![]` arguments and the `effects` field init. + - Keep `EpisodeInvocationRecord::from(&DecisionLogEntry)` initializing + `effects: vec![]`. +- `crates/kernel/supervisor/src/capture.rs` + - Delete the `entry.effects -> CapturedActionEffect` remap in + `CapturingDecisionLog::log`. + - Push `EpisodeInvocationRecord::from(&entry)` directly; host enrichment + still owns authoritative `record.effects`. +- `crates/kernel/supervisor/src/replay.rs` + - Delete the same remap in `MemoryDecisionLog::records`. + - Leave `compare_decisions(...)` untouched; it compares + `EpisodeInvocationRecord.effects`, not `DecisionLogEntry.effects`. +- `crates/prod/core/host/src/capture_enrichment.rs` + - Update the header comment that still says host-enriched effects + overwrite “supervisor-populated” `record.effects`; after S2.1 there is + no supervisor-side effect materialization path. + +## Test Migration + +- Rewrite + `capturing_log_hashes_non_empty_effects_correctly` + (`crates/kernel/supervisor/src/capture/tests.rs:173-234`) into a narrower + local regression test: `CapturingDecisionLog::log` should leave + `record.effects` empty after logging a decision. +- Treat the stronger post-S2.1 contract coverage as retained existing tests, + not a new local replacement: + - `crates/kernel/supervisor/tests/replay_harness.rs` already covers the + termination-only empty-effects path end-to-end. + - `crates/prod/core/host/src/capture_enrichment/tests.rs` already covers + host-owned positive-path/defaulting behavior for persisted + `EpisodeInvocationRecord.effects`. + - Host runner/replay tests asserting non-empty persisted effects stay + unchanged; they validate host enrichment, not `DecisionLogEntry.effects`. +- Drop the removed `effects` field from any remaining `DecisionLogEntry` + struct literals. + +## Verification and Rollback + +- Verification after execution: + - `cargo test -p ergo-supervisor` + - `cargo test -p ergo-host` + - Both wire-shape guards below are expected to pass unchanged because they + assert persisted `EpisodeInvocationRecord.effects` serialization, not the + removed non-serde `DecisionLogEntry.effects` field. + - Confirm the persisted wire-shape guards still pass: + - `missing_effects_field_fails_deserialization` in + `crates/kernel/supervisor/tests/replay_harness.rs` + - CLI assertion that serialized decisions still include `effects` + in `crates/prod/clients/cli/src/tests.rs` +- Rollback is a clean `git revert`: no serde surface changes, no data migration, + no capture-version bump. + +## Commit Note + +The S2.1 commit body should cite [`freeze-v1.md` §4.2](../../system/freeze-v1.md) +as the pre-authorization and link this plan: +`docs/ledger/decisions/s2.1-plan.md`. From bece0b2d547b5bcc347c79698074881550a2a0d4 Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 14:49:46 -0700 Subject: [PATCH 02/11] S2.3: relocate host behavior out of ergo-adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-authorized by freeze-v1.md §4.3. - moved BufferingRuntimeInvoker, ContextStore, handler coverage, and effect-handler support into ergo-host::host - updated host imports and tests; no RuntimeHandle::run or RunResult seam change in this commit - refreshed the S2.3 planning artifact and the v1 boundary/freeze docs for the relocated paths Plan: docs/ledger/decisions/s2.3-plan.md --- .../kernel/adapter/src/host/context_store.rs | 24 - crates/kernel/adapter/src/host/mod.rs | 9 - crates/kernel/adapter/src/lib.rs | 1 - .../prod/core/host/src/egress/validation.rs | 4 +- .../core/host/src/egress/validation/tests.rs | 3 +- crates/prod/core/host/src/error.rs | 2 +- crates/prod/core/host/src/error/tests.rs | 3 +- .../core/host}/src/host/buffering_invoker.rs | 53 +- .../prod/core/host/src/host/context_store.rs | 44 ++ .../core/host}/src/host/coverage.rs | 25 +- .../core/host}/src/host/effects.rs | 30 +- crates/prod/core/host/src/host/mod.rs | 34 ++ crates/prod/core/host/src/lib.rs | 1 + crates/prod/core/host/src/runner.rs | 13 +- crates/prod/core/host/src/runner/tests.rs | 2 +- docs/ledger/decisions/s2.3-plan.md | 235 ++++++++ .../decisions/v1-host-boundary-migration.md | 178 ++++++ docs/system/freeze-v1.md | 181 ++++++ docs/system/host-boundary.md | 525 ++++++++++++++++++ 19 files changed, 1309 insertions(+), 58 deletions(-) delete mode 100644 crates/kernel/adapter/src/host/context_store.rs delete mode 100644 crates/kernel/adapter/src/host/mod.rs rename crates/{kernel/adapter => prod/core/host}/src/host/buffering_invoker.rs (76%) create mode 100644 crates/prod/core/host/src/host/context_store.rs rename crates/{kernel/adapter => prod/core/host}/src/host/coverage.rs (89%) rename crates/{kernel/adapter => prod/core/host}/src/host/effects.rs (91%) create mode 100644 crates/prod/core/host/src/host/mod.rs create mode 100644 docs/ledger/decisions/s2.3-plan.md create mode 100644 docs/ledger/decisions/v1-host-boundary-migration.md create mode 100644 docs/system/freeze-v1.md create mode 100644 docs/system/host-boundary.md diff --git a/crates/kernel/adapter/src/host/context_store.rs b/crates/kernel/adapter/src/host/context_store.rs deleted file mode 100644 index b021abc..0000000 --- a/crates/kernel/adapter/src/host/context_store.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::collections::BTreeMap; - -#[derive(Debug, Clone, Default)] -pub struct ContextStore { - values: BTreeMap, -} - -impl ContextStore { - pub fn new() -> Self { - Self::default() - } - - pub fn snapshot(&self) -> &BTreeMap { - &self.values - } - - pub fn set(&mut self, key: String, value: serde_json::Value) { - self.values.insert(key, value); - } - - pub fn get(&self, key: &str) -> Option<&serde_json::Value> { - self.values.get(key) - } -} diff --git a/crates/kernel/adapter/src/host/mod.rs b/crates/kernel/adapter/src/host/mod.rs deleted file mode 100644 index b77a93c..0000000 --- a/crates/kernel/adapter/src/host/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod buffering_invoker; -mod context_store; -mod coverage; -mod effects; - -pub use buffering_invoker::BufferingRuntimeInvoker; -pub use context_store::ContextStore; -pub use coverage::{ensure_handler_coverage, HandlerCoverageError}; -pub use effects::{AppliedWrite, EffectApplyError, EffectHandler, SetContextHandler}; diff --git a/crates/kernel/adapter/src/lib.rs b/crates/kernel/adapter/src/lib.rs index 79d84f7..125a042 100644 --- a/crates/kernel/adapter/src/lib.rs +++ b/crates/kernel/adapter/src/lib.rs @@ -42,7 +42,6 @@ pub mod composition; pub mod errors; pub mod event_binding; pub mod fixture; -pub mod host; pub mod manifest; pub mod provenance; pub mod provides; diff --git a/crates/prod/core/host/src/egress/validation.rs b/crates/prod/core/host/src/egress/validation.rs index 0630919..651cbb6 100644 --- a/crates/prod/core/host/src/egress/validation.rs +++ b/crates/prod/core/host/src/egress/validation.rs @@ -20,7 +20,7 @@ //! Connects to: //! - `runner::validate_hosted_runner_configuration(...)`, which uses this module as //! the canonical live-egress validation seam. -//! - `ergo_adapter::host::ensure_handler_coverage(...)` for HST-5 ownership checks. +//! - `crate::host::ensure_handler_coverage(...)` for HST-5 ownership checks. //! //! Safety notes: //! - Warning order is deterministic because `EgressConfig.routes` is a `BTreeMap`. @@ -33,10 +33,10 @@ use std::collections::{BTreeSet, HashSet}; -use ergo_adapter::host::{ensure_handler_coverage, HandlerCoverageError}; use ergo_adapter::AdapterProvides; use super::EgressConfig; +use crate::host::{ensure_handler_coverage, HandlerCoverageError}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum EgressValidationWarning { diff --git a/crates/prod/core/host/src/egress/validation/tests.rs b/crates/prod/core/host/src/egress/validation/tests.rs index 44d9e9f..c8b1fd9 100644 --- a/crates/prod/core/host/src/egress/validation/tests.rs +++ b/crates/prod/core/host/src/egress/validation/tests.rs @@ -24,12 +24,13 @@ use super::*; use crate::egress::{EgressChannelConfig, EgressRoute}; -use ergo_adapter::host::HandlerCoverageError; use ergo_adapter::ContextKeyProvision; use std::collections::{BTreeSet, HashMap, HashSet}; use std::error::Error as _; use std::time::Duration; +use crate::host::HandlerCoverageError; + fn adapter_with_effects(effects: &[&str]) -> AdapterProvides { AdapterProvides { context: HashMap::from([( diff --git a/crates/prod/core/host/src/error.rs b/crates/prod/core/host/src/error.rs index 4180079..dd27296 100644 --- a/crates/prod/core/host/src/error.rs +++ b/crates/prod/core/host/src/error.rs @@ -37,10 +37,10 @@ //! - Variant names and field shapes are live public API because `ergo-host` and //! `sdk-rust` both re-export them and downstream code pattern-matches them. -use ergo_adapter::host::{EffectApplyError, HandlerCoverageError}; use ergo_adapter::{EventBindingError, ExternalEventPayloadError}; use crate::egress::{EgressProcessError, EgressValidationError}; +use crate::host::{EffectApplyError, HandlerCoverageError}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum EgressDispatchFailure { diff --git a/crates/prod/core/host/src/error/tests.rs b/crates/prod/core/host/src/error/tests.rs index 85974ba..2a5211f 100644 --- a/crates/prod/core/host/src/error/tests.rs +++ b/crates/prod/core/host/src/error/tests.rs @@ -8,11 +8,12 @@ use super::*; use crate::egress::{EgressProcessError, EgressValidationError}; -use ergo_adapter::host::{EffectApplyError, HandlerCoverageError}; use ergo_adapter::{EventBindingError, ExternalEventPayloadError}; use std::error::Error as _; use std::time::Duration; +use crate::host::{EffectApplyError, HandlerCoverageError}; + #[test] fn egress_dispatch_failure_channel_accessor_and_display_are_stable() { let ack_timeout = EgressDispatchFailure::AckTimeout { diff --git a/crates/kernel/adapter/src/host/buffering_invoker.rs b/crates/prod/core/host/src/host/buffering_invoker.rs similarity index 76% rename from crates/kernel/adapter/src/host/buffering_invoker.rs rename to crates/prod/core/host/src/host/buffering_invoker.rs index 66b7577..19bbff0 100644 --- a/crates/kernel/adapter/src/host/buffering_invoker.rs +++ b/crates/prod/core/host/src/host/buffering_invoker.rs @@ -1,11 +1,36 @@ +//! host::buffering_invoker +//! +//! Purpose: +//! - Hold the host-owned runtime buffer shim that captures `RunResult.effects` +//! from `RuntimeHandle::run(...)` while presenting a termination-only +//! `RuntimeInvoker` surface to the supervisor. +//! +//! Owns: +//! - `BufferingRuntimeInvoker` and its replace-and-drain buffer lifecycle. +//! - The private `RuntimeResultProvider` helper seam used by local tests. +//! +//! Does not own: +//! - The public `RuntimeInvoker` contract or `RuntimeHandle` semantics; those +//! remain in `ergo_adapter`. +//! - Effect application or capture enrichment; `runner.rs` owns those later +//! host steps. +//! +//! Connects to: +//! - `runner.rs`, which drains pending effects after each supervisor step. +//! - `ergo_adapter::RuntimeHandle`, which remains the engine behind the shim. +//! +//! Safety notes: +//! - Each `run(...)` call replaces the pending-effect buffer rather than +//! extending it, so retries preserve the latest attempt only. +//! - `drain_pending_effects()` is single-use and clears the buffer. + use std::sync::{Arc, Mutex}; use std::time::Duration; -use ergo_runtime::common::ActionEffect; - -use crate::{ +use ergo_adapter::{ EventId, ExecutionContext, GraphId, RunResult, RunTermination, RuntimeHandle, RuntimeInvoker, }; +use ergo_runtime::common::ActionEffect; trait RuntimeResultProvider { fn run_result( @@ -92,8 +117,8 @@ impl RuntimeInvoker for BufferingRuntimeInvoker { #[cfg(test)] mod tests { use super::*; + use ergo_adapter::{ErrKind, EventTime, ExternalEvent, ExternalEventKind}; use ergo_runtime::common::{EffectWrite, Value}; - use ergo_runtime::runtime::ExecutionContext as RuntimeExecutionContext; struct ScriptedProvider { queue: Mutex>, @@ -141,7 +166,7 @@ mod tests { fn replaces_pending_effects_on_retry_attempt() { let provider = Arc::new(ScriptedProvider::new(vec![ RunResult { - termination: RunTermination::Failed(crate::ErrKind::NetworkTimeout), + termination: RunTermination::Failed(ErrKind::NetworkTimeout), effects: vec![effect_for_key("first", 1.0)], }, RunResult { @@ -150,14 +175,20 @@ mod tests { }, ])); let invoker = BufferingRuntimeInvoker::new_with_provider(provider); - let ctx = ExecutionContext::new(RuntimeExecutionContext::default()); + let ctx = ExternalEvent::mechanical_at( + EventId::new("seed"), + ExternalEventKind::Command, + EventTime::default(), + ) + .context() + .clone(); let graph_id = GraphId::new("g"); let event_id = EventId::new("e"); let first = invoker.run(&graph_id, &event_id, &ctx, None); assert_eq!( first, - RunTermination::Failed(crate::ErrKind::NetworkTimeout) + RunTermination::Failed(ErrKind::NetworkTimeout) ); assert_eq!(invoker.pending_effect_count(), 1); @@ -178,7 +209,13 @@ mod tests { effects: vec![effect_for_key("k", 42.0)], }])); let invoker = BufferingRuntimeInvoker::new_with_provider(provider); - let ctx = ExecutionContext::new(RuntimeExecutionContext::default()); + let ctx = ExternalEvent::mechanical_at( + EventId::new("seed"), + ExternalEventKind::Command, + EventTime::default(), + ) + .context() + .clone(); let _ = invoker.run(&GraphId::new("g"), &EventId::new("e"), &ctx, None); assert_eq!(invoker.pending_effect_count(), 1); diff --git a/crates/prod/core/host/src/host/context_store.rs b/crates/prod/core/host/src/host/context_store.rs new file mode 100644 index 0000000..496cdab --- /dev/null +++ b/crates/prod/core/host/src/host/context_store.rs @@ -0,0 +1,44 @@ +//! host::context_store +//! +//! Purpose: +//! - Hold the host-owned mutable context map that effect handlers write and +//! hosted-event binding reads. +//! +//! Owns: +//! - `ContextStore` and its simple set/get/snapshot accessors. +//! +//! Does not own: +//! - Context schema or writability rules; handlers validate those against +//! adapter declarations before mutating the store. +//! +//! Connects to: +//! - `runner.rs`, which merges incoming context over the store snapshot. +//! - `effects.rs`, which mutates the store for handler-owned effect kinds. +//! +//! Safety notes: +//! - The store preserves deterministic ordering via `BTreeMap`. + +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default)] +pub struct ContextStore { + values: BTreeMap, +} + +impl ContextStore { + pub fn new() -> Self { + Self::default() + } + + pub fn snapshot(&self) -> &BTreeMap { + &self.values + } + + pub fn set(&mut self, key: String, value: serde_json::Value) { + self.values.insert(key, value); + } + + pub fn get(&self, key: &str) -> Option<&serde_json::Value> { + self.values.get(key) + } +} diff --git a/crates/kernel/adapter/src/host/coverage.rs b/crates/prod/core/host/src/host/coverage.rs similarity index 89% rename from crates/kernel/adapter/src/host/coverage.rs rename to crates/prod/core/host/src/host/coverage.rs index c2e1566..b499c9b 100644 --- a/crates/kernel/adapter/src/host/coverage.rs +++ b/crates/prod/core/host/src/host/coverage.rs @@ -1,6 +1,29 @@ +//! host::coverage +//! +//! Purpose: +//! - Enforce host ownership coverage for graph-emittable effect kinds accepted +//! by the adapter contract. +//! +//! Owns: +//! - `ensure_handler_coverage(...)` and the typed +//! `HandlerCoverageError` failure surface. +//! +//! Does not own: +//! - Adapter acceptance semantics; it consumes an already-materialized +//! `AdapterProvides`. +//! - Egress routing config parsing or handler implementation details. +//! +//! Connects to: +//! - `runner.rs` and `egress/validation.rs`, which use this as the canonical +//! HST-5 ownership gate. +//! +//! Safety notes: +//! - Coverage is checked only for graph-emittable kinds that the adapter +//! contract accepts. + use std::collections::{BTreeSet, HashSet}; -use crate::AdapterProvides; +use ergo_adapter::AdapterProvides; #[derive(Debug, Clone, PartialEq, Eq)] pub enum HandlerCoverageError { diff --git a/crates/kernel/adapter/src/host/effects.rs b/crates/prod/core/host/src/host/effects.rs similarity index 91% rename from crates/kernel/adapter/src/host/effects.rs rename to crates/prod/core/host/src/host/effects.rs index 9d3db8e..8d09580 100644 --- a/crates/kernel/adapter/src/host/effects.rs +++ b/crates/prod/core/host/src/host/effects.rs @@ -1,5 +1,29 @@ -use crate::host::ContextStore; -use crate::AdapterProvides; +//! host::effects +//! +//! Purpose: +//! - Define the host-owned effect-handler seam and the default +//! `set_context` handler used by the canonical runner. +//! +//! Owns: +//! - `EffectHandler`, `SetContextHandler`, `AppliedWrite`, and +//! `EffectApplyError`. +//! +//! Does not own: +//! - Runtime effect production or the accepted effect contract itself; those +//! come from the runtime and adapter layers. +//! - Hosted-runner orchestration, capture enrichment, or egress dispatch. +//! +//! Connects to: +//! - `runner.rs`, which dispatches handler-owned effects through these types. +//! - `context_store.rs`, which stores applied writes. +//! +//! Safety notes: +//! - `SetContextHandler` validates declared key, writable, and type before +//! mutating `ContextStore`. +//! - Partial writes are not rolled back when a later write fails. + +use super::context_store::ContextStore; +use ergo_adapter::AdapterProvides; use ergo_runtime::common::{ActionEffect, Value}; #[derive(Debug, Clone, PartialEq)] @@ -169,7 +193,7 @@ fn runtime_value_to_json(value: &Value) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::provides::ContextKeyProvision; + use ergo_adapter::ContextKeyProvision; use ergo_runtime::common::EffectWrite; use std::collections::HashMap; diff --git a/crates/prod/core/host/src/host/mod.rs b/crates/prod/core/host/src/host/mod.rs new file mode 100644 index 0000000..fa40790 --- /dev/null +++ b/crates/prod/core/host/src/host/mod.rs @@ -0,0 +1,34 @@ +//! host +//! +//! Purpose: +//! - Group the host-owned support types that implement the canonical effect +//! loop, context storage, and host-side runtime buffering used by +//! `HostedRunner`. +//! +//! Owns: +//! - `BufferingRuntimeInvoker`, `ContextStore`, handler coverage checks, and +//! the host-owned effect-handler types. +//! +//! Does not own: +//! - Kernel runtime invocation contracts from `ergo_adapter`. +//! - Hosted-runner orchestration, replay, or top-level public error shaping. +//! +//! Connects to: +//! - `runner.rs`, which is the dominant consumer of these support types. +//! - `error.rs` and `egress/validation.rs`, which expose the typed failure +//! surfaces produced here. +//! +//! Safety notes: +//! - This module is host-owned regardless of its former adapter-crate location. +//! - `RuntimeResultProvider` remains private to `buffering_invoker.rs`; it is a +//! host testability seam, not a public contract. + +mod buffering_invoker; +mod context_store; +mod coverage; +mod effects; + +pub use buffering_invoker::BufferingRuntimeInvoker; +pub use context_store::ContextStore; +pub use coverage::{ensure_handler_coverage, HandlerCoverageError}; +pub use effects::{AppliedWrite, EffectApplyError, EffectHandler, SetContextHandler}; diff --git a/crates/prod/core/host/src/lib.rs b/crates/prod/core/host/src/lib.rs index 4a761e1..fe5c975 100644 --- a/crates/prod/core/host/src/lib.rs +++ b/crates/prod/core/host/src/lib.rs @@ -35,6 +35,7 @@ mod error; mod expand_diagnostics; mod gen_docs_usecase; mod graph_dot_usecase; +pub mod host; mod manifest_usecases; mod protocol; #[allow(clippy::result_large_err)] diff --git a/crates/prod/core/host/src/runner.rs b/crates/prod/core/host/src/runner.rs index e53ef46..b89e320 100644 --- a/crates/prod/core/host/src/runner.rs +++ b/crates/prod/core/host/src/runner.rs @@ -17,7 +17,8 @@ //! live in `usecases/live_prep.rs` and `usecases/live_run.rs`. //! - Ingress process protocol behavior, which lives in `usecases/process_driver.rs`. //! - Replay comparison semantics, which live in `replay.rs` and `ergo_supervisor`. -//! - `set_context` key/writable/type enforcement, which belongs to the adapter host effect layer. +//! - `set_context` key/writable/type enforcement, which belongs to the host +//! effect layer. //! //! Connects to: //! - `capture_enrichment.rs` for persisted host capture sidecars. @@ -53,10 +54,6 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::{Arc, Mutex}; -use ergo_adapter::host::{ - ensure_handler_coverage, AppliedWrite, BufferingRuntimeInvoker, ContextStore, EffectHandler, - SetContextHandler, -}; use ergo_adapter::{ bind_semantic_event_with_binder, compile_event_binder, AdapterProvides, EventId, EventTime, ExternalEvent, ExternalEventKind, GraphId, RunTermination, RuntimeHandle, @@ -80,6 +77,10 @@ use crate::egress::{ use crate::error::{ EgressDispatchFailure, HostedEgressValidationError, HostedEventBuildError, HostedStepError, }; +use crate::host::{ + ensure_handler_coverage, AppliedWrite, BufferingRuntimeInvoker, ContextStore, EffectApplyError, + EffectHandler, SetContextHandler, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostedEvent { @@ -856,7 +857,7 @@ impl HostedRunner { } (None, false) => { return Err(HostedStepError::from( - ergo_adapter::host::EffectApplyError::UnhandledEffectKind { + EffectApplyError::UnhandledEffectKind { kind: effect.kind.clone(), }, )); diff --git a/crates/prod/core/host/src/runner/tests.rs b/crates/prod/core/host/src/runner/tests.rs index 99b41f3..d74e77e 100644 --- a/crates/prod/core/host/src/runner/tests.rs +++ b/crates/prod/core/host/src/runner/tests.rs @@ -9,7 +9,6 @@ //! downstream CLI/SDK suites. use super::*; -use ergo_adapter::host::{EffectApplyError, HandlerCoverageError}; use ergo_adapter::{ContextKeyProvision, RuntimeHandle}; use ergo_adapter::{EventBindingError, ExternalEventPayloadError}; use ergo_runtime::catalog::{build_core_catalog, core_registries}; @@ -24,6 +23,7 @@ use std::time::Duration; use crate::egress::{EgressChannelConfig, EgressConfig, EgressRoute}; use crate::error::{HostedEgressValidationError, HostedEventBuildError}; +use crate::host::{EffectApplyError, HandlerCoverageError}; fn build_context_set_bool_graph() -> ExpandedGraph { let mut nodes = HashMap::new(); diff --git a/docs/ledger/decisions/s2.3-plan.md b/docs/ledger/decisions/s2.3-plan.md new file mode 100644 index 0000000..c71a077 --- /dev/null +++ b/docs/ledger/decisions/s2.3-plan.md @@ -0,0 +1,235 @@ +--- +Authority: PROJECT +Date: 2026-04-19 +Decision-Owner: Sebastian (Architect) +Participants: Codex +Status: DRAFT +Scope: v1 +Parent-Decision: ../../system/freeze-v1.md +Resolves: S2.3 planning +--- + +# S2.3 Plan: Relocate Host Behavior out of `ergo-adapter` + +## Scope + +Pre-authorized by [`freeze-v1.md` §4.3](../../system/freeze-v1.md). Relocate +host-behavior support code from `crates/kernel/adapter/src/host/` into +`crates/prod/core/host/` without changing `RuntimeHandle::run`, `RunResult`, +or any supervisor-visible seam. This is a pure ownership/layout move ahead of +S2.2. + +Items in scope: + +- `BufferingRuntimeInvoker` +- `ContextStore` +- `ensure_handler_coverage` and `HandlerCoverageError` +- `EffectHandler`, `SetContextHandler`, `AppliedWrite`, and `EffectApplyError` +- `RuntimeResultProvider` plus its `impl RuntimeResultProvider for RuntimeHandle` + +## Re-Verification at Current HEAD + +- Current source inventory under `crates/kernel/adapter/src/host/`: + - `buffering_invoker.rs` + - `context_store.rs` + - `coverage.rs` + - `effects.rs` + - `mod.rs` +- Workspace grep for `ergo_adapter::host` at current HEAD found only host-crate + consumers and their tests: + - `crates/prod/core/host/src/runner.rs` + - `crates/prod/core/host/src/error.rs` + - `crates/prod/core/host/src/egress/validation.rs` + - `crates/prod/core/host/src/runner/tests.rs` + - `crates/prod/core/host/src/error/tests.rs` + - `crates/prod/core/host/src/egress/validation/tests.rs` +- No `ergo_adapter::host` imports were found in `crates/prod/clients/sdk-rust`, + `crates/prod/clients/cli`, or anywhere outside `crates/prod/core/host/`. +- Indirect public blast radius is larger than the direct import list: + `EffectApplyError` and `HandlerCoverageError` are payload types on public + `ergo-host` enums in `crates/prod/core/host/src/error.rs` and + `crates/prod/core/host/src/egress/validation.rs`, and those enums are + re-exported by `ergo-host` and `ergo-sdk-rust`. S2.3 does not change their + variant names or field shapes, but it does move the concrete type path for + callers that name these payload types directly. +- Crate graph check: + - `ergo-host` depends on `ergo-adapter` + - `ergo-adapter` does not and cannot depend on `ergo-host` + - therefore an adapter-side re-export of relocated prod-owned code would + create a dependency inversion / cycle unless scope widened beyond S2.3 + +## Re-Export Strategy Decision + +Options evaluated: + +- Option 1: shim with deprecation in `ergo_adapter::host` +- Option 2: permanent shim in `ergo_adapter::host` +- Option 3: no shim; same-commit migration + +Recommendation: **Option 3**. + +Rationale: + +- Options 1 and 2 are not cleanly viable under the current crate graph. Once the + code lives in `ergo-host`, `ergo-adapter` cannot re-export it without gaining + a dependency on `ergo-host`, which would invert the layering. +- A fake shim that duplicates definitions inside `ergo-adapter` would stop being + a pure relocation and would leave the same ownership confusion S2.3 is meant + to remove. +- Workspace audit found no direct `ergo_adapter::host` consumers outside + `ergo-host`, so an atomic same-commit path migration is bounded and auditable. + +Residual risk: + +- Out-of-workspace users of `ergo_adapter::host` would see a path break. That is + not visible from repo-local grep. If external compatibility is required, that + needs a wider compat plan than S2.3 allows. +- Downstream callers that pattern-match `HostedStepError::EffectApply`, + `HostedStepError::HandlerCoverage`, or `EgressValidationError::Coverage` and + also import the nested payload types by their current + `ergo_adapter::host::{...}` paths will need path updates. The public error + shapes stay the same; the module ownership path changes. + +## RuntimeResultProvider Disposition + +Recommendation: move `RuntimeResultProvider` into the relocated +`buffering_invoker.rs` and keep it private there. + +Rationale: + +- It is only a host-side testability/helper seam for `BufferingRuntimeInvoker`. +- It is not a kernel contract and should not remain in `ergo-adapter`. +- Because the trait becomes local to `ergo-host`, `impl RuntimeResultProvider + for ergo_adapter::RuntimeHandle` can legally move with it without creating an + orphan-rule problem or a dependency inversion. + +## Planned Module Layout + +Create a new support submodule under `crates/prod/core/host/src/host/`: + +- `mod.rs` +- `buffering_invoker.rs` +- `context_store.rs` +- `coverage.rs` +- `effects.rs` + +Expose it as `pub mod host;` from `crates/prod/core/host/src/lib.rs`. + +Why this layout: + +- Keeps the relocated support types grouped under one non-canonical namespace. +- Makes the import migration mechanical: `ergo_adapter::host::...` becomes + `ergo_host::host::...` for external callers and `crate::host::...` inside the + host crate. +- Avoids widening the root `ergo_host::*` facade with additional low-level + support types during the relocation itself. + +## Planned File Changes + +- `crates/kernel/adapter/src/lib.rs` + - Remove `pub mod host;` +- Delete: + - `crates/kernel/adapter/src/host/mod.rs` + - `crates/kernel/adapter/src/host/buffering_invoker.rs` + - `crates/kernel/adapter/src/host/context_store.rs` + - `crates/kernel/adapter/src/host/coverage.rs` + - `crates/kernel/adapter/src/host/effects.rs` +- Add: + - `crates/prod/core/host/src/host/mod.rs` + - `crates/prod/core/host/src/host/buffering_invoker.rs` + - `crates/prod/core/host/src/host/context_store.rs` + - `crates/prod/core/host/src/host/coverage.rs` + - `crates/prod/core/host/src/host/effects.rs` +- Update imports/comments/tests in: + - `crates/prod/core/host/src/lib.rs` + - `crates/prod/core/host/src/runner.rs` + - `crates/prod/core/host/src/error.rs` + - `crates/prod/core/host/src/egress/validation.rs` + - `crates/prod/core/host/src/runner/tests.rs` + - `crates/prod/core/host/src/error/tests.rs` + - `crates/prod/core/host/src/egress/validation/tests.rs` + +## Migration Sequence + +Recommendation: **Option A, single atomic commit**. + +Rationale: + +- The bounded in-repo consumer set makes a single move auditable and keeps the + relocation free of temporary compatibility structure. +- The import graph is narrow enough that one commit can move the files, update + imports, update docs, and keep the workspace valid before and after. +- S2.3 is explicitly scoped as relocation only; a staged sequence would add + transitional structure that the final state does not want. + +Staged fallback if execution needs it: + +- A compile-safe Option B does exist, but only as a temporary **host-side** + bridge, not an adapter-side shim: + 1. add `pub mod host` in `ergo-host` that re-exports `ergo_adapter::host::*` + 2. retarget in-repo host imports/tests to `crate::host::{...}` / + `ergo_host::host::{...}` + 3. replace that bridge with the relocated owned files + 4. remove `pub mod host;` and `src/host/*` from `ergo-adapter` +- This is viable because `ergo-host` already depends on `ergo-adapter`. +- It is **not** the recommended primary plan because it adds a transitional + surface that S2.3 does not want as its end state. + +## Docs and Re-Anchor Updates + +- `docs/system/freeze-v1.md` + - remove the S2.3 carve-out row in §4.3 after the move lands + - update any remaining text in §4 that still names + `crates/kernel/adapter/src/host/` + - note: at current HEAD, §3.4 is behavior-only and does not carry explicit + adapter-host file paths, so S2.3 should not require a substantive §3 + rewrite +- `docs/system/host-boundary.md` + - update the inventory table entries for adapter-host files + - update explicit path references in §§3, 6, 9, 10, and 11 that still cite + `crates/kernel/adapter/src/host/...` +- `docs/ledger/decisions/v1-host-boundary-migration.md` + - update the migration record text that still names + `crates/kernel/adapter/src/host/` as the current location +- code comments/headers + - update `crates/prod/core/host/src/egress/validation.rs` to stop citing + `ergo_adapter::host::ensure_handler_coverage(...)` + +## Test Migration + +- Inline tests currently move with the relocated files: + - `buffering_invoker.rs` tests move into + `crates/prod/core/host/src/host/buffering_invoker.rs` + - `coverage.rs` tests move into + `crates/prod/core/host/src/host/coverage.rs` + - `effects.rs` tests move into + `crates/prod/core/host/src/host/effects.rs` +- `context_store.rs` has no dedicated tests today. +- Host-crate tests that currently import `ergo_adapter::host::{...}` update to + `crate::host::{...}` or `ergo_host::host::{...}` as appropriate. +- No `crates/kernel/adapter/src/tests.rs` cases currently exercise these moved + items; adapter tests for `RuntimeHandle::run` stay in place for S2.2. +- Indirect host regression coverage that needs no import edit but remains part of + the verification surface includes: + - `crates/prod/core/host/src/usecases/tests/live_prep.rs` + - `crates/prod/core/host/src/usecases/tests/live_run.rs` + +## Verification and Rollback + +- Verification after execution: + - `cargo test -p ergo-adapter` + - `cargo test -p ergo-host` + - `cargo test -p ergo-sdk-rust` + - `cargo test --workspace` + - workspace grep confirming no remaining `ergo_adapter::host` imports outside + intended docs/history +- Rollback is a clean `git revert`: this plan assumes pure relocation and import + rewrites only, with no signature changes or behavior changes. If execution + appears to require seam changes, stop and re-scope instead of hiding them + inside S2.3. + +## Commit Note + +The S2.3 commit body should cite [`freeze-v1.md` §4.3](../../system/freeze-v1.md) +as the pre-authorization and link this plan: +`docs/ledger/decisions/s2.3-plan.md`. diff --git a/docs/ledger/decisions/v1-host-boundary-migration.md b/docs/ledger/decisions/v1-host-boundary-migration.md new file mode 100644 index 0000000..b6b7657 --- /dev/null +++ b/docs/ledger/decisions/v1-host-boundary-migration.md @@ -0,0 +1,178 @@ +--- +Authority: PROJECT +Date: 2026-04-20 +Decision-Owner: Sebastian (Architect) +Participants: Codex, Auggie +Status: DECIDED +Scope: v1 +Parent-Decision: ../gap-work/closed/sup2-alignment.md +Resolves: none (forward commitment) +--- + +# Decision: v1 Host-Boundary Migration — Forward Commitment + +## Context + +This record is the forward companion to a retrospective closure ledger +rather than a child decision of a prior ruling; the `Parent-Decision` +field above names that ledger. + +The v0 → v1 host-boundary migration was tracked retrospectively by +[`sup2-alignment.md`](../gap-work/closed/sup2-alignment.md) and closed +on 2026-03-15 against four enumerated gaps (D1–D4). That closure was +valid for what it tracked. + +A forensic audit of post-closure state on 2026-04-19 discovered three +residual v0 shapes the closure gate did not catch: + +- `DecisionLogEntry.effects: Vec` still exists in + `crates/kernel/supervisor/src/lib.rs`. Every production call site + writes `vec![]`, so the field is vestigial v0 residue. +- `RunResult` is still publicly importable from the kernel adapter + crate. Any holder of a `RuntimeHandle` can observe effects directly + off the return value, so `SUP-2` is preserved by the buffering + shim's existence rather than enforced at the type level. +- At the time of the Session 1 audit, host-behavior modules + (`BufferingRuntimeInvoker`, `ContextStore`, `ensure_handler_coverage`, + the effect-handler module) still lived under + `crates/kernel/adapter/src/host/` rather than + `crates/prod/core/host/`. That file path was a v0 migration artifact. + Session 2 S2.3 relocates them into `crates/prod/core/host/src/host/`. + +These are code-shape residuals, not semantic drift. Runtime behavior +at HEAD `7784f46f` matches the v1 boundary described in +[`host-boundary.md`](../../system/host-boundary.md); the types and +module layout encoding that behavior still carry v0 shapes in places. + +The audit also surfaced a process finding: the v0 freeze +([`freeze.md`](../../system/freeze.md)) referenced a joint-escalation +convention for v1-only changes that was never defined in any +reachable doc and was not honored in practice. The 046dd4b spec +rewrite landed without escalation, which is part of what motivated +this pass. + +--- + +## Ruling + +1. **Invariant authority.** The v1 host boundary specified in + [`docs/system/host-boundary.md`](../../system/host-boundary.md) is + the authoritative invariant reference for host / supervisor / + adapter ownership, the provenance trinity, effect-buffer + lifecycle, context-merge precedence, and the strict-replay + contract. Future work referencing any of those surfaces resolves + against that document. + +2. **Symbol-level commitment.** The v1 architecture freeze surface in + [`docs/system/freeze-v1.md`](../../system/freeze-v1.md) binds the + symbol-level commitments that encode the invariants. Changes to + symbols in its §3 follow the freeze-v1.md §6 change protocol + (commit-body acknowledgment naming which symbol changed and why). + +3. **Residual debt schedule.** Session 2 removes the three residual + v0 shapes via pre-authorized transformations recorded in + [`freeze-v1.md §4`](../../system/freeze-v1.md): + + - S2.1 removes `DecisionLogEntry.effects` + - S2.2 redesigns the runtime seam so `RuntimeHandle::run`'s public + API returns `RunTermination` only (effect-observation mechanism + chosen during S2.2 planning) + - S2.3 relocates host-behavior modules to `crates/prod/core/host/` + + Executing these transformations during Session 2 does not require + re-escalation. + +4. **Escalation-protocol lightening.** The v0 freeze's notional + joint-escalation convention is not carried into v1. The v1 freeze + uses a single-sentence commit-body-acknowledgment rule. Rationale: + lighter discipline that will be followed beats heavier discipline + that won't. This is a solo-dev-plus-AI codebase; protocol weight + has to be proportionate to enforcement capacity. + +--- + +## Implementation + +The forward commitments are realized through three companion +artifacts. This decision record does not itself specify new behavior, +introduce rule IDs, or enumerate symbols; it records the +forward-commitment decision and cross-references the documents that +carry the content. + +- [`host-boundary.md`](../../system/host-boundary.md) — CANONICAL v1 + invariant specification +- [`freeze-v1.md`](../../system/freeze-v1.md) — CANONICAL v1 + symbol-level freeze +- Session task register — S2.1 / S2.2 / S2.3 planning preconditions + (step-zero audit for S2.2 complete; re-export compatibility question + for S2.3 recorded) + +--- + +## Methodology + +The forensic audit used a four-task pattern. Recording it here so +future boundary-migration closure gates are replayable without +rediscovering the method. + +1. **Post-freeze commits against manifest.** Enumerate every commit + landed after the retrospective closure (`sup2-alignment.md`, + 2026-03-15) and compare each against the post-closure manifest of + what was supposed to be true. *Catches:* commits that silently + violated closure without triggering a gate. + +2. **Ledger-closure direction classification.** For each closed + ledger in `gap-work/closed/`, classify the closure as + *retrospective* (tracked known gaps to closure) vs *forward* + (committed to state beyond the closure's enumeration). *Catches:* + retrospective closures that left forward-state commitments + unwritten and therefore undefended. + +3. **Align/reconcile commit-message grep.** Search commit history for + `align|reconcile|sync` patterns and inspect the diffs. *Catches:* + invisible prior drift — reconciliation commits are evidence drift + had occurred even when the original drift wasn't flagged at the + time. + +4. **Symbol-level diff of freeze-state vs HEAD.** For every symbol + the freeze committed to, diff the symbol's current shape at HEAD + against its shape at the freeze anchor. *Catches:* shape-only + follow-through or residual v0 encoding that didn't register as + semantic drift. + +Outputs of this audit are `host-boundary.md §11` (26-row +claim-verification pass at HEAD `7784f46f`), `freeze-v1.md §3` +(symbol list verified via grep against current paths), and +`freeze-v1.md §4` / the §Context of this record (the three residual +shapes and their Session 2 disposition). + +--- + +## What This Does NOT Decide + +- **New semantics.** All runtime / ownership / provenance / replay semantics are covered by `host-boundary.md` and inherited from its source material. +- **New rule IDs.** No `SUP-*` / `HST-*` / `REP-*` additions. +- **Session 2 implementation detail.** S2.1 / S2.2 / S2.3 planning artifacts are produced by Codex when each task starts; this record names only the pre-authorization chain. +- **Amendment to `sup2-alignment.md`.** That ledger stays CLOSED and authoritative for the four gaps it closed (D1–D4). This record is its forward companion, not its successor. +- **Primitive ontology.** `freeze.md` (v0 FROZEN) remains authoritative for Source / Compute / Trigger / Action semantics. + +--- + +## Impacted Files + +No direct file impact from this decision itself; the companion +documents and the Session 2 work carry the content. + +Artifacts produced by the Session 1 pass: + +- `docs/system/host-boundary.md` (new — invariant spec) +- `docs/system/freeze-v1.md` (new — freeze surface) +- `docs/system/kernel.md` (edit — workstream log entry C.1; v1 pointer) +- `docs/system/kernel-prod-separation.md` (edit — reference cross-links) +- `docs/orchestration/supervisor.md` (edit — version-tag banner) +- `docs/orchestration/adapter.md` (edit — version-tag banner) +- `docs/invariants/08-replay.md` (edit — architectural-framing prepend) +- `docs/ledger/gap-work/closed/sup2-alignment.md` (CLOSED, unchanged) + +Code changes are scheduled through Session 2; see `freeze-v1.md §4` +and the Session task register. diff --git a/docs/system/freeze-v1.md b/docs/system/freeze-v1.md new file mode 100644 index 0000000..f1f189d --- /dev/null +++ b/docs/system/freeze-v1.md @@ -0,0 +1,181 @@ +--- +Authority: CANONICAL +Version: v1 +Last Updated: 2026-04-20 +Owner: Sebastian (Architect) +Scope: v1 architecture freeze surface — supervisor termination-only contract, host-owned effect boundary, provenance trinity, persisted-format types +Change Rule: Commit-body acknowledgment (see §6) +--- + +# v1 Architecture Freeze Declaration + +## 0. Anchor + +HEAD: `7784f46f034798de70ab24f8f3dfb31c9e5142ad` + +This declaration freezes the v1 host-boundary architecture surface as +observed at the HEAD above. Each symbol in §3 is named with its crate +path at this commit. The invariant specification this declaration +commits to is [`host-boundary.md`](host-boundary.md) (CANONICAL v1). + +One pre-authorized code change remains scheduled after this freeze +landed and is recorded in §4 so that executing it does not read as a +breach: + +- Session 2 S2.2 — redesign the runtime seam to enforce termination-only on `RuntimeHandle::run`'s public API (effect-observation mechanism chosen during S2.2 planning; see §4.1) + +--- + +## 1. What This Freezes + +The v1 freeze covers **symbols**, not **files**. Physical file layout +is allowed to move without touching this list. + +Freeze categories: + +1. **Supervisor termination-only contract** — what the kernel supervisor observes and what it does not +2. **Runtime seam** — `RuntimeHandle` / `RuntimeInvoker::run` signature shape +3. **Provenance trinity** — `adapter_provenance`, `runtime_provenance`, `egress_provenance` schemes and authoring locus +4. **Host-owned semantic authority** — `ContextStore`, effect loop, capture enrichment +5. **Persisted formats** — capture-bundle types that cross the serde boundary + +--- + +## 2. Relationship to `freeze.md` (v0) + +This document **adds** the v1 architecture layer. It does not replace +[`freeze.md`](freeze.md) (v0 primitive-ontology freeze). + +| Layer | Document | Status | +|---|---|---| +| Primitive ontology (Source/Compute/Trigger/Action, wiring rules, execution model) | `freeze.md` | v0 FROZEN, still authoritative | +| Host-boundary architecture (this document) | `freeze-v1.md` | v1 CANONICAL | +| Invariant specification (the "why") | `host-boundary.md` | v1 CANONICAL | +| Canonical HST/SUP/REP rule IDs | `07-orchestration.md`, `08-replay.md`, `rule-registry.md` | v1 CANONICAL | + +A future change touching the primitive ontology constitutes a v2 +decision in the sense of `freeze.md`. This document does not relax +that. + +--- + +## 3. Frozen Surface + +Every entry names a symbol, its crate path at HEAD `7784f46f`, and the +behavior it commits to. Files may move; symbols and contracts do not, +except under §4 pre-authorized transformations. + +### 3.1 Supervisor Termination-Only Contract + +| Symbol | Path | Commitment | +|---|---|---| +| `Supervisor` (struct) | `crates/kernel/supervisor/src/lib.rs` | Mechanical scheduler; no observation of `ActionEffect`, `RunResult`, or domain payloads (`SUP-2`) | +| `DecisionLog` (trait) | `crates/kernel/supervisor/src/lib.rs` | Trait surface is write-only: declares `log(...)` and nothing else (`SUP-7`) | +| `NO_ADAPTER_PROVENANCE` (const `"none"`) | `crates/kernel/supervisor/src/lib.rs` | Sentinel for adapterless captures; `REP-7` bidirectional guard keys on this exact string | +| `RunTermination` (enum, `Serialize`/`Deserialize`) | `crates/kernel/adapter/src/lib.rs` | Persisted on `EpisodeInvocationRecord.termination`; variant set and payload shape are part of the capture-bundle serde surface (adding variants or widening payload requires a `capture_version` bump) | +| `EpisodeInvocationRecord` (struct, `Serialize`/`Deserialize`) | `crates/kernel/supervisor/src/lib.rs` | Capture-bundle decision record; field set is persisted | +| `CapturingDecisionLog` / `CapturingSession` | `crates/kernel/supervisor/src/capture.rs` | Kernel-side capture wrapper; authors non-effect decision fields only (host owns `effects`, `intent_acks`, `interruptions` per §3.4) | + +### 3.2 Runtime Seam + +| Symbol | Path | Commitment | +|---|---|---| +| `RuntimeInvoker` (trait) | `crates/kernel/adapter/src/lib.rs` | Kernel-owned contract for invoking a runtime; termination-only observable surface to the supervisor | +| `RuntimeHandle` (struct) | `crates/kernel/adapter/src/lib.rs` | Adapter-layer handle used by the supervisor to drive runtime execution | +| `RuntimeHandle::run` | `crates/kernel/adapter/src/lib.rs` | Signature change pre-authorized; see §4.1 carve-out | + +### 3.3 Provenance Trinity + +| Symbol | Path | Commitment | +|---|---|---| +| `adapter_provenance` scheme | `crates/kernel/adapter/src/provenance.rs::fingerprint` | String format `adapter:{id}@{version};sha256:{hex}`; SHA-256 over key-sorted canonicalized manifest JSON | +| `runtime_provenance` scheme | `crates/kernel/runtime/src/provenance.rs::compute_runtime_provenance` | String format `rpv1:sha256:{hex}`; `Rpv1` is the only defined scheme in v1 | +| `egress_provenance` authoring locus | `crates/prod/core/host/src/runner.rs` | Host stamps the bundle post-step; kernel strict-replay validator does not gate on this field (`REP-7` covers adapter + runtime only) | +| `CaptureBundle.{adapter_provenance, runtime_provenance, egress_provenance}` | `crates/kernel/supervisor/src/lib.rs` | Field names and types (two `String`, one `Option`) are persisted | + +### 3.4 Host-Owned Semantic Authority + +The following behaviors are host-owned regardless of current file +location or support-module layout. + +| Behavior | Ownership commitment | +|---|---| +| `ContextStore` read/write authority | Host; supervisor does not observe | +| Effect loop (drain + dispatch) | Host; supervisor does not observe | +| Handler-owned effect application (`SetContextHandler::apply`) | Host | +| Egress dispatch | Host | +| Capture enrichment of `decisions[i].{effects, intent_acks, interruptions}` via `enrich_bundle_with_host_artifacts` | Host is the authoritative writer, keyed on decision index; kernel capture initializes empty `effects`, and host finalization binds authoritative non-empty effects later | +| Context merge precedence (incoming > store) | Host (`HST-6`) | +| Effect-handler coverage gate (`ensure_handler_coverage`) | Host (`HST-5`) | + +### 3.5 Persisted Formats + +Capture-bundle types that cross the serde boundary. Field-level +changes require explicit serde-compatibility handling (`capture_version` +bump or alias path). + +| Symbol | Path | Notes | +|---|---|---| +| `CaptureBundle` | `crates/kernel/supervisor/src/lib.rs` | Current `capture_version` is `v3`; kernel replay enforces strict match | +| `EpisodeInvocationRecord` | `crates/kernel/supervisor/src/lib.rs` | See §3.1 | +| `ExternalEventRecord` | `crates/kernel/adapter/src/capture.rs` | SHA-256 hash contract (`REP-1`); re-exported into supervisor via `use ergo_adapter::capture::ExternalEventRecord` | +| `CapturedActionEffect` | `crates/kernel/supervisor/src/lib.rs` | `(effect, effect_hash)` comparison pair used by strict replay (`replay.rs:328-345`) | +| `RunTermination` | `crates/kernel/adapter/src/lib.rs` | See §3.1 | + +--- + +## 4. Pre-Authorized Transformations + +The following code changes are pre-authorized by this freeze. +Executing them during Session 2 is not a breach and does not require +re-escalation. This document is re-anchored once each lands. + +### 4.1 S2.2 — `RuntimeHandle::run` seam redesign + +**Current signature at HEAD `7784f46f`:** `RuntimeHandle::run(...) -> RunResult { termination, effects }`. Any holder of a `RuntimeHandle` — including prod-side callers outside the buffering shim — can observe effects directly off the return value, so `SUP-2` is preserved by the shim's existence rather than enforced by the type. + +**Approved transformation:** `RuntimeHandle::run`'s public signature returns `RunTermination` only. Effects are observable through a host-facing seam whose concrete mechanism — a sink parameter on a separate method, a kernel-defined observation trait implemented only by the buffering shim, or an equivalent construction — is chosen during S2.2 planning. The mechanism must prevent any caller holding a public `RuntimeHandle` from observing effects through the public API; placing a sink parameter on `run` itself is not pre-authorized, because it would recreate the current trust gap in a new shape. After S2.2 lands, `SUP-2` is type-enforced by the public seam rather than preserved by the shim's existence. + +**Concrete sink shape:** Deferred to S2.2 planning. Candidate shapes under consideration for the sink itself (orthogonal to where the sink lives): mutable `Vec`, kernel-defined trait, caller closure. A prod-defined type is ruled out (it would invert the crate dependency). + +**Pre-authorized:** Executing this transformation during S2.2 does not require re-escalation. Codex's five-site audit of the current `RunResult`-producing sites in `adapter/src/lib.rs` (lines 459, 468, 476, 498, 512) is the step-zero input to S2.2 planning. + +**Re-anchor:** Once S2.2 lands, §3.2 of this document is updated to reflect the final signature shape, and this §4.1 row is removed. + +--- + +## 5. Explicit Non-Scope + +This freeze does not cover: + +- Physical module/file locations (covered by S2.3; layout is free to move) +- Function-internal implementation details where no symbol or serde shape is involved +- `RunResult` (transitional; subsumed by the S2.2 transformation in §4.1) +- v0 primitive ontology (covered by `freeze.md`) +- Authoring layer (covered by `freeze.md` §7) +- Workflow/process rules (`DOC-GATE-1` and similar) +- SDK composition (`SDK-CANON-*`; covered by `kernel-prod-separation.md`) + +--- + +## 6. Change Protocol + +**Rule:** Changes to symbols in §3 require explicit acknowledgment in the commit body naming which symbol changed and why. + +That is the entire protocol. + +**Rationale note:** The v0 freeze (`freeze.md`) referenced a joint-escalation workflow that was not defined in any reachable doc and was not honored in practice. Lighter discipline that will be followed beats heavier discipline that won't. This is a solo-dev-plus-AI codebase; protocol weight has to be proportionate to enforcement capacity. + +The symbol-specific scope of §3 keeps the surface narrow enough that drift on it is notable. When drift does happen, `host-boundary.md` (invariant spec) and the Session 1 retrospective (Artifact A, forthcoming) provide the working memory for cheap reconstruction. + +--- + +## 7. Companion Documents + +- [`host-boundary.md`](host-boundary.md) — v1 CANONICAL invariant specification; the "why" behind every §3 commitment +- [`freeze.md`](freeze.md) — v0 primitive-ontology freeze; still authoritative for Source/Compute/Trigger/Action +- [`kernel.md`](kernel.md) — v0 kernel closure declaration; this document is its v1 successor for host-boundary concerns +- [`kernel-prod-separation.md`](kernel-prod-separation.md) — kernel/prod boundary rules; §3.4 of this document names the same boundary in symbol terms +- [`rule-registry.md`](../invariants/rule-registry.md) — canonical HST/SUP/REP rule IDs +- [`07-orchestration.md`](../invariants/07-orchestration.md) — orchestration-phase invariant tables +- [`08-replay.md`](../invariants/08-replay.md) — replay-phase invariant tables diff --git a/docs/system/host-boundary.md b/docs/system/host-boundary.md new file mode 100644 index 0000000..fc75b7b --- /dev/null +++ b/docs/system/host-boundary.md @@ -0,0 +1,525 @@ +--- +Authority: CANONICAL +Version: v1 +Last Updated: 2026-04-19 +Owner: Sebastian (Architect) +Scope: v1 host boundary — effect loop, context store, capture enrichment, provenance trinity +Change Rule: Operational log +--- + +# v1 Host Boundary — Invariants and Enforcement + +## 0. Anchor + +HEAD: `7784f46f034798de70ab24f8f3dfb31c9e5142ad` + +This document describes the v1 host boundary as it exists at the HEAD +above. Every §3–§8 claim cites a file path and line range in the code +tree at this commit. Downstream rewrites of `07-orchestration.md`, +`08-replay.md`, `supervisor.md`, and `adapter.md` are deferred to +Session 3 and must reconcile against §9. + +Files described (short blob hash, path, line count at HEAD): + +| hash | path | lines | +|---|---|---:| +| `f14eb7b6d4d5` | `crates/kernel/supervisor/src/lib.rs` | 562 | +| `357cbd4296bd` | `crates/kernel/supervisor/src/capture.rs` | 472 | +| `40887f188608` | `crates/kernel/supervisor/src/replay.rs` | 371 | +| `79d84f7d9634` | `crates/kernel/adapter/src/lib.rs` | 728 | +| `6dc42bd9f215` | `crates/kernel/adapter/src/provenance.rs` | 107 | +| `0ecf96723dbe` | `crates/prod/core/host/src/host/buffering_invoker.rs` | 219 | +| `496cdab95622` | `crates/prod/core/host/src/host/context_store.rs` | 44 | +| `b499c9b41eec` | `crates/prod/core/host/src/host/coverage.rs` | 194 | +| `36ea02697c3d` | `crates/kernel/runtime/src/provenance.rs` | 397 | +| `e53ef463a1e3` | `crates/prod/core/host/src/runner.rs` | 929 | +| `5e4c45dfc69f` | `docs/invariants/07-orchestration.md` | 122 | +| `40e5364b99fc` | `docs/invariants/08-replay.md` | 106 | +| `e0b3fb797542` | `docs/orchestration/supervisor.md` | 524 | +| `968e3c6d3e13` | `docs/orchestration/adapter.md` | 120 | +| `90c6ce047541` | `docs/system/kernel-prod-separation.md` | 144 | + +The claim-verification pass (§11) re-checks these anchors as the final +step. + +--- + +## 1. Scope + +This document defines the v1 host boundary: which component owns which +part of episode execution, capture, and replay. It does not define new +semantics. It codifies the boundary reached by the 2026-02-16 → +2026-03-26 migration tracked retrospectively in +[`sup2-alignment.md`](../ledger/gap-work/closed/sup2-alignment.md). + +What this doc establishes: + +- The ownership contract between Supervisor, Host, and Adapter under v1 +- The provenance trinity (`adapter_provenance`, `runtime_provenance`, `egress_provenance`) +- Effect buffer lifecycle and non-rollback posture +- Context merge precedence and schema gating +- Capture bundle composition (pre-host vs host-enriched fields) +- Strict replay contract (provenance match + decision/effect comparison) +- Rule-ID reconciliation across the `SUP-*`, `HST-*`, `REP-*`, `RTHANDLE-*`, `CXT-*`, and `RUN-CANON-*` families + +What this doc does not establish: + +- New rule IDs. All referenced rules already exist in `07-orchestration.md` and `08-replay.md`. +- New kernel semantics. Kernel meaning is frozen (see [`kernel.md`](kernel.md)). +- Session 3 rewrite of `supervisor.md`, `adapter.md`, `07-orchestration.md`, or `08-replay.md`. Those are deferred; §9 is the working table for that rewrite. + +--- + +## 2. Roles in one diagram + +``` + ┌──────────────────────────────────────────┐ + │ Host │ + │ (crates/prod/core/host) │ + │ │ + external event ──► │ 1. build_external_event │──► adapter binder + │ (context merge: incoming > store) │ + │ │ + │ 2. session.on_event(e) ─────────┐ │ + │ (CapturingSession wraps │ │ + │ Supervisor + CapturingLog) ▼ │ + │ ┌──────────────────┐ │ + │ │ Supervisor │ │ + │ │ (kernel) │ │ + │ │ │ │ + │ │ Decision-only: │ │ + │ │ Skip | Invoke | │ │ + │ │ Drop | Retry │ │ + │ │ │ │ + │ │ runtime.run() ─► RunTermination (no RunResult) + │ └──────────────────┘ │ + │ │ │ + │ 3. drain_pending_effects ◄───────┘ │ (BufferingRuntimeInvoker shim) + │ │ + │ 4. dispatch_invoked_effects │ + │ - handler.apply() → ContextStore │ + │ - egress.dispatch() → external I/O │ + │ │ + │ 5. enrich_bundle_with_host_artifacts │ + │ (decisions[i] ← effects, intent_acks, │ + │ interruptions; egress_provenance) │ + └──────────────────────────────────────────┘ +``` + +The Supervisor is termination-only (`SUP-2`). Effects produced by the +runtime during `runtime.run(...)` are *not* handed back to the +Supervisor. They are held by a host-owned buffer shim +(`BufferingRuntimeInvoker`) and drained by the host after +`on_event(...)` returns. + +--- + +## 3. Ownership contract + +### 3.1 Supervisor (kernel) — termination-only + +Responsibilities: + +- Apply mechanical constraints (rate limits, invoke/retry policy, deadlines) +- Record decisions into `DecisionLog` via `log_decision(...)` +- Invoke `RuntimeInvoker::run(...)` and observe only `RunTermination` + +Non-responsibilities: + +- Does not observe `ActionEffect`, `RunResult`, or domain payloads +- Does not own `ContextStore` +- Does not apply effects +- Does not enrich capture bundles (host-authored fields in §7.1) + +Evidence: + +- `crates/kernel/supervisor/src/lib.rs:213` — `Supervisor` struct +- `EpisodeInvocationRecord::from(&DecisionLogEntry)` in `crates/kernel/supervisor/src/lib.rs:173-188` hardcodes `effects: vec![]`, so kernel capture remains termination-only even after `DecisionLogEntry.effects` was removed. + +### 3.2 Host (prod) — effect loop + context + enrichment + +Responsibilities: + +- Build `ExternalEvent` (context merge, schema gate, adapter binder) — `crates/prod/core/host/src/runner.rs:714` +- Hold the `ContextStore` — `runner.rs` (field on `HostedRunner`); read for merge at `runner.rs:722`; writes via handler at `runner.rs:795` +- Drain the per-step effect buffer via `runtime.drain_pending_effects()` — `runner.rs:576` +- Apply handler-owned effect kinds into `ContextStore` — `runner.rs:795` in `dispatch_invoked_effects` +- Dispatch egress-owned effect kinds through configured egress channels — `runner.rs:837` in `dispatch_invoked_effects` +- Enrich `CaptureBundle` with applied effects, intent acks, and interruptions — `runner.rs:650-655` via `enrich_bundle_with_host_artifacts` +- Stamp `egress_provenance` on the bundle — `runner.rs:649` + +Non-responsibilities: + +- Does not redefine kernel semantics or introduce new rule IDs +- Does not own the `RuntimeInvoker` trait (owned by kernel adapter) +- Does not compute `runtime_provenance` (owned by kernel runtime) or `adapter_provenance` (owned by kernel adapter) + +### 3.3 Adapter (kernel) — declarative contract + +Responsibilities: + +- Declare context keys, event kinds, accepted effects, and capture fields (manifest) +- Produce `adapter_provenance` fingerprint — `crates/kernel/adapter/src/provenance.rs:10` (`fingerprint(manifest)` → `adapter:{id}@{version};sha256:{hex}`) +- Own the `RuntimeInvoker` trait as kernel contract +- Provide the host binder that maps semantic events to bound events + +Non-responsibilities: + +- Does not execute; runtime does +- Does not dispatch effects; host does +- Does not own `ContextStore` or effect handlers as semantic authority — those are host-layer concerns +- Host support types now live under `crates/prod/core/host/src/host/`, matching their host-owned responsibility + +--- + +## 4. Provenance trinity + +The v1 capture bundle carries three provenance strings. Each is produced +by a distinct layer and bounds a distinct failure domain. + +### 4.1 `adapter_provenance` + +- Scheme: `adapter:{id}@{version};sha256:{hex}` +- Produced by: `fingerprint(manifest)` — `crates/kernel/adapter/src/provenance.rs:10` +- Input: recursively key-sorted (canonicalized) `AdapterManifest` JSON +- Absent-adapter fallback: the constant string `"none"` (`NO_ADAPTER_PROVENANCE` in `crates/kernel/supervisor/src/lib.rs:50`) +- Matched on strict replay (`REP-7`): `validate_replay_provenance` — `crates/kernel/supervisor/src/replay.rs:245` + +### 4.2 `runtime_provenance` + +- Scheme: `rpv1:sha256:{hex}` (only scheme defined in v1; see `RuntimeProvenanceScheme::Rpv1`) +- Produced by: `compute_runtime_provenance` — `crates/kernel/runtime/src/provenance.rs:52` +- Input: canonical `ExpandedGraph` plus primitive catalog metadata, JSON-serialized with sorted keys +- Matched on strict replay (`REP-7`): same validator as `adapter_provenance` + +### 4.3 `egress_provenance` + +- Produced by: host (post-step) — `crates/prod/core/host/src/runner.rs:649` +- Records the egress runtime configuration used during live dispatch +- Field shape: `CaptureBundle.egress_provenance: Option` — `crates/kernel/supervisor/src/lib.rs:201`; `None` for fixture/adapterless runs +- Decision record: [`docs/ledger/decisions/egress-provenance.md`](../ledger/decisions/egress-provenance.md) + +Replay semantics: + +- `adapter_provenance` and `runtime_provenance` are compared strictly by `replay_checked_strict` (`REP-7`) +- `egress_provenance` is not compared by the kernel replay validator; it is a host-side attestation carried alongside the bundle, not a strict-replay gate + +### 4.4 Why three, not one + +Each provenance string bounds a different failure domain: + +- `adapter_provenance` pins the compatibility contract (what the host validated against) +- `runtime_provenance` pins the expanded graph and primitive versions (what the runtime actually executed) +- `egress_provenance` pins the boundary-I/O configuration (what realized external effects) + +Collapsing them into a single hash would hide which layer changed. +Keeping them separate preserves layer-specific diagnostic capability on +replay-mismatch failures. + +--- + +## 5. Context merge precedence (HST-6) + +### 5.1 Incoming > store + +For every `on_event(...)`: + +1. Host reads `ContextStore.snapshot()` — `runner.rs:722` +2. Host merges adapter-declared, schema-allowed store keys into a candidate payload +3. Host overlays incoming event payload fields on top — `runner.rs:728-730` +4. Final merged payload is passed to the adapter binder at `runner.rs:731-740` + +Overlay rule: **keys present in the incoming event replace any same-named keys from the store.** + +### 5.2 Schema gate on store-supplied keys + +A key survives from the store into the merged payload only if all three +conditions hold: + +- `adapter.provides.context.contains_key(key)` — declared in the manifest +- `allowed_schema_keys(adapter, &semantic_kind).contains(key)` — permitted for this event kind +- Key is present in `ContextStore.snapshot()` at step time + +Incoming event keys bypass the store gate but still flow through the +adapter binder's semantic event validation. + +### 5.3 Why this order + +`HST-6` makes merge deterministic across replays of identical captured +events. The incoming payload is authoritative. The store is re-built +during replay from captured `set_context` effects in the same decision +order, so any value that was in the live store is reconstructible, and +any value that came in on the event is carried on the event record +itself. + +--- + +## 6. Effect buffer lifecycle (HST-7) + +### 6.1 Replace-only, drain-once + +The runtime does not call back into the supervisor with effects. It +writes effects into a host-owned buffer held by +`BufferingRuntimeInvoker`: + +- Each `run(...)` **replaces** `pending_effects` with the latest invocation's effects — `crates/prod/core/host/src/host/buffering_invoker.rs:112` (`guard.pending_effects = result.effects` inside `impl RuntimeInvoker for BufferingRuntimeInvoker`, lines 100-115) +- Each host step **drains** via `std::mem::take(...)` — `buffering_invoker.rs:86` +- Before the next `on_event`, host asserts `pending_effect_count() == 0` — `runner.rs:547-551` + +Replace (not append) semantics are why `HST-4` holds: a retry that +re-invokes the runtime overwrites stale effects rather than accumulating +them. + +### 6.2 Non-rollback commitment + +Once the host drains and dispatches effects, no rollback is possible: + +- `SetContextHandler` writes into `ContextStore` directly +- Egress dispatch is irreversible by construction (external I/O is committed when the channel acks) +- If the outcome terminates abnormally, prior effects from this decision are still committed (`SUP-6` — invocation-scoped atomicity) + +Evidence: `runner.rs:794` — comment `"SUP-6 alignment: no rollback on handler failure."` + +### 6.3 Buffer shim location + +The `BufferingRuntimeInvoker` shim lives in +`crates/prod/core/host/src/host/buffering_invoker.rs`. It is host +behavior and now sits under the host crate's support-module boundary, +which matches the v1 ownership contract. + +--- + +## 7. Capture enrichment + +### 7.1 Pre-host vs host-enriched fields + +| `CaptureBundle` field | Author | Site | +|---|---|---| +| `capture_version` | kernel capture | `crates/kernel/supervisor/src/capture.rs:241` | +| `graph_id` | kernel capture | `capture.rs:242` | +| `config` | kernel capture | `capture.rs:243` | +| `events` | kernel capture (`CapturingSession::on_event`) | `capture.rs:260` (`guard.events.push(...)`) | +| `decisions` (non-effect fields) | kernel capture (`CapturingDecisionLog`) | `capture.rs:189-206` (`CapturingDecisionLog::log` body; `EpisodeInvocationRecord::from(&entry)` at line 201, push at line 205) | +| `decisions[i].effects` | **host** (authoritative writer; kernel capture initializes empty defaults first, see §7.3) | `runner.rs:650-655` via `enrich_bundle_with_host_artifacts` | +| `decisions[i].intent_acks` | host | same | +| `decisions[i].interruptions` | host | same | +| `adapter_provenance` | host seed → kernel capture | `runner.rs:455-465` (host seed) / `capture.rs:246` (kernel store in `CaptureBundle` literal at `capture.rs:240-248`) | +| `runtime_provenance` | host seed → kernel capture | `runner.rs:465-466` (host seed, passed into `CapturingSession::new_with_provenance`) / `capture.rs:247` (kernel store) | +| `egress_provenance` | host (post-step) | `runner.rs:649` | + +### 7.2 Association by decision index, not `event_id` + +Host enrichment keys on `decisions[i]` position (via +`AppliedEffectsByDecision`), not on `event_id`. This is safety-relevant +because duplicate `event_id` values could otherwise overwrite prior +decisions' effects. `HST-9` rejects duplicate `event_id` values +defensively at the host step boundary; the index-based association is a +second-line guarantee. + +Evidence: `runner.rs:759-761` — `self.applied_effects.record(decision_index, drained_effects.to_vec())` inside `dispatch_invoked_effects`, guarded by `if !drained_effects.is_empty()`. + +### 7.3 Why host, not supervisor + +If the supervisor wrote `decisions[i].effects` with authoritative +content, it would have to observe effects — contradicting `SUP-2` +(strategy-neutrality) and bleeding `ActionEffect` into the kernel +scheduling layer. The v1 solution: + +- Kernel capture initializes `EpisodeInvocationRecord.effects` to `vec![]` in `EpisodeInvocationRecord::from(&entry)` and `CapturingDecisionLog::log` pushes that record directly. +- Host is the only authoritative source of non-empty effect content. Post-step, host overwrites `record.effects` for every decision index covered by the per-decision sidecar (`AppliedEffectsByDecision`) via `enrich_bundle_with_host_artifacts` (§7.1). Decision indices outside the sidecar's recorded range keep whatever the supervisor wrote — which in production is `vec![]`. + +The sidecar records only Invoke decisions whose drained effect buffer is non-empty (`runner.rs:759-761` — guarded by `if !drained_effects.is_empty()`). Decisions that fall outside that record set therefore retain the kernel-written placeholder. Known cases at HEAD: + +- Skip / Defer decisions (never invoke runtime; `HST-3` forces zero effects, so `dispatch_invoked_effects` is not called for them — `runner.rs:590-604`). +- Invoke decisions that ran but emitted no effects (`dispatch_invoked_effects` skips `applied_effects.record` when `drained_effects` is empty). +- Adapterless / fixture runs (no adapter present, so `dispatch_invoked_effects` returns early before any `record` call — `runner.rs:752-757`; such runs also require zero effects by construction). + +The kernel-written `record.effects` field is therefore not a trusted +content channel. It is a default-empty placeholder that +survives only for decisions in the cases above, and any non-empty +effect content on the bundle comes from host enrichment. + +--- + +## 8. Replay contract (strict) + +### 8.1 Entry + +`replay_checked_strict(bundle, runtime, expectations)` — `crates/kernel/supervisor/src/replay.rs:200`. + +### 8.2 Preflight (`validate_bundle_strict`) + +1. Capture version match (`REP-1` — self-validating form) — `replay.rs:175-179` +2. All event records pass `validate_hash()` (`REP-1` — rehydration integrity) — `replay.rs:181-187` +3. No duplicate `event_id`s (`REP-8`) — `replay.rs:273-284` +4. Provenance match (`REP-7`) — `replay.rs:245-271`: + - `adapter_provenance == expected_adapter_provenance`, with the `"none"` bidirectional guard (`AdapterRequiredForProvenancedCapture` / `UnexpectedAdapterProvidedForNoAdapterCapture`) + - `runtime_provenance == expected_runtime_provenance` + +### 8.3 Decision comparison + +`compare_decisions(captured, replayed)` — `replay.rs:290`: + +- Non-effect decision fields compared positionally (`event_id`, `decision`, `schedule_at`, `episode_id`, `deadline`, `termination`, `retry_count`) — `replay.rs:299-309` +- `decisions[i].effects` compared by `(effect, effect_hash)` pair equality — `replay.rs:328-345` +- Mismatch in effect count or content returns `ReplayError::EffectMismatch` + +### 8.4 What replay does not verify + +- `egress_provenance` — informational; not kernel-gated +- Live boundary I/O — replay is capture-driven; no live channels are started +- Cross-ingestion normalization parity — deferred (`INGEST-TIME-1`, per `08-replay.md`) + +--- + +## 9. Rule-ID reconciliation (working table) + +This table is the working document for the deferred Session 3 rewrite +of `supervisor.md`, `adapter.md`, `07-orchestration.md`, and +`08-replay.md`. It enumerates every rule currently declared in those +docs that touches the v1 host boundary and states its v1 disposition. + +Status values: + +- **applies** — rule holds verbatim at HEAD; no rewrite needed beyond source citations +- **clarified** — rule holds but prose needs tightening in the Session 3 rewrite (e.g. to name the correct enforcement locus under v1) +- **relocated** — rule's enforcement locus moved between layers during the v0 → v1 migration; prose still reads correctly but the authority line in the spec doc should change +- **closed** — rule is retired and retained only as a historical anchor +- **process** — rule governs workflow rather than runtime behavior; out of semantic scope for this doc +- **out-of-scope** — rule belongs to a layer this doc does not address (tracked for completeness) + +| Rule ID | v1 status | Evidence | +|---|---|---| +| `CXT-1` | clarified | `runner.rs:714-744` enforces adapter-governed context keys; spec prose in `supervisor.md §3` still correctly says "externally supplied and adapter-governed" but should name the host-side enforcement locus | +| `SUP-1` | applies | `crates/kernel/supervisor/src/lib.rs` — `Supervisor::graph_id` is private with no setter; set only at construction | +| `SUP-2` | applies | `RuntimeInvoker::run()` returns `RunTermination` only (`crates/kernel/adapter/src/lib.rs`); no kernel supervisor path observes `RunResult`. The rule holds verbatim at HEAD. Adjacent technical debt — `RunResult`'s current adapter-crate visibility while the shim now lives in `ergo-host` — is tracked in §10 S2.2 and is a belt-and-braces hardening, not a rule change. | +| `SUP-3` | applies | Replay harness in `crates/kernel/supervisor/tests/replay_harness.rs`; strict entry at `replay.rs:200` | +| `SUP-4` | applies | `should_retry()` matches only `NetworkTimeout | AdapterUnavailable | RuntimeError | TimedOut` — `supervisor/src/lib.rs` | +| `SUP-5` | applies | `ErrKind` enum in `supervisor/src/lib.rs` has only mechanical variants | +| `SUP-6` | applies | Invocation-scoped atomicity preserved by host non-rollback posture — §6.2; `runner.rs:794` | +| `SUP-7` | applies | `DecisionLog` trait declares only `fn log()` in `crates/kernel/supervisor/src/lib.rs`; `records()` is on the concrete `MemoryDecisionLog`/`CapturingDecisionLog` impls, not on the trait. The write-only property holds verbatim at HEAD. | +| `SUP-TICK-1` | applies | `supervisor/src/lib.rs` — Pump scheduling; legacy `Tick` alias in serde `#[serde(alias = "Tick")]` | +| `RTHANDLE-META-1` | applies | `crates/kernel/adapter/src/lib.rs` — `RuntimeHandle::run()` forwards `graph_id` and `event_id` into `execute_with_metadata(...)` | +| `RTHANDLE-ID-1` | applies | `FaultRuntimeHandle` keys injected outcomes on `EventId` only | +| `RTHANDLE-ERRKIND-1` | closed | Fix landed 2026-02-06; `RuntimeHandle::run()` maps pre-execution failures to `ErrKind::ValidationFailed`. Historical anchor only. | +| `HST-1` | applies | Host applies effects at the boundary; not read back from `DecisionLog` — `runner.rs:576` (drain), `runner.rs:746` (dispatch) | +| `HST-2` | applies | `SetContextHandler::apply` in `crates/prod/core/host/src/host/effects.rs` validates declared key, writable, type | +| `HST-3` | applies | `runner.rs:599-603` — non-invoke decisions must produce zero effects | +| `HST-4` | applies | Replace semantics — `buffering_invoker.rs:112` — §6.1 | +| `HST-5` | applies | `ensure_handler_coverage` — `crates/prod/core/host/src/host/coverage.rs:50-78` | +| `HST-6` | applies | Incoming > store overlay — `runner.rs:721-730` — §5.1 | +| `HST-7` | applies | Replace-only, drain-once, commit-non-empty, no rollback — §6 | +| `HST-8` | applies | One `on_event` lifecycle per step — `runner.rs:556-566` | +| `HST-9` | applies | Duplicate `event_id` rejection at `HostedRunner::execute_step` — `runner.rs:542-545` | +| `RUN-CANON-1` | applies | Canonical run requires explicit `DriverConfig` — host request types in `crates/prod/core/host/src/` | +| `RUN-CANON-2` | applies | Adapter binding mandatory for production; three-gate enforcement described in `07-orchestration.md` notes | +| `DOC-GATE-1` | process | Workflow rule — out of runtime scope for this doc | +| `SDK-CANON-1` | out-of-scope | SDK-layer delegation; see `docs/system/kernel-prod-separation.md §3` | +| `SDK-CANON-2` | out-of-scope | Same | +| `SDK-CANON-3` | out-of-scope | Same | +| `REP-1` | applies | `ExternalEventRecord::validate_hash()` — `replay.rs:181-187` | +| `REP-2` | applies | `rehydrate_event` — `replay.rs:356` | +| `REP-3` | applies | Fault injection keys on `EventId` — `RTHANDLE-ID-1` mirror | +| `REP-4` | applies | Capture types are in `kernel/supervisor/src/capture.rs`; runtime types are in `kernel/runtime`; the two are distinct | +| `REP-5` | applies | Supervisor does not read wall-clock time; `schedule_at` is externally supplied | +| `REP-6` | closed | `08-replay.md` lines 58–62: "Prior documentation suggesting 'triggers may hold internal state' was a semantic error that conflated execution-local bookkeeping with ontological state. Triggers are stateless (see `TRG-STATE-1`). There is no trigger state to capture. Temporal patterns requiring memory (once, count, latch, debounce) must be implemented as clusters." Closed by clarification 2025-12-28. | +| `REP-7` | applies | `validate_replay_provenance` — `replay.rs:245-271` — §8.2 | +| `REP-8` | applies | `validate_unique_event_ids` — `replay.rs:273-284` — §8.2 | +| `REP-SCOPE` | applies | Scope A (supervisor scheduling + host-owned effect integrity, same-ingestion path) | +| `SOURCE-TRUST` | applies | Trust-based; `docs/orchestration/supervisor.md §2.3` | +| `INGEST-TIME-1` | deferred | Cross-ingestion normalization parity — explicitly deferred in `08-replay.md` | + +**Coverage check:** the table covers every rule declared in the +"Invariants" tables of `07-orchestration.md` (§7) and `08-replay.md` +(§8), plus `CXT-1` from the same. `TRG-STATE-*` and the primitive +families (`ADP-*`, `SRC-*`, `CMP-*`, `TRG-*`, `ACT-*`, `COMP-*`, +`D.*`/`I.*`/`E.*`/`V.*`) are out of scope for this doc (they govern +declaration and composition, not the host boundary). + +--- + +## 10. Known v1 technical debt (non-normative) + +The remaining item below is the last Session 2 migration artifact. It +does not change v1 semantics; it codifies the boundary by tightening +the runtime seam now that S2.1 and S2.3 have landed. + +| ID | What | Where | Session 2 work | +|---|---|---|---| +| S2.2 | `RunResult` is publicly importable from the kernel adapter crate while `BufferingRuntimeInvoker` now lives in `ergo-host::host` | `crates/kernel/adapter/src/lib.rs` and `crates/prod/core/host/src/host/buffering_invoker.rs` | Narrowing `RunResult` to `pub(crate)` is no longer viable after S2.3. Remaining options are a no-op (`pub` stays) or a seam redesign. Candidate redesign direction at current HEAD: have `RuntimeHandle::run(...)` return `RunTermination` directly and route effects to the shim through a shim-owned sink (e.g. `&mut dyn EffectSink` or a closure), eliminating `RunResult` as a shared return type. A second direction raised during review — collapsing `RunResult` into a variant on `RunTermination` — is flagged rather than listed. The load-bearing concern is persisted-format surface: `RunTermination` derives `Serialize, Deserialize` and is persisted on `EpisodeInvocationRecord.termination`, so any effect-bearing variant widens the capture bundle's on-disk shape and breaks forward-compatibility for existing captures, regardless of how the supervisor reads the value. The secondary, `SUP-2`-adjacent concern is that any supervisor code pattern-matching with a payload binding would observe effects — avoidable in code but not enforced by the type. Neither remaining direction is fully audited against `RuntimeHandle::run`'s five `RunResult`-producing sites in `adapter/src/lib.rs`; that audit is S2.2's step zero. | + +The item above is **non-semantic**. It brings the remaining public seam +into alignment with the boundary that this document establishes. + +--- + +## 11. Claim verification (read-back pass) + +Each row below is a semantic claim made in §§3–8. Every claim must +resolve to the stated file and line range at HEAD +`7784f46f034798de70ab24f8f3dfb31c9e5142ad`. This section is the +final pre-merge gate for Artifact B; if any row does not resolve, the +claim is retracted or rewritten before merge. + +| # | Claim (section) | Stated source | Verified | +|---|---|---|:---:| +| 1 | Supervisor observes only `RunTermination` (§3.1) | `crates/kernel/supervisor/src/lib.rs` — `Supervisor` + `RuntimeInvoker::run` signature in `crates/kernel/adapter/src/lib.rs` | ✓ | +| 2 | Kernel capture initializes `EpisodeInvocationRecord.effects` to `vec![]` before host enrichment (§3.1, §7.3) | `supervisor/src/lib.rs:173-188` — `impl From<&DecisionLogEntry> for EpisodeInvocationRecord` hardcodes `effects: vec![]`; `capture.rs:189-205` pushes that record directly | ✓ | +| 3 | Host builds `ExternalEvent` with context merge (§3.2, §5) | `runner.rs:714-744` `build_external_event` | ✓ | +| 4 | Host drains the per-step effect buffer (§3.2, §6.1) | `runner.rs:576` `self.runtime.drain_pending_effects()` | ✓ | +| 5 | Host dispatches handler-owned effects into `ContextStore` (§3.2) | `runner.rs:794-796` — `handler.apply(effect, &mut self.context_store, ...)` | ✓ | +| 6 | Host enriches bundle with effects / intent_acks / interruptions (§3.2, §7.1) | `runner.rs:650-655` `enrich_bundle_with_host_artifacts(&mut bundle, self.applied_effects.effects(), self.applied_intent_acks.intent_acks(), self.interruptions.interruptions())` | ✓ | +| 7 | Host stamps `egress_provenance` on the bundle (§3.2, §4.3, §7.1) | `runner.rs:649` `bundle.egress_provenance = self.egress_provenance.clone()` | ✓ | +| 8 | `adapter_provenance` scheme is `adapter:{id}@{version};sha256:{hex}` (§4.1) | `crates/kernel/adapter/src/provenance.rs:20-23` `format!("adapter:{}@{};sha256:{}", manifest.id, manifest.version, hash)` | ✓ | +| 9 | `adapter_provenance` is a canonicalized-JSON SHA-256 (§4.1) | `provenance.rs:10-23` — `canonicalize` recursively sorts object keys; `serde_json::to_vec` then `Sha256::new()` | ✓ | +| 10 | `NO_ADAPTER_PROVENANCE == "none"` (§4.1) | `crates/kernel/supervisor/src/lib.rs:50` `pub const NO_ADAPTER_PROVENANCE: &str = "none";` | ✓ | +| 11 | `runtime_provenance` scheme is `rpv1:sha256:{hex}` (§4.2) | `crates/kernel/runtime/src/provenance.rs:74-78` `format!("{}:sha256:{}", RuntimeProvenanceScheme::Rpv1.prefix(), to_hex(&digest))` with `prefix() == "rpv1"` | ✓ | +| 12 | Context merge overlays incoming > store (§5.1) | `runner.rs:721-730` — store keys inserted first (lines 722-726), incoming keys inserted after (lines 728-730) | ✓ | +| 13 | Store gate requires manifest declaration + schema-allowed + present in snapshot (§5.2) | `runner.rs:722-726` — conditional on `adapter.provides.context.contains_key(key) && allowed_store_keys.contains(key)`, iterating over `self.context_store.snapshot()` | ✓ | +| 14 | Effect buffer replace on `run()` (§6.1) | `buffering_invoker.rs:112` `guard.pending_effects = result.effects` assignment (not extend), inside `impl RuntimeInvoker for BufferingRuntimeInvoker` at lines 100-115 | ✓ | +| 15 | Effect buffer drain uses `std::mem::take` (§6.1) | `buffering_invoker.rs:86` `std::mem::take(&mut guard.pending_effects)` | ✓ | +| 16 | Host asserts empty buffer before next `on_event` (§6.1) | `runner.rs:547-551` `if self.runtime.pending_effect_count() != 0 { return Err(HostedStepError::LifecycleViolation { ... }); }` | ✓ | +| 17 | No rollback on handler failure (§6.2) | `runner.rs:794` comment `// SUP-6 alignment: no rollback on handler failure.` | ✓ | +| 18 | Host enrichment is by decision index (§7.2) | `runner.rs:759-761` — `self.applied_effects.record(decision_index, drained_effects.to_vec())` guarded by `if !drained_effects.is_empty()` | ✓ | +| 19 | Kernel capture writes only the empty `record.effects` placeholder; non-empty effects come from host enrichment (§7.3) | `crates/kernel/supervisor/src/capture.rs:189-205` — `CapturingDecisionLog::log` pushes `EpisodeInvocationRecord::from(&entry)` directly; `runner.rs:650-655` later enriches authoritative host effects | ✓ | +| 20 | Strict replay entrypoint (§8.1) | `replay.rs:200-207` `pub fn replay_checked_strict(...) -> Result, ReplayError>` | ✓ | +| 21 | Preflight version match (§8.2) | `replay.rs:175-179` `if bundle.capture_version != crate::CAPTURE_FORMAT_VERSION` | ✓ | +| 22 | Preflight event hash validation (§8.2) | `replay.rs:181-187` `for record in &bundle.events { if !record.validate_hash() { ... } }` | ✓ | +| 23 | Preflight duplicate `event_id` rejection (§8.2) | `replay.rs:273-284` `validate_unique_event_ids` | ✓ | +| 24 | Provenance match with `"none"` bidirectional guard (§8.2) | `replay.rs:249-261` — `AdapterRequiredForProvenancedCapture` and `UnexpectedAdapterProvidedForNoAdapterCapture` variants | ✓ | +| 25 | Decision comparison covers non-effect fields positionally (§8.3) | `replay.rs:299-309` — `cap.event_id != rep.event_id || cap.decision != rep.decision || ...` | ✓ | +| 26 | Effect comparison uses `(effect, effect_hash)` equality (§8.3) | `replay.rs:328-345` `if cap_eff.effect != rep_eff.effect || cap_eff.effect_hash != rep_eff.effect_hash` | ✓ | + +Footnote on scope: `INGEST-TIME-1` (cross-ingestion normalization +parity) is not verified here because it is explicitly deferred in +`08-replay.md`; it appears in §9 only for completeness. + +--- + +## 12. Supersession notes (non-normative) + +This doc does not rewrite any existing spec. It establishes a +canonical v1 reference that the Session 3 rewrite will reconcile +against. + +- [`docs/system/kernel-prod-separation.md`](kernel-prod-separation.md) (CANONICAL v1) — compatible. This doc is strictly more specific about the host boundary. No rewrite of kernel-prod-separation is implied. +- [`docs/invariants/07-orchestration.md`](../invariants/07-orchestration.md) (CANONICAL v1) — compatible rule table. Session 3 may add source citations to the `HST-*` and `SUP-*` rows by referencing §9 of this doc. +- [`docs/invariants/08-replay.md`](../invariants/08-replay.md) (CANONICAL v1) — compatible. §§4 and §8 of this doc provide the rationale that `08-replay.md` intentionally omits. +- [`docs/orchestration/supervisor.md`](../orchestration/supervisor.md) (FROZEN, marked `Version: v0`) — semantically correct for v1 supervisor behavior. The `v0` version tag predates the 2026-02-16 migration. Re-anchoring the authority line is the subject of Artifact C (v1 freeze declaration), not this doc. +- [`docs/orchestration/adapter.md`](../orchestration/adapter.md) (FROZEN, marked `Version: v0`) — same posture; re-anchored by Artifact C. +- [`docs/ledger/gap-work/closed/sup2-alignment.md`](../ledger/gap-work/closed/sup2-alignment.md) (CLOSED retrospective) — this doc is its forward-facing companion. The ledger retrospectively tracked the v0 → v1 migration; this doc states the resulting v1 boundary as a living reference. + +--- + +## 13. References + +- [Kernel Closure and v1 Workstream Declaration](kernel.md) +- [Current Architecture](current-architecture.md) +- [Kernel/Prod Separation and Host Intent](kernel-prod-separation.md) +- [Orchestration Phase Invariants](../invariants/07-orchestration.md) +- [Replay Phase Invariants](../invariants/08-replay.md) +- [Execution Supervisor (frozen)](../orchestration/supervisor.md) +- [Adapter Contract (frozen)](../orchestration/adapter.md) +- [v1 External Effect Intent Model](../ledger/decisions/v1-external-effect-intent-model.md) +- [Egress Provenance](../ledger/decisions/egress-provenance.md) +- [SUP-2 Alignment (closed retrospective)](../ledger/gap-work/closed/sup2-alignment.md) From 0218a5fd01f90649de8da8d3924d694aecec7dae Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 16:28:38 -0700 Subject: [PATCH 03/11] S2.2: redesign runtime observation seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per freeze-v1.md §6 acknowledgment: - RuntimeHandle::run (§3.2): public signature changed from returning RunResult to returning RunTermination only. - HostedRunner::new(...): deliberate companion break; constructor now accepts BufferingRuntimeInvoker instead of RuntimeHandle so the public host upgrade path is buffered, not raw. Pre-authorized by freeze-v1.md §4.1. Plan: docs/ledger/decisions/s2.2-plan.md --- crates/kernel/adapter/src/lib.rs | 234 +++++++++----- crates/kernel/adapter/src/tests.rs | 10 +- .../core/host/src/demo_fixture_usecase.rs | 8 +- .../core/host/src/host/buffering_invoker.rs | 102 +++--- crates/prod/core/host/src/host/mod.rs | 5 +- crates/prod/core/host/src/replay/tests.rs | 9 +- crates/prod/core/host/src/runner.rs | 9 +- crates/prod/core/host/src/runner/tests.rs | 20 +- .../prod/core/host/src/usecases/live_prep.rs | 14 +- crates/prod/core/host/src/usecases/shared.rs | 2 +- .../core/host/src/usecases/tests/live_prep.rs | 4 +- docs/ledger/decisions/s2.2-plan.md | 302 ++++++++++++++++++ 12 files changed, 564 insertions(+), 155 deletions(-) create mode 100644 docs/ledger/decisions/s2.2-plan.md diff --git a/crates/kernel/adapter/src/lib.rs b/crates/kernel/adapter/src/lib.rs index 125a042..ccd3a6b 100644 --- a/crates/kernel/adapter/src/lib.rs +++ b/crates/kernel/adapter/src/lib.rs @@ -29,7 +29,7 @@ use std::time::Duration; use ergo_runtime::catalog::{CorePrimitiveCatalog, CoreRegistries}; use ergo_runtime::cluster::{ExpandedGraph, PrimitiveCatalog, PrimitiveKind}; -use ergo_runtime::common::Value; +use ergo_runtime::common::{ActionEffect, Value}; use ergo_runtime::runtime::{ execute_with_metadata, validate as runtime_validate, ExecError, ExecutionContext as RuntimeExecutionContext, Registries, @@ -179,9 +179,9 @@ pub enum ErrKind { /// Result of a runtime invocation, carrying termination status and any effects. #[derive(Debug, Clone)] -pub struct RunResult { - pub termination: RunTermination, - pub effects: Vec, +struct RunResult { + termination: RunTermination, + effects: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -419,18 +419,17 @@ fn json_to_value(value: &serde_json::Value) -> Option { Some(Value::Series(series)) } -/// RuntimeHandle holds the execution dependencies needed to invoke the runtime. -/// It is constructed with an expanded graph, primitive catalog, registries, and adapter provides. +/// Shared runtime execution dependencies used by both public handle types. #[derive(Clone)] -pub struct RuntimeHandle { +struct RuntimeState { graph: Arc, catalog: Arc, registries: Arc, adapter_provides: AdapterProvides, } -impl RuntimeHandle { - pub fn new( +impl RuntimeState { + fn new( graph: Arc, catalog: Arc, registries: Arc, @@ -444,80 +443,7 @@ impl RuntimeHandle { } } - pub fn run( - &self, - graph_id: &GraphId, - event_id: &EventId, - ctx: &ExecutionContext, - deadline: Option, - ) -> RunResult { - let _ = graph_id; - let _ = event_id; - - if matches!(deadline, Some(d) if d.is_zero()) { - return RunResult { - termination: RunTermination::Aborted, - effects: vec![], - }; - } - - let validated = match runtime_validate(&self.graph, &*self.catalog) { - Ok(graph) => graph, - Err(_) => { - return RunResult { - termination: RunTermination::Failed(ErrKind::ValidationFailed), - effects: vec![], - } - } - }; - - if self.validate_composition(&validated).is_err() { - return RunResult { - termination: RunTermination::Failed(ErrKind::ValidationFailed), - effects: vec![], - }; - } - - // Create temporary Registries reference from owned CoreRegistries - let registries = Registries { - sources: &self.registries.sources, - computes: &self.registries.computes, - triggers: &self.registries.triggers, - actions: &self.registries.actions, - }; - - // Call runtime::execute, surface effects through the boundary - match execute_with_metadata( - &validated, - ®istries, - ctx.inner(), - graph_id.as_str(), - event_id.as_str(), - ) { - Ok(report) => RunResult { - termination: RunTermination::Completed, - effects: report.effects, - }, - Err(exec_err) => { - let termination = match exec_err { - ExecError::ComputeFailed { .. } - | ExecError::NonFiniteOutput { .. } - | ExecError::MissingRequiredContextKey { .. } - | ExecError::ContextKeyTypeMismatch { .. } => { - RunTermination::Failed(ErrKind::SemanticError) - } - _ => RunTermination::Failed(ErrKind::RuntimeError), - }; - RunResult { - termination, - effects: vec![], - } - } - } - } - - /// Derive effect kinds that this composed graph can emit based on registered action manifests. - pub fn graph_emittable_effect_kinds(&self) -> HashSet { + fn graph_emittable_effect_kinds(&self) -> HashSet { let mut kinds = HashSet::new(); for node in self.graph.nodes.values() { @@ -603,6 +529,146 @@ impl RuntimeHandle { } } +/// RuntimeHandle holds the execution dependencies needed to invoke the runtime. +/// It is constructed with an expanded graph, primitive catalog, registries, and adapter provides. +#[derive(Clone)] +pub struct RuntimeHandle { + state: RuntimeState, +} + +impl RuntimeHandle { + pub fn new( + graph: Arc, + catalog: Arc, + registries: Arc, + adapter_provides: AdapterProvides, + ) -> Self { + Self { + state: RuntimeState::new(graph, catalog, registries, adapter_provides), + } + } + + pub fn run( + &self, + graph_id: &GraphId, + event_id: &EventId, + ctx: &ExecutionContext, + deadline: Option, + ) -> RunTermination { + execute_once(&self.state, graph_id, event_id, ctx, deadline).termination + } + + /// Derive effect kinds that this composed graph can emit based on registered action manifests. + pub fn graph_emittable_effect_kinds(&self) -> HashSet { + self.state.graph_emittable_effect_kinds() + } +} + +/// ReportingRuntimeHandle holds the same execution dependencies as +/// `RuntimeHandle`, but exposes the low-level reporting seam used by the host +/// buffering wrapper. +#[derive(Clone)] +pub struct ReportingRuntimeHandle { + state: RuntimeState, +} + +impl ReportingRuntimeHandle { + pub fn new( + graph: Arc, + catalog: Arc, + registries: Arc, + adapter_provides: AdapterProvides, + ) -> Self { + Self { + state: RuntimeState::new(graph, catalog, registries, adapter_provides), + } + } + + pub fn run_reporting( + &self, + graph_id: &GraphId, + event_id: &EventId, + ctx: &ExecutionContext, + deadline: Option, + effects_out: &mut Vec, + ) -> RunTermination { + let result = execute_once(&self.state, graph_id, event_id, ctx, deadline); + *effects_out = result.effects; + result.termination + } + + pub fn graph_emittable_effect_kinds(&self) -> HashSet { + self.state.graph_emittable_effect_kinds() + } +} + +fn execute_once( + state: &RuntimeState, + graph_id: &GraphId, + event_id: &EventId, + ctx: &ExecutionContext, + deadline: Option, +) -> RunResult { + if matches!(deadline, Some(d) if d.is_zero()) { + return RunResult { + termination: RunTermination::Aborted, + effects: vec![], + }; + } + + let validated = match runtime_validate(&state.graph, &*state.catalog) { + Ok(graph) => graph, + Err(_) => { + return RunResult { + termination: RunTermination::Failed(ErrKind::ValidationFailed), + effects: vec![], + } + } + }; + + if state.validate_composition(&validated).is_err() { + return RunResult { + termination: RunTermination::Failed(ErrKind::ValidationFailed), + effects: vec![], + }; + } + + let registries = Registries { + sources: &state.registries.sources, + computes: &state.registries.computes, + triggers: &state.registries.triggers, + actions: &state.registries.actions, + }; + + match execute_with_metadata( + &validated, + ®istries, + ctx.inner(), + graph_id.as_str(), + event_id.as_str(), + ) { + Ok(report) => RunResult { + termination: RunTermination::Completed, + effects: report.effects, + }, + Err(exec_err) => { + let termination = match exec_err { + ExecError::ComputeFailed { .. } + | ExecError::NonFiniteOutput { .. } + | ExecError::MissingRequiredContextKey { .. } + | ExecError::ContextKeyTypeMismatch { .. } => { + RunTermination::Failed(ErrKind::SemanticError) + } + _ => RunTermination::Failed(ErrKind::RuntimeError), + }; + RunResult { + termination, + effects: vec![], + } + } + } +} + fn source_parameters_with_manifest_defaults( manifest: &ergo_runtime::source::SourcePrimitiveManifest, node_parameters: &HashMap, @@ -658,7 +724,7 @@ impl RuntimeInvoker for RuntimeHandle { ctx: &ExecutionContext, deadline: Option, ) -> RunTermination { - RuntimeHandle::run(self, graph_id, event_id, ctx, deadline).termination + RuntimeHandle::run(self, graph_id, event_id, ctx, deadline) } } diff --git a/crates/kernel/adapter/src/tests.rs b/crates/kernel/adapter/src/tests.rs index c63490a..f3e9e74 100644 --- a/crates/kernel/adapter/src/tests.rs +++ b/crates/kernel/adapter/src/tests.rs @@ -200,10 +200,7 @@ fn runtime_handle_rejects_required_context_when_provides_empty() { let ctx = ExecutionContext::new(rt_ctx); let result = runtime.run(&GraphId::new("g"), &EventId::new("e"), &ctx, None); - assert_eq!( - result.termination, - RunTermination::Failed(ErrKind::ValidationFailed) - ); + assert_eq!(result, RunTermination::Failed(ErrKind::ValidationFailed)); } #[test] @@ -249,10 +246,7 @@ fn runtime_handle_rejects_unsupported_capture_format() { let ctx = ExecutionContext::new(RuntimeExecutionContext::default()); let result = runtime.run(&GraphId::new("g"), &EventId::new("e"), &ctx, None); - assert_eq!( - result.termination, - RunTermination::Failed(ErrKind::ValidationFailed) - ); + assert_eq!(result, RunTermination::Failed(ErrKind::ValidationFailed)); } #[test] diff --git a/crates/prod/core/host/src/demo_fixture_usecase.rs b/crates/prod/core/host/src/demo_fixture_usecase.rs index 297a79b..6ad9244 100644 --- a/crates/prod/core/host/src/demo_fixture_usecase.rs +++ b/crates/prod/core/host/src/demo_fixture_usecase.rs @@ -28,13 +28,15 @@ use std::path::PathBuf; use std::sync::Arc; use ergo_adapter::{ - ensure_demo_sources_have_no_required_context, fixture, AdapterProvides, GraphId, RuntimeHandle, + ensure_demo_sources_have_no_required_context, fixture, AdapterProvides, GraphId, + ReportingRuntimeHandle, }; use ergo_runtime::catalog::{build_core_catalog, core_registries}; use ergo_runtime::provenance::{compute_runtime_provenance, RuntimeProvenanceScheme}; use ergo_supervisor::demo::demo_1; use ergo_supervisor::Constraints; +use crate::host::BufferingRuntimeInvoker; use crate::usecases::{ run_fixture, HostAdapterSetupError, HostGraphPreparationError, HostRunError, HostSetupError, RunFixtureRequest, RunFixtureResult, @@ -75,7 +77,7 @@ pub fn run_demo_fixture_from_path( }, )?; - let runtime = RuntimeHandle::new( + let runtime = ReportingRuntimeHandle::new( graph.clone(), catalog.clone(), core_registries.clone(), @@ -95,7 +97,7 @@ pub fn run_demo_fixture_from_path( let runner = HostedRunner::new( GraphId::new(DEMO_GRAPH_ID), Constraints::default(), - runtime, + BufferingRuntimeInvoker::new(runtime), runtime_provenance, None, None, diff --git a/crates/prod/core/host/src/host/buffering_invoker.rs b/crates/prod/core/host/src/host/buffering_invoker.rs index 19bbff0..bf160db 100644 --- a/crates/prod/core/host/src/host/buffering_invoker.rs +++ b/crates/prod/core/host/src/host/buffering_invoker.rs @@ -1,13 +1,14 @@ //! host::buffering_invoker //! //! Purpose: -//! - Hold the host-owned runtime buffer shim that captures `RunResult.effects` -//! from `RuntimeHandle::run(...)` while presenting a termination-only +//! - Hold the host-owned runtime buffer shim that captures reported effects +//! from `ReportingRuntimeHandle::run_reporting(...)` while presenting a +//! termination-only //! `RuntimeInvoker` surface to the supervisor. //! //! Owns: //! - `BufferingRuntimeInvoker` and its replace-and-drain buffer lifecycle. -//! - The private `RuntimeResultProvider` helper seam used by local tests. +//! - The private reporting-runtime helper seam used by local tests. //! //! Does not own: //! - The public `RuntimeInvoker` contract or `RuntimeHandle` semantics; those @@ -17,40 +18,50 @@ //! //! Connects to: //! - `runner.rs`, which drains pending effects after each supervisor step. -//! - `ergo_adapter::RuntimeHandle`, which remains the engine behind the shim. +//! - `ergo_adapter::ReportingRuntimeHandle`, which remains the low-level +//! engine behind the shim. //! //! Safety notes: //! - Each `run(...)` call replaces the pending-effect buffer rather than //! extending it, so retries preserve the latest attempt only. //! - `drain_pending_effects()` is single-use and clears the buffer. +use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::Duration; use ergo_adapter::{ - EventId, ExecutionContext, GraphId, RunResult, RunTermination, RuntimeHandle, RuntimeInvoker, + EventId, ExecutionContext, GraphId, ReportingRuntimeHandle, RunTermination, RuntimeInvoker, }; use ergo_runtime::common::ActionEffect; -trait RuntimeResultProvider { - fn run_result( +trait ReportingRuntime { + fn run_reporting( &self, graph_id: &GraphId, event_id: &EventId, ctx: &ExecutionContext, deadline: Option, - ) -> RunResult; + effects_out: &mut Vec, + ) -> RunTermination; + + fn graph_emittable_effect_kinds(&self) -> HashSet; } -impl RuntimeResultProvider for RuntimeHandle { - fn run_result( +impl ReportingRuntime for ReportingRuntimeHandle { + fn run_reporting( &self, graph_id: &GraphId, event_id: &EventId, ctx: &ExecutionContext, deadline: Option, - ) -> RunResult { - self.run(graph_id, event_id, ctx, deadline) + effects_out: &mut Vec, + ) -> RunTermination { + ReportingRuntimeHandle::run_reporting(self, graph_id, event_id, ctx, deadline, effects_out) + } + + fn graph_emittable_effect_kinds(&self) -> HashSet { + ReportingRuntimeHandle::graph_emittable_effect_kinds(self) } } @@ -62,20 +73,23 @@ struct BufferState { #[derive(Clone)] pub struct BufferingRuntimeInvoker { - engine: Arc, + engine: Arc, + graph_emittable_effect_kinds: Arc>, state: Arc>, } impl BufferingRuntimeInvoker { - // Allow non-Send/Sync in Arc: RuntimeHandle contains non-Send/Sync trait object types. + // Allow non-Send/Sync in Arc: ReportingRuntimeHandle contains non-Send/Sync trait object types. #[allow(clippy::arc_with_non_send_sync)] - pub fn new(inner: RuntimeHandle) -> Self { + pub fn new(inner: ReportingRuntimeHandle) -> Self { Self::new_with_provider(Arc::new(inner)) } - fn new_with_provider(engine: Arc) -> Self { + fn new_with_provider(engine: Arc) -> Self { + let graph_emittable_effect_kinds = Arc::new(engine.graph_emittable_effect_kinds()); Self { engine, + graph_emittable_effect_kinds, state: Arc::new(Mutex::new(BufferState::default())), } } @@ -94,6 +108,10 @@ impl BufferingRuntimeInvoker { let guard = self.state.lock().expect("buffering runtime state poisoned"); guard.run_call_count } + + pub fn graph_emittable_effect_kinds(&self) -> &HashSet { + self.graph_emittable_effect_kinds.as_ref() + } } impl RuntimeInvoker for BufferingRuntimeInvoker { @@ -104,13 +122,16 @@ impl RuntimeInvoker for BufferingRuntimeInvoker { ctx: &ExecutionContext, deadline: Option, ) -> RunTermination { - let result = self.engine.run_result(graph_id, event_id, ctx, deadline); + let mut effects = vec![]; + let termination = + self.engine + .run_reporting(graph_id, event_id, ctx, deadline, &mut effects); let mut guard = self.state.lock().expect("buffering runtime state poisoned"); guard.run_call_count = guard.run_call_count.saturating_add(1); - guard.pending_effects = result.effects; + guard.pending_effects = effects; - result.termination + termination } } @@ -120,34 +141,46 @@ mod tests { use ergo_adapter::{ErrKind, EventTime, ExternalEvent, ExternalEventKind}; use ergo_runtime::common::{EffectWrite, Value}; + struct ScriptedRun { + termination: RunTermination, + effects: Vec, + } + struct ScriptedProvider { - queue: Mutex>, + queue: Mutex>, + graph_emittable_effect_kinds: HashSet, } impl ScriptedProvider { - fn new(queue: Vec) -> Self { + fn new(queue: Vec) -> Self { Self { queue: Mutex::new(queue), + graph_emittable_effect_kinds: HashSet::new(), } } } - impl RuntimeResultProvider for ScriptedProvider { - fn run_result( + impl ReportingRuntime for ScriptedProvider { + fn run_reporting( &self, _graph_id: &GraphId, _event_id: &EventId, _ctx: &ExecutionContext, _deadline: Option, - ) -> RunResult { + effects_out: &mut Vec, + ) -> RunTermination { let mut guard = self.queue.lock().expect("scripted queue poisoned"); if guard.is_empty() { - return RunResult { - termination: RunTermination::Completed, - effects: vec![], - }; + effects_out.clear(); + return RunTermination::Completed; } - guard.remove(0) + let scripted = guard.remove(0); + *effects_out = scripted.effects; + scripted.termination + } + + fn graph_emittable_effect_kinds(&self) -> HashSet { + self.graph_emittable_effect_kinds.clone() } } @@ -165,11 +198,11 @@ mod tests { #[test] fn replaces_pending_effects_on_retry_attempt() { let provider = Arc::new(ScriptedProvider::new(vec![ - RunResult { + ScriptedRun { termination: RunTermination::Failed(ErrKind::NetworkTimeout), effects: vec![effect_for_key("first", 1.0)], }, - RunResult { + ScriptedRun { termination: RunTermination::Completed, effects: vec![effect_for_key("second", 2.0)], }, @@ -186,10 +219,7 @@ mod tests { let event_id = EventId::new("e"); let first = invoker.run(&graph_id, &event_id, &ctx, None); - assert_eq!( - first, - RunTermination::Failed(ErrKind::NetworkTimeout) - ); + assert_eq!(first, RunTermination::Failed(ErrKind::NetworkTimeout)); assert_eq!(invoker.pending_effect_count(), 1); let second = invoker.run(&graph_id, &event_id, &ctx, None); @@ -204,7 +234,7 @@ mod tests { #[test] fn drain_pending_effects_is_single_use_and_clears_buffer() { - let provider = Arc::new(ScriptedProvider::new(vec![RunResult { + let provider = Arc::new(ScriptedProvider::new(vec![ScriptedRun { termination: RunTermination::Completed, effects: vec![effect_for_key("k", 42.0)], }])); diff --git a/crates/prod/core/host/src/host/mod.rs b/crates/prod/core/host/src/host/mod.rs index fa40790..7ffe21f 100644 --- a/crates/prod/core/host/src/host/mod.rs +++ b/crates/prod/core/host/src/host/mod.rs @@ -20,8 +20,9 @@ //! //! Safety notes: //! - This module is host-owned regardless of its former adapter-crate location. -//! - `RuntimeResultProvider` remains private to `buffering_invoker.rs`; it is a -//! host testability seam, not a public contract. +//! - The reporting-runtime helper trait remains private to +//! `buffering_invoker.rs`; it is a host testability seam, not a public +//! contract. mod buffering_invoker; mod context_store; diff --git a/crates/prod/core/host/src/replay/tests.rs b/crates/prod/core/host/src/replay/tests.rs index 0976c6f..c9945d9 100644 --- a/crates/prod/core/host/src/replay/tests.rs +++ b/crates/prod/core/host/src/replay/tests.rs @@ -21,9 +21,12 @@ use std::error::Error; use super::*; use crate::error::EgressDispatchFailure; +use crate::host::BufferingRuntimeInvoker; use crate::{HostedAdapterConfig, HostedEvent}; use ergo_adapter::capture::CaptureError; -use ergo_adapter::{AdapterProvides, ContextKeyProvision, EventId, ExternalEventKind}; +use ergo_adapter::{ + AdapterProvides, ContextKeyProvision, EventId, ExternalEventKind, ReportingRuntimeHandle, +}; use ergo_runtime::catalog::{build_core_catalog, core_registries}; use ergo_runtime::cluster::{ ExpandedEdge, ExpandedEndpoint, ExpandedGraph, ExpandedNode, ImplementationInstance, @@ -681,7 +684,7 @@ fn adapter_provides_for_series_effect() -> AdapterProvides { // Allow non-Send/Sync in Arc: CoreRegistries and CorePrimitiveCatalog contain non-Send/Sync types. #[allow(clippy::arc_with_non_send_sync)] fn runner_for_graph(graph: ExpandedGraph, provides: AdapterProvides) -> HostedRunner { - let runtime = ergo_adapter::RuntimeHandle::new( + let runtime = ReportingRuntimeHandle::new( Arc::new(graph), Arc::new(build_core_catalog()), Arc::new(core_registries().expect("core registries must initialize for host replay tests")), @@ -692,7 +695,7 @@ fn runner_for_graph(graph: ExpandedGraph, provides: AdapterProvides) -> HostedRu HostedRunner::new( ergo_adapter::GraphId::new(GRAPH_ID), Constraints::default(), - runtime, + BufferingRuntimeInvoker::new(runtime), RUNTIME_PROVENANCE.to_string(), Some(adapter), None, diff --git a/crates/prod/core/host/src/runner.rs b/crates/prod/core/host/src/runner.rs index b89e320..6f734a4 100644 --- a/crates/prod/core/host/src/runner.rs +++ b/crates/prod/core/host/src/runner.rs @@ -56,7 +56,7 @@ use std::sync::{Arc, Mutex}; use ergo_adapter::{ bind_semantic_event_with_binder, compile_event_binder, AdapterProvides, EventId, EventTime, - ExternalEvent, ExternalEventKind, GraphId, RunTermination, RuntimeHandle, + ExternalEvent, ExternalEventKind, GraphId, RunTermination, }; use ergo_runtime::common::ActionEffect; use ergo_supervisor::{ @@ -408,14 +408,14 @@ impl HostedRunner { pub fn new( graph_id: GraphId, constraints: Constraints, - runtime: RuntimeHandle, + runtime: BufferingRuntimeInvoker, runtime_provenance: String, adapter: Option, egress_config: Option, egress_provenance: Option, replay_external_kinds: Option>, ) -> Result { - let graph_emittable_effect_kinds = runtime.graph_emittable_effect_kinds(); + let graph_emittable_effect_kinds = runtime.graph_emittable_effect_kinds().clone(); let replay_external_kinds = replay_external_kinds.unwrap_or_default(); let warnings = validate_hosted_runner_configuration( adapter.as_ref(), @@ -441,7 +441,7 @@ impl HostedRunner { pub(crate) fn new_validated( graph_id: GraphId, constraints: Constraints, - runtime: RuntimeHandle, + runtime: BufferingRuntimeInvoker, runtime_provenance: String, adapter: Option, egress_config: Option, @@ -450,7 +450,6 @@ impl HostedRunner { ) -> Self { let handlers = default_handlers(); let egress = egress_config.map(EgressRuntime::new); - let runtime = BufferingRuntimeInvoker::new(runtime); let decision_log = HostDecisionLog::default(); let adapter_provenance = adapter diff --git a/crates/prod/core/host/src/runner/tests.rs b/crates/prod/core/host/src/runner/tests.rs index d74e77e..2af2753 100644 --- a/crates/prod/core/host/src/runner/tests.rs +++ b/crates/prod/core/host/src/runner/tests.rs @@ -9,7 +9,7 @@ //! downstream CLI/SDK suites. use super::*; -use ergo_adapter::{ContextKeyProvision, RuntimeHandle}; +use ergo_adapter::{ContextKeyProvision, ReportingRuntimeHandle}; use ergo_adapter::{EventBindingError, ExternalEventPayloadError}; use ergo_runtime::catalog::{build_core_catalog, core_registries}; use ergo_runtime::cluster::{ @@ -23,7 +23,7 @@ use std::time::Duration; use crate::egress::{EgressChannelConfig, EgressConfig, EgressRoute}; use crate::error::{HostedEgressValidationError, HostedEventBuildError}; -use crate::host::{EffectApplyError, HandlerCoverageError}; +use crate::host::{BufferingRuntimeInvoker, EffectApplyError, HandlerCoverageError}; fn build_context_set_bool_graph() -> ExpandedGraph { let mut nodes = HashMap::new(); @@ -413,8 +413,11 @@ fn build_merge_precedence_graph() -> ExpandedGraph { // Allow non-Send/Sync in Arc: CoreRegistries and CorePrimitiveCatalog contain non-Send/Sync types. #[allow(clippy::arc_with_non_send_sync)] -fn runtime_for_graph(graph: ExpandedGraph, provides: AdapterProvides) -> RuntimeHandle { - RuntimeHandle::new( +fn reporting_runtime_for_graph( + graph: ExpandedGraph, + provides: AdapterProvides, +) -> ReportingRuntimeHandle { + ReportingRuntimeHandle::new( Arc::new(graph), Arc::new(build_core_catalog()), Arc::new(core_registries().expect("core registries must initialize for host tests")), @@ -422,6 +425,10 @@ fn runtime_for_graph(graph: ExpandedGraph, provides: AdapterProvides) -> Runtime ) } +fn runtime_for_graph(graph: ExpandedGraph, provides: AdapterProvides) -> BufferingRuntimeInvoker { + BufferingRuntimeInvoker::new(reporting_runtime_for_graph(graph, provides)) +} + fn adapter_provides_with_effects(extra_effects: &[&str]) -> AdapterProvides { let mut context = HashMap::new(); context.insert( @@ -943,7 +950,10 @@ fn replay_step_threads_replay_mode_into_execute_step() { #[test] fn replay_mode_does_not_start_egress_channels() { let provides = adapter_provides_with_effects(&["place_order"]); - let runtime = runtime_for_graph(build_number_source_graph(), provides.clone()); + let runtime = BufferingRuntimeInvoker::new(reporting_runtime_for_graph( + build_number_source_graph(), + provides.clone(), + )); let adapter = adapter_config(provides); let egress_config = EgressConfig::builder(Duration::from_millis(50)) .channel( diff --git a/crates/prod/core/host/src/usecases/live_prep.rs b/crates/prod/core/host/src/usecases/live_prep.rs index e6ed152..a7f6b90 100644 --- a/crates/prod/core/host/src/usecases/live_prep.rs +++ b/crates/prod/core/host/src/usecases/live_prep.rs @@ -69,10 +69,12 @@ use super::{ ReplayGraphFromPathsRequest, ReplayGraphRequest, ReplayGraphResult, RunGraphFromAssetsRequest, RunGraphFromPathsRequest, RuntimeSurfaces, SessionIntent, }; +use ergo_adapter::ReportingRuntimeHandle; // Sibling module types. use super::live_run::{replay_graph, validate_driver_input}; // Crate-internal helpers. use crate::diagnostics::emit_warnings_to_stderr; +use crate::host::BufferingRuntimeInvoker; use crate::runner::host_internal_handler_kinds; pub(super) struct PreparedLiveRunnerSetup { @@ -99,7 +101,7 @@ impl PreparedLiveRunnerSetup { struct ValidatedLiveRunnerSetup { graph_id: GraphId, runtime_provenance: String, - runtime: RuntimeHandle, + runtime: BufferingRuntimeInvoker, adapter_config: Option, egress_config: Option, egress_provenance: Option, @@ -394,7 +396,7 @@ fn validate_live_runner_setup_from_assets( registries, } = prepared; - let runtime = RuntimeHandle::new( + let runtime = ReportingRuntimeHandle::new( Arc::new(expanded), catalog, registries, @@ -418,7 +420,7 @@ fn validate_live_runner_setup_from_assets( Ok(ValidatedLiveRunnerSetup { graph_id: GraphId::new(graph_id), runtime_provenance, - runtime, + runtime: BufferingRuntimeInvoker::new(runtime), adapter_config: adapter_setup.adapter_config, egress_config: options.egress_config.clone(), egress_provenance, @@ -524,7 +526,7 @@ fn captured_external_effect_kinds(bundle: &CaptureBundle) -> HashSet { } pub(super) fn replay_owned_external_kinds( - runtime: &RuntimeHandle, + runtime: &ReportingRuntimeHandle, adapter_provides: &AdapterProvides, handler_kinds: &BTreeSet, ) -> HashSet { @@ -732,7 +734,7 @@ fn prepare_replay_request_from_assets( .. } = prepared; - let runtime = RuntimeHandle::new( + let runtime = ReportingRuntimeHandle::new( Arc::new(expanded), catalog, registries, @@ -753,7 +755,7 @@ fn prepare_replay_request_from_assets( let runner = HostedRunner::new( GraphId::new(bundle.graph_id.as_str().to_string()), bundle.config.clone(), - runtime, + BufferingRuntimeInvoker::new(runtime), runtime_provenance.clone(), adapter_setup.adapter_config, None, diff --git a/crates/prod/core/host/src/usecases/shared.rs b/crates/prod/core/host/src/usecases/shared.rs index 1878b56..26012fc 100644 --- a/crates/prod/core/host/src/usecases/shared.rs +++ b/crates/prod/core/host/src/usecases/shared.rs @@ -27,7 +27,7 @@ pub(super) use ergo_adapter::{ fixture::{self, FixtureParseError}, validate_action_adapter_composition, validate_capture_format, validate_source_adapter_composition, AdapterManifest, AdapterProvides, CompositionError, - DemoSourceContextError, EventBindingError, EventTime, GraphId, InvalidAdapter, RuntimeHandle, + DemoSourceContextError, EventBindingError, EventTime, GraphId, InvalidAdapter, }; pub(super) use ergo_loader::LoaderError; pub(super) use ergo_runtime::catalog::{ diff --git a/crates/prod/core/host/src/usecases/tests/live_prep.rs b/crates/prod/core/host/src/usecases/tests/live_prep.rs index 3cd9347..4c96b11 100644 --- a/crates/prod/core/host/src/usecases/tests/live_prep.rs +++ b/crates/prod/core/host/src/usecases/tests/live_prep.rs @@ -1134,7 +1134,7 @@ fn replay_from_paths_handles_external_effect_capture_without_live_egress( let adapter = AdapterInput::Path(temp_dir.join("adapter.yaml")); let adapter_setup = prepare_adapter_setup(Some(&adapter), &prepared) .map_err(|err| format!("prepare replay adapter: {err}"))?; - let runtime = RuntimeHandle::new( + let runtime = ergo_adapter::ReportingRuntimeHandle::new( Arc::new(prepared.expanded), prepared.catalog, prepared.registries, @@ -1146,7 +1146,7 @@ fn replay_from_paths_handles_external_effect_capture_without_live_egress( let replay_runner = HostedRunner::new( GraphId::new(bundle.graph_id.as_str().to_string()), bundle.config.clone(), - runtime, + crate::host::BufferingRuntimeInvoker::new(runtime), prepared.runtime_provenance.clone(), adapter_setup.adapter_config, None, diff --git a/docs/ledger/decisions/s2.2-plan.md b/docs/ledger/decisions/s2.2-plan.md new file mode 100644 index 0000000..25cf3ab --- /dev/null +++ b/docs/ledger/decisions/s2.2-plan.md @@ -0,0 +1,302 @@ +--- +Authority: PROJECT +Date: 2026-04-19 +Decision-Owner: Sebastian (Architect) +Participants: Codex +Status: DRAFT +Scope: v1 +Parent-Decision: ../../system/freeze-v1.md +Resolves: S2.2 planning +--- + +# S2.2 Plan: Redesign the Runtime Observation Seam + +## Scope + +Pre-authorized by [`freeze-v1.md` §4.1](../../system/freeze-v1.md). Change +`RuntimeHandle::run(...)` so its public signature returns `RunTermination` +only, and move effect observation behind a host-facing seam used by +`BufferingRuntimeInvoker`. + +This is the final Session 2 code change. It does **not** change +`RuntimeInvoker::run(...)`, replay semantics, capture serde shape, or +`FaultRuntimeHandle`'s default termination-only role. + +## Step-Zero Re-Verification at Current HEAD + +- `crates/kernel/adapter/src/lib.rs` still has five `RunResult` construction + sites in `RuntimeHandle::run(...)`: + - `458-461` — deadline zero early return → `Aborted`, `effects: vec![]` + - `467-471` — `runtime_validate(...)` failure → `Failed(ValidationFailed)`, + `effects: vec![]` + - `475-478` — `validate_composition(...)` failure → + `Failed(ValidationFailed)`, `effects: vec![]` + - `497-500` — `execute_with_metadata(...)` success → `Completed`, + `effects: report.effects` + - `511-514` — `ExecError` path → `Failed(SemanticError|RuntimeError)`, + `effects: vec![]` +- Public `RunResult` consumers after S2.3 are bounded: + - Production: `crates/prod/core/host/src/host/buffering_invoker.rs` + imports `RunResult` and consumes it only through the private + `RuntimeResultProvider` trait. + - Tests: + - `crates/prod/core/host/src/host/buffering_invoker.rs` test + `ScriptedProvider` constructs `RunResult` directly. + - `crates/kernel/adapter/src/tests.rs` calls `RuntimeHandle::run(...)` + directly but only asserts `result.termination`. +- Workspace grep found no other direct `RunResult` constructors, no other + `run_result(...)` trait/helper, and no non-host production code reading + `result.effects`. +- `FaultRuntimeHandle` remains termination-only already: + `crates/kernel/adapter/src/lib.rs:701-718`. +- Capture/replay remains on the `RuntimeInvoker` path: + - supervisor invokes `RuntimeInvoker::run(...)` at + `crates/kernel/supervisor/src/lib.rs:438-444` + - `BufferingRuntimeInvoker` is host-only and drained after the step at + `crates/prod/core/host/src/runner.rs:576` + +## Sink-Shape Evaluation + +The four candidates below are evaluated as ways to attach effect observation to +the existing `RuntimeHandle` seam. The recommendation section then narrows the +problem one level up: effect observation should move off `RuntimeHandle` +entirely, because constructability is the real post-S2.3 boundary pressure. + +### 1. `&mut Vec` on a separate `RuntimeHandle` method + +Reject. + +- Ergonomics are simple, but any caller holding a public `RuntimeHandle` could + pass its own `Vec` and observe effects directly. +- This fails the §4.1 hardening goal even if `run(...)` itself becomes + termination-only. + +### 2. Kernel-defined observation trait on `RuntimeHandle` + +Reject as the primary shape. + +- If the trait/method is public so `ergo-host` can use it cross-crate, any + other adapter consumer can implement/use the same observer and regain direct + effect visibility. +- If it is sealed or crate-private strongly enough to block external callers, + it also blocks `ergo-host`. +- It keeps the observation seam conceptually attached to `RuntimeHandle`, + which is the public supervisor-facing handle that S2.2 is trying to simplify. + +### 3. Caller closure on a separate `RuntimeHandle` method + +Reject. + +- Same leak path as option 1 with slightly worse test ergonomics and weaker + type clarity around replace/drain behavior. +- A public caller with `RuntimeHandle` can still observe effects by providing a + closure. + +### 4. Host-defined observation trait implemented directly for `RuntimeHandle` + +Reject in that exact form. + +- Post-S2.3, a host-defined trait impl for a kernel type is legal. +- The problem is not the impl; it is the data source. A direct impl for + `RuntimeHandle` still needs some cross-crate helper in `ergo-adapter` that + exposes effects, and any public helper on `RuntimeHandle` re-creates the leak + path that S2.2 is meant to close. + +## Recommendation + +Use **option 4's seam ownership pattern** — a host-defined private observation +trait inside `crates/prod/core/host/src/host/buffering_invoker.rs` — but do +**not** implement it directly for `RuntimeHandle`. + +Instead, split the current mixed role into two adapter-owned engine types and +shift the public host upgrade path off `RuntimeHandle` entirely: + +- `RuntimeHandle` remains the public supervisor-facing handle and becomes + termination-only on its inherent `run(...)` method. +- A new adapter-owned effect-capable engine type (working name: + `ReportingRuntimeHandle`) carries the execution/reporting path consumed by the + host buffer shim. +- `BufferingRuntimeInvoker::new(...)` stops accepting `RuntimeHandle` and + accepts the new observed engine instead. +- No public `RuntimeHandle -> ReportingRuntimeHandle` conversion/upgrade helper + is added. +- `ReportingRuntimeHandle` is constructed through its own inherent constructor, + taking the same graph/catalog/registries/provides inputs as + `RuntimeHandle::new(...)`. There is no public conversion path from + `RuntimeHandle`. + +Why this is the recommended shape: + +- It satisfies the hard S2.2 contract precisely: a caller holding a public + `RuntimeHandle` cannot observe effects through that handle or upgrade it into + the observing path through a public conversion helper. +- It makes constructability, not trait ownership alone, the decisive boundary: + the observing path is a distinct host-facing engine, not a second method on + `RuntimeHandle`. +- It is honest about the enforcement line S2.2 can actually draw: this change + narrows effect observation off `RuntimeHandle`; it does not promise that an + effect-capable low-level engine type becomes impossible for every future + adapter consumer to construct. `ReportingRuntimeHandle` is therefore treated as + a deliberate low-level observation seam, not as a hidden or sealed host-only + capability. +- It keeps runtime/composition meaning in the kernel adapter crate rather than + duplicating `validate_composition(...)` and `execute_with_metadata(...)` in + `ergo-host`. +- It lets `ergo-host` keep the replace/drain lifecycle host-owned while using a + distinct host-facing seam instead of a public effect-bearing return from + `RuntimeHandle::run(...)`. +- It removes `RunResult` as a shared public adapter/host return type. + +Hard requirement for the implementation: + +- `RuntimeHandle` and the new observed engine must share **one** internal + execution helper so the five outcome mappings, `ValidationFailed` + categorization, and `execute_with_metadata(...)` metadata forwarding remain + defined in exactly one place. + +## FaultRuntimeHandle Disposition + +`FaultRuntimeHandle` stays termination-only. + +- It is a fault-injection harness keyed on `EventId`, not a live effect source. +- It does not need the new host-facing observation seam. +- The resulting asymmetry is acceptable: host buffering tests already use a + scripted provider, while replay/supervisor tests continue to use the simpler + termination-only fault handle. + +## Internal `RunResult` Fate + +Recommendation: keep `RunResult` only as a **private adapter helper** during +S2.2, then evaluate full removal later if it is still redundant. + +- It is already the compact representation of the five execution outcomes. +- Keeping it private reduces churn while `RuntimeHandle` and the new + effect-capable engine share the same internal execution mapping. +- The public seam change is the hard requirement for S2.2; full helper deletion + is optional if it does not buy clarity immediately. + +## Planned Module / Type Layout + +- `crates/kernel/adapter/src/lib.rs` + - Change `RuntimeHandle::run(...) -> RunTermination` + - Add the new adapter-owned effect-capable engine type beside + `RuntimeHandle` so the relationship stays explicit in the seam-defining + file. + - Add one shared private execution helper, working name `execute_once`, used + by both `RuntimeHandle` and `ReportingRuntimeHandle`. + - Keep `execute_once` private to the module (`fn`, or `pub(crate)` only if a + submodule boundary ends up requiring it). + - `execute_once` returns the retained-private `RunResult` helper. + - `RuntimeHandle::run(...)` and `ReportingRuntimeHandle::` + must both delegate to `execute_once` and must not duplicate validation or + outcome-mapping logic. + - Keep `RunResult` private here if retained. +- `crates/prod/core/host/src/host/buffering_invoker.rs` + - Replace the private `RuntimeResultProvider` seam with a host-private trait + that operates on the new effect-capable engine and reports one invocation's + `RunTermination` plus effects. + - Keep replace/drain lifecycle semantics in `BufferingRuntimeInvoker` + itself; the engine reports, the shim buffers/drains. + - Migrate tests from scripted `RunResult` queues to scripted + `(RunTermination, Vec)`-style responses or an equivalent + host-private scripted provider type. +- `crates/prod/core/host/src/runner.rs` and host prep paths + - Build the new effect-capable engine for host-runner paths instead of + passing a plain `RuntimeHandle` into `BufferingRuntimeInvoker::new(...)`. + - Update `HostedRunner::{new,new_validated}` so the public host-side upgrade + path is `BufferingRuntimeInvoker`, not `RuntimeHandle`. + - Keep supervisor/capture/replay on the existing `RuntimeInvoker` contract. + +## Ripple Enumeration + +- `crates/kernel/adapter/src/lib.rs` + - `RunResult` visibility / helper status + - `RuntimeHandle::run(...)` + - five `RunResult` construction sites + - `impl RuntimeInvoker for RuntimeHandle` +- `crates/prod/core/host/src/host/buffering_invoker.rs` + - private trait rename/replacement + - production buffering path + - inline tests and scripted provider +- `crates/prod/core/host/src/runner.rs` + - `HostedRunner::{new,new_validated}` construction path +- `crates/prod/core/host/src/usecases/live_prep.rs` + - validated/prepared runner setup path +- `crates/prod/core/host/src/usecases/shared.rs` + - shared prelude/import surface for host runtime construction +- `crates/prod/core/host/src/demo_fixture_usecase.rs` + - direct host runner construction +- `crates/prod/core/host/src/runner/tests.rs` + - helper constructors that currently return `RuntimeHandle` +- `crates/prod/core/host/src/replay/tests.rs` + - any direct host-side runtime construction for replay runner tests +- `crates/prod/core/host/src/usecases/tests/live_prep.rs` + - replay/manual-runner tests that currently construct `RuntimeHandle` +- `crates/kernel/adapter/src/tests.rs` + - assertions updated from `result.termination` to direct `RunTermination` + +Public-surface note: + +- `ergo_adapter::RunResult` and `RuntimeHandle::run(...) -> RunResult` are real + external API breaks. +- `ergo_host::HostedRunner::new(...)` currently accepts `RuntimeHandle`; if S2.2 + changes that parameter to `BufferingRuntimeInvoker`, that is a second public + API break and should be treated deliberately in the execution diff and commit + note. + +## Capture / Replay Impact + +No semantic change expected. + +- `CapturingSession` and supervisor replay use `RuntimeInvoker::run(...)`, which + is already termination-only today. +- `EpisodeInvocationRecord.termination` remains the persisted field; no serde + shape changes are planned. +- `BufferingRuntimeInvoker` remains a host-only wrapper drained after each + supervisor step, so capture enrichment and strict replay continue to compare + `EpisodeInvocationRecord.effects`, not the adapter runtime seam. + +## §3 Impact / Re-Anchor After Landing + +After S2.2 lands, the doc follow-up should update: + +- `docs/system/freeze-v1.md` + - `§0` anchor hash + - `§3.2` row for `RuntimeHandle::run` + - remove `§4.1` +- `docs/system/host-boundary.md` + - `§10` S2.2 debt row + - any `§§2–3 / 9 / 11` references that still describe `RunResult` as the + live direct return from `RuntimeHandle::run(...)` + +No other frozen §3 symbol should require a semantic rewrite. + +## Commit Structure + +Recommendation: **single atomic commit**. + +- The public `RuntimeHandle::run` signature, the host buffering seam, and the + buffering tests all move together. +- A staged sequence would need a temporary compatibility seam that either keeps + `RunResult` public longer than necessary or adds a transitional helper that + S2.2 does not want as an end state. + +## Verification and Rollback + +- Verification after execution: + - `cargo test -p ergo-adapter` + - `cargo test -p ergo-host` + - `cargo test -p ergo-supervisor` + - `cargo test -p ergo-sdk-rust` + - `cargo test --workspace` +- Rollback remains a clean `git revert`: no persisted-format change, no data + migration, and no capture-version bump. If execution appears to require + replay/capture serde edits, stop and re-scope instead of absorbing them into + S2.2. + +## Commit Note + +The S2.2 commit body should cite [`freeze-v1.md` §4.1](../../system/freeze-v1.md) +as the pre-authorization and, per §6, acknowledge the `§3.2` symbol change for +`RuntimeHandle::run`. Link this plan: +`docs/ledger/decisions/s2.2-plan.md`. From 5f5c7dc5e2f738662e4b0855327b3fd6e70a0b10 Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 16:59:25 -0700 Subject: [PATCH 04/11] Session 1 companion doc-edits for v1 boundary Banners, prepends, and cross-links landed alongside Session 1 Artifacts B and C but were not committed. - docs/invariants/08-replay.md: v1 architectural-framing prepend - docs/orchestration/adapter.md: v1 version-tag banner - docs/orchestration/supervisor.md: v1 version-tag banner - docs/system/kernel.md: v1 Workstream pointer + C.1 row - docs/system/kernel-prod-separation.md: reference cross-links Cross-reference: docs/system/host-boundary.md, docs/system/freeze-v1.md, docs/ledger/decisions/v1-host-boundary-migration.md --- docs/invariants/08-replay.md | 11 +++++++++++ docs/orchestration/adapter.md | 7 +++++++ docs/orchestration/supervisor.md | 7 +++++++ docs/system/kernel-prod-separation.md | 2 ++ docs/system/kernel.md | 6 ++++++ 5 files changed, 33 insertions(+) diff --git a/docs/invariants/08-replay.md b/docs/invariants/08-replay.md index 40e5364..1ef3df3 100644 --- a/docs/invariants/08-replay.md +++ b/docs/invariants/08-replay.md @@ -7,6 +7,17 @@ Scope: Replay-phase invariants for capture integrity and deterministic verificat Change Rule: Operational log --- +> **v1 architectural framing (2026-04-20).** The `REP-*` invariants in +> this document operate inside the v1 host-boundary architecture +> committed by [`../system/freeze-v1.md`](../system/freeze-v1.md) and +> specified by [`../system/host-boundary.md`](../system/host-boundary.md). +> The invariant table below remains authoritative for rule wording, +> enforcement sites, and test anchors. For the architectural rationale +> behind `REP-SCOPE`, the provenance trinity (`REP-7`), and the +> host-owned capture-enrichment surface that decision and effect +> comparison operate against (`REP-1`, `REP-8`), see `host-boundary.md` +> §§4, 6, 8. + ## 8. Replay Phase **Scope:** Deterministic capture and verification of episode execution. diff --git a/docs/orchestration/adapter.md b/docs/orchestration/adapter.md index 968e3c6..84e6c31 100644 --- a/docs/orchestration/adapter.md +++ b/docs/orchestration/adapter.md @@ -7,6 +7,13 @@ Verified Against Tag: v1.0.0-alpha.1 Change Rule: v1 only --- +> **Version-tag note (2026-04-20).** The frontmatter above is the +> pre-migration tag; the body of this document describes v1 adapter +> contract behavior as committed by +> [`../system/freeze-v1.md`](../system/freeze-v1.md) and specified by +> [`../system/host-boundary.md`](../system/host-boundary.md). +> Frontmatter re-anchoring is deferred to the Session 3 doc rewrite. + # Adapter Contract — v0 This document defines the minimal compliance requirements for adapter diff --git a/docs/orchestration/supervisor.md b/docs/orchestration/supervisor.md index e0b3fb7..e5c1717 100644 --- a/docs/orchestration/supervisor.md +++ b/docs/orchestration/supervisor.md @@ -7,6 +7,13 @@ Verified Against Tag: v1.0.0-alpha.1 Change Rule: v1 only --- +> **Version-tag note (2026-04-20).** The frontmatter above is the +> pre-migration tag; the body of this document describes v1 supervisor +> behavior as committed by +> [`../system/freeze-v1.md`](../system/freeze-v1.md) and specified by +> [`../system/host-boundary.md`](../system/host-boundary.md). +> Frontmatter re-anchoring is deferred to the Session 3 doc rewrite. + # Execution Supervisor — v0 This document defines the Execution Supervisor: the minimal orchestration layer that governs diff --git a/docs/system/kernel-prod-separation.md b/docs/system/kernel-prod-separation.md index 90c6ce0..3e91dfd 100644 --- a/docs/system/kernel-prod-separation.md +++ b/docs/system/kernel-prod-separation.md @@ -139,6 +139,8 @@ If any answer is ambiguous, escalate before merge. - [Current Architecture](current-architecture.md) - [Kernel Closure and v1 Workstream Declaration](kernel.md) +- [v1 Host Boundary — Invariants and Enforcement](host-boundary.md) +- [v1 Architecture Freeze Declaration](freeze-v1.md) - [Orchestration Phase Invariants](../invariants/07-orchestration.md) - [Replay Phase Invariants](../invariants/08-replay.md) - [Rule Registry](../invariants/rule-registry.md) diff --git a/docs/system/kernel.md b/docs/system/kernel.md index e8296f6..c6adc34 100644 --- a/docs/system/kernel.md +++ b/docs/system/kernel.md @@ -125,6 +125,11 @@ New semantics are allowed, but must be: - Regression-tested - Tagged/recorded as new obligations +The v1 host-boundary architecture (supervisor termination-only +contract, host-owned effect loop, provenance trinity) is specified in +[`host-boundary.md`](host-boundary.md) and committed at the symbol +level by [`freeze-v1.md`](freeze-v1.md). + --- ## Compatibility Posture @@ -166,6 +171,7 @@ Tracks semantic changes that exceed v0 scope. | Item | PR | Tag | Description | |------|-----|----------------|------------------------------------------------------------------------------------------------------------------------------| | B.2 | #35 | v1.0.0-alpha.1 | Divide-by-zero semantics: strict divide errors, safe_divide with fallback, NUM-FINITE-1 guard, SemanticError classification | +| C.1 | — | (pre-S2.x) | v1 host-boundary architecture frozen: supervisor termination-only, host-owned effect loop, provenance trinity. Invariant spec in `host-boundary.md`; symbol-level commitment in `freeze-v1.md`. Pre-authorizes S2.1/S2.2/S2.3. | --- From 3fbbe2c25b872f85ba6e72e0fb5df9e8bc26c07f Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 17:00:05 -0700 Subject: [PATCH 05/11] Clarify shared.rs prelude consumer note Reorganize the header comment to lead with who consumes the prelude (live_prep.rs, live_run.rs, and the usecases.rs facade) before noting the process_driver.rs exception. Prior wording omitted usecases.rs from the consumer list. --- crates/prod/core/host/src/usecases/shared.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/prod/core/host/src/usecases/shared.rs b/crates/prod/core/host/src/usecases/shared.rs index 26012fc..7660fd0 100644 --- a/crates/prod/core/host/src/usecases/shared.rs +++ b/crates/prod/core/host/src/usecases/shared.rs @@ -16,8 +16,9 @@ //! - Process-driver-only types (`process_driver.rs` imports directly). //! //! Safety notes: -//! - `process_driver.rs` imports all its dependencies explicitly and does NOT -//! use this prelude. Only `live_prep.rs` and `live_run.rs` consume it. +//! - This prelude is consumed by `live_prep.rs`, `live_run.rs`, and the +//! `usecases.rs` facade. `process_driver.rs` imports all its dependencies +//! explicitly and does NOT use this prelude. //! - Keep imports here narrowly aligned with real multi-consumer needs; do not //! add items used by only one sibling. From 07f29dc8be0f2e170af53b721f4ae023d7de8d7c Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 17:00:18 -0700 Subject: [PATCH 06/11] Fix stale function-name reference in live_run.rs header Header comment cited 'run_fixture_items_driver'; the function was renamed to 'run_prepared_fixture_driver'. Comment brought into sync with the actual function name at lines 658 and 669. --- crates/prod/core/host/src/usecases/live_run.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/prod/core/host/src/usecases/live_run.rs b/crates/prod/core/host/src/usecases/live_run.rs index 37cc267..23193a7 100644 --- a/crates/prod/core/host/src/usecases/live_run.rs +++ b/crates/prod/core/host/src/usecases/live_run.rs @@ -39,12 +39,13 @@ //! `process_driver.rs` via `pub(super)` function parameter. This module //! owns the lifecycle policy (bounded-run limits, host stop); the process //! driver consumes it to decide when to stop reading events. -//! - The fixture step loop (`run_fixture_items_driver`) and the process driver -//! step loop (`process_driver.rs`) share the same commit/interrupt outcome -//! routing pattern but are structurally different ingress protocols (nested -//! episode→event iteration vs. streaming message parsing). Unifying them -//! would require a trait/callback abstraction that adds indirection without -//! reducing meaningful risk. The duplication is structural, not accidental. +//! - The fixture step loop (`run_prepared_fixture_driver`) and the process +//! driver step loop (`process_driver.rs`) share the same commit/interrupt +//! outcome routing pattern but are structurally different ingress protocols +//! (nested episode→event iteration vs. streaming message parsing). Unifying +//! them would require a trait/callback abstraction that adds indirection +//! without reducing meaningful risk. The duplication is structural, not +//! accidental. // Allow non-Send/Sync in Arc: CoreRegistries and CorePrimitiveCatalog contain non-Send/Sync types. #![allow(clippy::arc_with_non_send_sync)] From d34084623a4b948cb758ca93f4782abdcd78b169 Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 17:01:24 -0700 Subject: [PATCH 07/11] Ignore .claude/ tooling worktree --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9b934b8..9dd7203 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ tools/ralph/ /sandbox/ /fixtures/ .agents/ +.claude/ __pycache__/ From 62738f9f0834b8ddd8bd631315600ad45ee7a1cb Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 19:14:12 -0700 Subject: [PATCH 08/11] Re-anchor v1 docs post-Session 2. Re-anchors the three v1 boundary documents from original authoring HEAD 7784f46f to HEAD 0218a5f, reflecting the three Session 2 transformations (S2.1 DecisionLogEntry.effects removal, S2.2 runtime seam redesign, S2.3 host-module relocation). docs/system/freeze-v1.md - Sec 0: anchor hash updated to 0218a5f; pre-authorized bullet converted to historical note describing the re-anchor rationale. - Sec 3: HEAD reference updated to 0218a5f. - Sec 3.2: RuntimeHandle::run commitment rewritten to reflect the post-S2.2 termination-only public signature; new row added for ReportingRuntimeHandle carrying run_reporting as the adapter-layer reporting seam. - Sec 3.5: CapturedActionEffect line reference refreshed (replay.rs:328-345 -> 312-329). - Sec 4: title suffixed '(historical)'; Sec 4.1 converted to a discharged historical note anchored at HEAD 0218a5f; prior-signature framing retained; concrete-sink-shape and re-anchor bullets removed as no longer applicable; step-zero audit retained as historical. - Sec 5: RunResult non-scope bullet removed (no longer transitional). docs/system/host-boundary.md - Sec 0: Last Updated date advanced to 2026-04-20; anchor hash updated to 0218a5f with re-anchor rationale; file-table blob hashes and line counts refreshed for all 15 listed files. - Sec 3.1, 3.2, 3.3, 4, 5, 6, 7, 8, 9: line-level citations refreshed throughout (supervisor/lib.rs, capture.rs, replay.rs, runner.rs, buffering_invoker.rs). Key shifts: Supervisor struct 213->212; EpisodeInvocationRecord From impl 173-188 -> 172-187; CapturingDecisionLog::log 189-206 -> 187-196; validate_replay_provenance 245-271 -> 229-255; replay_checked_strict 200 -> 184; validate_unique_event_ids 273-284 -> 257-268; compare_decisions 290 -> 274; non-effect fields 299-309 -> 284-293; effect comparison 328-345 -> 312-329; rehydrate_event 356 -> 340; buffering_invoker replace-site 112 -> 132; drain-site 86 -> 99; SUP-6 comment 794 -> 793. - Sec 6.1: replace-site citation rewritten to reflect the S2.2 sink mechanism (guard.pending_effects = effects; populated by self.engine.run_reporting(..., &mut effects)). - Sec 10: converted to historical; narrates S2.2 discharge and enumerates the three post-execution guarantees; no outstanding v1 debt tracked at current HEAD. - Sec 11: intro softened to 'pre-merge gate at original authoring; re-anchored post-Session 2'; all 26 rows refreshed with verified line ranges at HEAD 0218a5f; Row 14 substantively rewritten for the S2.2 sink-parameter mechanism; Row 17 SUP-6 refreshed to 793. docs/ledger/decisions/v1-host-boundary-migration.md - Context: the three residual-v0-shapes paragraph past-tensed; new paragraph records the Session 2 discharge at HEAD 0218a5f; the 'Runtime behavior matches' paragraph updated to reflect that the type/layout encoding is now aligned too. - Ruling point 3: re-titled 'Residual debt schedule (discharged)'; S2.2 bullet expanded to name ReportingRuntimeHandle::run_reporting as the chosen effect-observation mechanism. - Methodology: output summary updated to note the host-boundary.md Sec 11 re-anchor from 7784f46f to 0218a5f. --- .../decisions/v1-host-boundary-migration.md | 49 +++--- docs/system/freeze-v1.md | 40 +++-- docs/system/host-boundary.md | 151 ++++++++++-------- 3 files changed, 127 insertions(+), 113 deletions(-) diff --git a/docs/ledger/decisions/v1-host-boundary-migration.md b/docs/ledger/decisions/v1-host-boundary-migration.md index b6b7657..8f06350 100644 --- a/docs/ledger/decisions/v1-host-boundary-migration.md +++ b/docs/ledger/decisions/v1-host-boundary-migration.md @@ -25,24 +25,28 @@ valid for what it tracked. A forensic audit of post-closure state on 2026-04-19 discovered three residual v0 shapes the closure gate did not catch: -- `DecisionLogEntry.effects: Vec` still exists in +- `DecisionLogEntry.effects: Vec` still existed in `crates/kernel/supervisor/src/lib.rs`. Every production call site - writes `vec![]`, so the field is vestigial v0 residue. -- `RunResult` is still publicly importable from the kernel adapter - crate. Any holder of a `RuntimeHandle` can observe effects directly - off the return value, so `SUP-2` is preserved by the buffering - shim's existence rather than enforced at the type level. + wrote `vec![]`, making the field vestigial v0 residue. +- `RunResult` was still publicly importable from the kernel adapter + crate. Any holder of a `RuntimeHandle` could observe effects + directly off the return value, so `SUP-2` was preserved by the + buffering shim's existence rather than enforced at the type level. - At the time of the Session 1 audit, host-behavior modules (`BufferingRuntimeInvoker`, `ContextStore`, `ensure_handler_coverage`, the effect-handler module) still lived under `crates/kernel/adapter/src/host/` rather than `crates/prod/core/host/`. That file path was a v0 migration artifact. - Session 2 S2.3 relocates them into `crates/prod/core/host/src/host/`. -These are code-shape residuals, not semantic drift. Runtime behavior -at HEAD `7784f46f` matches the v1 boundary described in -[`host-boundary.md`](../../system/host-boundary.md); the types and -module layout encoding that behavior still carry v0 shapes in places. +All three were discharged in Session 2 at HEAD `0218a5f` via the +pre-authorized transformations recorded in `freeze-v1.md §4`. + +These were code-shape residuals, not semantic drift. Runtime behavior +at the original authoring HEAD `7784f46f` already matched the v1 +boundary described in +[`host-boundary.md`](../../system/host-boundary.md); Session 2 +brought the types and module layout encoding that behavior into +alignment as well. The audit also surfaced a process finding: the v0 freeze ([`freeze.md`](../../system/freeze.md)) referenced a joint-escalation @@ -69,17 +73,19 @@ this pass. symbols in its §3 follow the freeze-v1.md §6 change protocol (commit-body acknowledgment naming which symbol changed and why). -3. **Residual debt schedule.** Session 2 removes the three residual - v0 shapes via pre-authorized transformations recorded in - [`freeze-v1.md §4`](../../system/freeze-v1.md): +3. **Residual debt schedule (discharged).** Session 2 removed the + three residual v0 shapes via the pre-authorized transformations + recorded in [`freeze-v1.md §4`](../../system/freeze-v1.md). All + three landed at HEAD `0218a5f`: - - S2.1 removes `DecisionLogEntry.effects` - - S2.2 redesigns the runtime seam so `RuntimeHandle::run`'s public - API returns `RunTermination` only (effect-observation mechanism - chosen during S2.2 planning) - - S2.3 relocates host-behavior modules to `crates/prod/core/host/` + - S2.1 removed `DecisionLogEntry.effects` + - S2.2 redesigned the runtime seam so `RuntimeHandle::run`'s + public API returns `RunTermination` only; the reporting seam + `ReportingRuntimeHandle::run_reporting(..., &mut Vec)` + carries effects to the host-owned `BufferingRuntimeInvoker` + - S2.3 relocated host-behavior modules to `crates/prod/core/host/` - Executing these transformations during Session 2 does not require + Executing these transformations during Session 2 did not require re-escalation. 4. **Escalation-protocol lightening.** The v0 freeze's notional @@ -141,7 +147,8 @@ rediscovering the method. semantic drift. Outputs of this audit are `host-boundary.md §11` (26-row -claim-verification pass at HEAD `7784f46f`), `freeze-v1.md §3` +claim-verification pass, originally authored against HEAD `7784f46f` +and re-anchored to HEAD `0218a5f` post-Session 2), `freeze-v1.md §3` (symbol list verified via grep against current paths), and `freeze-v1.md §4` / the §Context of this record (the three residual shapes and their Session 2 disposition). diff --git a/docs/system/freeze-v1.md b/docs/system/freeze-v1.md index f1f189d..c8ff8c4 100644 --- a/docs/system/freeze-v1.md +++ b/docs/system/freeze-v1.md @@ -11,18 +11,17 @@ Change Rule: Commit-body acknowledgment (see §6) ## 0. Anchor -HEAD: `7784f46f034798de70ab24f8f3dfb31c9e5142ad` +HEAD: `0218a5fd01f90649de8da8d3924d694aecec7dae` This declaration freezes the v1 host-boundary architecture surface as observed at the HEAD above. Each symbol in §3 is named with its crate path at this commit. The invariant specification this declaration commits to is [`host-boundary.md`](host-boundary.md) (CANONICAL v1). -One pre-authorized code change remains scheduled after this freeze -landed and is recorded in §4 so that executing it does not read as a -breach: - -- Session 2 S2.2 — redesign the runtime seam to enforce termination-only on `RuntimeHandle::run`'s public API (effect-observation mechanism chosen during S2.2 planning; see §4.1) +The re-anchor from the original authoring HEAD `7784f46f` to +`0218a5f` reflects Session 2's three executed transformations (S2.1, +S2.2, S2.3). §4 retains the pre-authorization language as a +historical note so the execution chain is replayable. --- @@ -61,7 +60,7 @@ that. ## 3. Frozen Surface -Every entry names a symbol, its crate path at HEAD `7784f46f`, and the +Every entry names a symbol, its crate path at HEAD `0218a5f`, and the behavior it commits to. Files may move; symbols and contracts do not, except under §4 pre-authorized transformations. @@ -82,7 +81,8 @@ except under §4 pre-authorized transformations. |---|---|---| | `RuntimeInvoker` (trait) | `crates/kernel/adapter/src/lib.rs` | Kernel-owned contract for invoking a runtime; termination-only observable surface to the supervisor | | `RuntimeHandle` (struct) | `crates/kernel/adapter/src/lib.rs` | Adapter-layer handle used by the supervisor to drive runtime execution | -| `RuntimeHandle::run` | `crates/kernel/adapter/src/lib.rs` | Signature change pre-authorized; see §4.1 carve-out | +| `RuntimeHandle::run` | `crates/kernel/adapter/src/lib.rs` | Public signature returns `RunTermination` only; no public path observes effects. `SUP-2` is type-enforced by this seam (post-S2.2). | +| `ReportingRuntimeHandle` (struct) | `crates/kernel/adapter/src/lib.rs` | Adapter-layer handle exposing the low-level reporting seam `run_reporting(graph_id, event_id, ctx, deadline, &mut Vec) -> RunTermination`. Consumed only by `BufferingRuntimeInvoker` in `ergo-host`. Effect observation lives on this type rather than on `RuntimeHandle`. | ### 3.3 Provenance Trinity @@ -119,28 +119,27 @@ bump or alias path). | `CaptureBundle` | `crates/kernel/supervisor/src/lib.rs` | Current `capture_version` is `v3`; kernel replay enforces strict match | | `EpisodeInvocationRecord` | `crates/kernel/supervisor/src/lib.rs` | See §3.1 | | `ExternalEventRecord` | `crates/kernel/adapter/src/capture.rs` | SHA-256 hash contract (`REP-1`); re-exported into supervisor via `use ergo_adapter::capture::ExternalEventRecord` | -| `CapturedActionEffect` | `crates/kernel/supervisor/src/lib.rs` | `(effect, effect_hash)` comparison pair used by strict replay (`replay.rs:328-345`) | +| `CapturedActionEffect` | `crates/kernel/supervisor/src/lib.rs` | `(effect, effect_hash)` comparison pair used by strict replay (`replay.rs:312-329`) | | `RunTermination` | `crates/kernel/adapter/src/lib.rs` | See §3.1 | --- -## 4. Pre-Authorized Transformations - -The following code changes are pre-authorized by this freeze. -Executing them during Session 2 is not a breach and does not require -re-escalation. This document is re-anchored once each lands. +## 4. Pre-Authorized Transformations (historical) -### 4.1 S2.2 — `RuntimeHandle::run` seam redesign +The carve-out below was pre-authorized by this freeze and executed +during Session 2. It is retained as a historical note so the +pre-authorization chain is replayable; §3.2 above reflects the +post-execution shape. -**Current signature at HEAD `7784f46f`:** `RuntimeHandle::run(...) -> RunResult { termination, effects }`. Any holder of a `RuntimeHandle` — including prod-side callers outside the buffering shim — can observe effects directly off the return value, so `SUP-2` is preserved by the shim's existence rather than enforced by the type. +### 4.1 S2.2 — `RuntimeHandle::run` seam redesign (discharged) -**Approved transformation:** `RuntimeHandle::run`'s public signature returns `RunTermination` only. Effects are observable through a host-facing seam whose concrete mechanism — a sink parameter on a separate method, a kernel-defined observation trait implemented only by the buffering shim, or an equivalent construction — is chosen during S2.2 planning. The mechanism must prevent any caller holding a public `RuntimeHandle` from observing effects through the public API; placing a sink parameter on `run` itself is not pre-authorized, because it would recreate the current trust gap in a new shape. After S2.2 lands, `SUP-2` is type-enforced by the public seam rather than preserved by the shim's existence. +**Status:** Executed. Landed at HEAD `0218a5f` (Session 2 S2.2). -**Concrete sink shape:** Deferred to S2.2 planning. Candidate shapes under consideration for the sink itself (orthogonal to where the sink lives): mutable `Vec`, kernel-defined trait, caller closure. A prod-defined type is ruled out (it would invert the crate dependency). +**Prior signature (HEAD `7784f46f`):** `RuntimeHandle::run(...) -> RunResult { termination, effects }`. Any holder of a `RuntimeHandle` — including prod-side callers outside the buffering shim — could observe effects directly off the return value, so `SUP-2` was preserved by the shim's existence rather than enforced by the type. -**Pre-authorized:** Executing this transformation during S2.2 does not require re-escalation. Codex's five-site audit of the current `RunResult`-producing sites in `adapter/src/lib.rs` (lines 459, 468, 476, 498, 512) is the step-zero input to S2.2 planning. +**Executed transformation:** `RuntimeHandle::run`'s public signature now returns `RunTermination` only. A separate adapter-layer type `ReportingRuntimeHandle` carries the low-level reporting seam `run_reporting(..., effects_out: &mut Vec) -> RunTermination`, which is consumed only by `BufferingRuntimeInvoker` in `ergo-host`. `RunResult` remains inside the adapter crate as a private type; it is no longer part of the freeze surface. Post-S2.2, `SUP-2` is type-enforced by the public seam rather than preserved by the shim's existence. -**Re-anchor:** Once S2.2 lands, §3.2 of this document is updated to reflect the final signature shape, and this §4.1 row is removed. +**Step-zero audit (historical):** Codex's five-site audit of the original `RunResult`-producing sites in `adapter/src/lib.rs` at HEAD `7784f46f` (lines 459, 468, 476, 498, 512) fed S2.2 planning. Post-execution those sites live inside the private `execute_once` helper in `adapter/src/lib.rs`. --- @@ -150,7 +149,6 @@ This freeze does not cover: - Physical module/file locations (covered by S2.3; layout is free to move) - Function-internal implementation details where no symbol or serde shape is involved -- `RunResult` (transitional; subsumed by the S2.2 transformation in §4.1) - v0 primitive ontology (covered by `freeze.md`) - Authoring layer (covered by `freeze.md` §7) - Workflow/process rules (`DOC-GATE-1` and similar) diff --git a/docs/system/host-boundary.md b/docs/system/host-boundary.md index fc75b7b..be7de94 100644 --- a/docs/system/host-boundary.md +++ b/docs/system/host-boundary.md @@ -1,7 +1,7 @@ --- Authority: CANONICAL Version: v1 -Last Updated: 2026-04-19 +Last Updated: 2026-04-20 Owner: Sebastian (Architect) Scope: v1 host boundary — effect loop, context store, capture enrichment, provenance trinity Change Rule: Operational log @@ -11,33 +11,36 @@ Change Rule: Operational log ## 0. Anchor -HEAD: `7784f46f034798de70ab24f8f3dfb31c9e5142ad` +HEAD: `0218a5fd01f90649de8da8d3924d694aecec7dae` This document describes the v1 host boundary as it exists at the HEAD above. Every §3–§8 claim cites a file path and line range in the code -tree at this commit. Downstream rewrites of `07-orchestration.md`, -`08-replay.md`, `supervisor.md`, and `adapter.md` are deferred to -Session 3 and must reconcile against §9. +tree at this commit. The re-anchor from original authoring HEAD +`7784f46f` to `0218a5f` reflects Session 2's three executed +transformations (S2.1 `DecisionLogEntry.effects` removal, S2.2 runtime +seam redesign, S2.3 host-module relocation). Downstream rewrites of +`07-orchestration.md`, `08-replay.md`, `supervisor.md`, and +`adapter.md` are deferred to Session 3 and must reconcile against §9. Files described (short blob hash, path, line count at HEAD): | hash | path | lines | |---|---|---:| -| `f14eb7b6d4d5` | `crates/kernel/supervisor/src/lib.rs` | 562 | -| `357cbd4296bd` | `crates/kernel/supervisor/src/capture.rs` | 472 | -| `40887f188608` | `crates/kernel/supervisor/src/replay.rs` | 371 | -| `79d84f7d9634` | `crates/kernel/adapter/src/lib.rs` | 728 | +| `88a261cf7364` | `crates/kernel/supervisor/src/lib.rs` | 530 | +| `59b4db69ef8e` | `crates/kernel/supervisor/src/capture.rs` | 461 | +| `882943fd5ab9` | `crates/kernel/supervisor/src/replay.rs` | 355 | +| `ccd3a6b80c9c` | `crates/kernel/adapter/src/lib.rs` | 793 | | `6dc42bd9f215` | `crates/kernel/adapter/src/provenance.rs` | 107 | -| `0ecf96723dbe` | `crates/prod/core/host/src/host/buffering_invoker.rs` | 219 | +| `bf160dbd5a09` | `crates/prod/core/host/src/host/buffering_invoker.rs` | 260 | | `496cdab95622` | `crates/prod/core/host/src/host/context_store.rs` | 44 | | `b499c9b41eec` | `crates/prod/core/host/src/host/coverage.rs` | 194 | | `36ea02697c3d` | `crates/kernel/runtime/src/provenance.rs` | 397 | -| `e53ef463a1e3` | `crates/prod/core/host/src/runner.rs` | 929 | +| `6f734a45a193` | `crates/prod/core/host/src/runner.rs` | 929 | | `5e4c45dfc69f` | `docs/invariants/07-orchestration.md` | 122 | -| `40e5364b99fc` | `docs/invariants/08-replay.md` | 106 | -| `e0b3fb797542` | `docs/orchestration/supervisor.md` | 524 | -| `968e3c6d3e13` | `docs/orchestration/adapter.md` | 120 | -| `90c6ce047541` | `docs/system/kernel-prod-separation.md` | 144 | +| `1ef3df307553` | `docs/invariants/08-replay.md` | 117 | +| `e5c1717abd4a` | `docs/orchestration/supervisor.md` | 531 | +| `84e6c31f1325` | `docs/orchestration/adapter.md` | 127 | +| `3e91dfd3f6c8` | `docs/system/kernel-prod-separation.md` | 146 | The claim-verification pass (§11) re-checks these anchors as the final step. @@ -133,8 +136,8 @@ Non-responsibilities: Evidence: -- `crates/kernel/supervisor/src/lib.rs:213` — `Supervisor` struct -- `EpisodeInvocationRecord::from(&DecisionLogEntry)` in `crates/kernel/supervisor/src/lib.rs:173-188` hardcodes `effects: vec![]`, so kernel capture remains termination-only even after `DecisionLogEntry.effects` was removed. +- `crates/kernel/supervisor/src/lib.rs:212` — `Supervisor` struct +- `EpisodeInvocationRecord::from(&DecisionLogEntry)` in `crates/kernel/supervisor/src/lib.rs:172-187` hardcodes `effects: vec![]`, so kernel capture remains termination-only even after `DecisionLogEntry.effects` was removed. ### 3.2 Host (prod) — effect loop + context + enrichment @@ -183,7 +186,7 @@ by a distinct layer and bounds a distinct failure domain. - Produced by: `fingerprint(manifest)` — `crates/kernel/adapter/src/provenance.rs:10` - Input: recursively key-sorted (canonicalized) `AdapterManifest` JSON - Absent-adapter fallback: the constant string `"none"` (`NO_ADAPTER_PROVENANCE` in `crates/kernel/supervisor/src/lib.rs:50`) -- Matched on strict replay (`REP-7`): `validate_replay_provenance` — `crates/kernel/supervisor/src/replay.rs:245` +- Matched on strict replay (`REP-7`): `validate_replay_provenance` — `crates/kernel/supervisor/src/replay.rs:229` ### 4.2 `runtime_provenance` @@ -196,7 +199,7 @@ by a distinct layer and bounds a distinct failure domain. - Produced by: host (post-step) — `crates/prod/core/host/src/runner.rs:649` - Records the egress runtime configuration used during live dispatch -- Field shape: `CaptureBundle.egress_provenance: Option` — `crates/kernel/supervisor/src/lib.rs:201`; `None` for fixture/adapterless runs +- Field shape: `CaptureBundle.egress_provenance: Option` — `crates/kernel/supervisor/src/lib.rs:200`; `None` for fixture/adapterless runs - Decision record: [`docs/ledger/decisions/egress-provenance.md`](../ledger/decisions/egress-provenance.md) Replay semantics: @@ -227,7 +230,7 @@ For every `on_event(...)`: 1. Host reads `ContextStore.snapshot()` — `runner.rs:722` 2. Host merges adapter-declared, schema-allowed store keys into a candidate payload 3. Host overlays incoming event payload fields on top — `runner.rs:728-730` -4. Final merged payload is passed to the adapter binder at `runner.rs:731-740` +4. Final merged payload is passed to the adapter binder at `runner.rs:732-740` Overlay rule: **keys present in the incoming event replace any same-named keys from the store.** @@ -262,8 +265,8 @@ The runtime does not call back into the supervisor with effects. It writes effects into a host-owned buffer held by `BufferingRuntimeInvoker`: -- Each `run(...)` **replaces** `pending_effects` with the latest invocation's effects — `crates/prod/core/host/src/host/buffering_invoker.rs:112` (`guard.pending_effects = result.effects` inside `impl RuntimeInvoker for BufferingRuntimeInvoker`, lines 100-115) -- Each host step **drains** via `std::mem::take(...)` — `buffering_invoker.rs:86` +- Each `run(...)` **replaces** `pending_effects` with the latest invocation's effects — `crates/prod/core/host/src/host/buffering_invoker.rs:132` (`guard.pending_effects = effects;` inside `impl RuntimeInvoker for BufferingRuntimeInvoker`, lines 117-136; the sink `Vec` is populated by `self.engine.run_reporting(..., &mut effects)` at lines 126-128) +- Each host step **drains** via `std::mem::take(...)` — `buffering_invoker.rs:99` - Before the next `on_event`, host asserts `pending_effect_count() == 0` — `runner.rs:547-551` Replace (not append) semantics are why `HST-4` holds: a retry that @@ -278,7 +281,7 @@ Once the host drains and dispatches effects, no rollback is possible: - Egress dispatch is irreversible by construction (external I/O is committed when the channel acks) - If the outcome terminates abnormally, prior effects from this decision are still committed (`SUP-6` — invocation-scoped atomicity) -Evidence: `runner.rs:794` — comment `"SUP-6 alignment: no rollback on handler failure."` +Evidence: `runner.rs:793` — comment `"SUP-6 alignment: no rollback on handler failure."` ### 6.3 Buffer shim location @@ -295,16 +298,16 @@ which matches the v1 ownership contract. | `CaptureBundle` field | Author | Site | |---|---|---| -| `capture_version` | kernel capture | `crates/kernel/supervisor/src/capture.rs:241` | -| `graph_id` | kernel capture | `capture.rs:242` | -| `config` | kernel capture | `capture.rs:243` | -| `events` | kernel capture (`CapturingSession::on_event`) | `capture.rs:260` (`guard.events.push(...)`) | -| `decisions` (non-effect fields) | kernel capture (`CapturingDecisionLog`) | `capture.rs:189-206` (`CapturingDecisionLog::log` body; `EpisodeInvocationRecord::from(&entry)` at line 201, push at line 205) | +| `capture_version` | kernel capture | `crates/kernel/supervisor/src/capture.rs:230` | +| `graph_id` | kernel capture | `capture.rs:231` | +| `config` | kernel capture | `capture.rs:232` | +| `events` | kernel capture (`CapturingSession::on_event`) | `capture.rs:249` (`guard.events.push(...)`) | +| `decisions` (non-effect fields) | kernel capture (`CapturingDecisionLog`) | `capture.rs:187-196` (`CapturingDecisionLog::log` body; `EpisodeInvocationRecord::from(&entry)` at line 191, push at line 194) | | `decisions[i].effects` | **host** (authoritative writer; kernel capture initializes empty defaults first, see §7.3) | `runner.rs:650-655` via `enrich_bundle_with_host_artifacts` | | `decisions[i].intent_acks` | host | same | | `decisions[i].interruptions` | host | same | -| `adapter_provenance` | host seed → kernel capture | `runner.rs:455-465` (host seed) / `capture.rs:246` (kernel store in `CaptureBundle` literal at `capture.rs:240-248`) | -| `runtime_provenance` | host seed → kernel capture | `runner.rs:465-466` (host seed, passed into `CapturingSession::new_with_provenance`) / `capture.rs:247` (kernel store) | +| `adapter_provenance` | host seed → kernel capture | `runner.rs:455-465` (host seed) / `capture.rs:235` (kernel store in `CaptureBundle` literal at `capture.rs:229-238`) | +| `runtime_provenance` | host seed → kernel capture | `runner.rs:465-466` (host seed, passed into `CapturingSession::new_with_provenance`) / `capture.rs:236` (kernel store) | | `egress_provenance` | host (post-step) | `runner.rs:649` | ### 7.2 Association by decision index, not `event_id` @@ -345,23 +348,23 @@ effect content on the bundle comes from host enrichment. ### 8.1 Entry -`replay_checked_strict(bundle, runtime, expectations)` — `crates/kernel/supervisor/src/replay.rs:200`. +`replay_checked_strict(bundle, runtime, expectations)` — `crates/kernel/supervisor/src/replay.rs:184`. ### 8.2 Preflight (`validate_bundle_strict`) -1. Capture version match (`REP-1` — self-validating form) — `replay.rs:175-179` -2. All event records pass `validate_hash()` (`REP-1` — rehydration integrity) — `replay.rs:181-187` -3. No duplicate `event_id`s (`REP-8`) — `replay.rs:273-284` -4. Provenance match (`REP-7`) — `replay.rs:245-271`: +1. Capture version match (`REP-1` — self-validating form) — `replay.rs:159-163` +2. All event records pass `validate_hash()` (`REP-1` — rehydration integrity) — `replay.rs:165-171` +3. No duplicate `event_id`s (`REP-8`) — `replay.rs:257-268` +4. Provenance match (`REP-7`) — `replay.rs:229-255`: - `adapter_provenance == expected_adapter_provenance`, with the `"none"` bidirectional guard (`AdapterRequiredForProvenancedCapture` / `UnexpectedAdapterProvidedForNoAdapterCapture`) - `runtime_provenance == expected_runtime_provenance` ### 8.3 Decision comparison -`compare_decisions(captured, replayed)` — `replay.rs:290`: +`compare_decisions(captured, replayed)` — `replay.rs:274`: -- Non-effect decision fields compared positionally (`event_id`, `decision`, `schedule_at`, `episode_id`, `deadline`, `termination`, `retry_count`) — `replay.rs:299-309` -- `decisions[i].effects` compared by `(effect, effect_hash)` pair equality — `replay.rs:328-345` +- Non-effect decision fields compared positionally (`event_id`, `decision`, `schedule_at`, `episode_id`, `deadline`, `termination`, `retry_count`) — `replay.rs:284-293` +- `decisions[i].effects` compared by `(effect, effect_hash)` pair equality — `replay.rs:312-329` - Mismatch in effect count or content returns `ReplayError::EffectMismatch` ### 8.4 What replay does not verify @@ -393,10 +396,10 @@ Status values: | `CXT-1` | clarified | `runner.rs:714-744` enforces adapter-governed context keys; spec prose in `supervisor.md §3` still correctly says "externally supplied and adapter-governed" but should name the host-side enforcement locus | | `SUP-1` | applies | `crates/kernel/supervisor/src/lib.rs` — `Supervisor::graph_id` is private with no setter; set only at construction | | `SUP-2` | applies | `RuntimeInvoker::run()` returns `RunTermination` only (`crates/kernel/adapter/src/lib.rs`); no kernel supervisor path observes `RunResult`. The rule holds verbatim at HEAD. Adjacent technical debt — `RunResult`'s current adapter-crate visibility while the shim now lives in `ergo-host` — is tracked in §10 S2.2 and is a belt-and-braces hardening, not a rule change. | -| `SUP-3` | applies | Replay harness in `crates/kernel/supervisor/tests/replay_harness.rs`; strict entry at `replay.rs:200` | +| `SUP-3` | applies | Replay harness in `crates/kernel/supervisor/tests/replay_harness.rs`; strict entry at `replay.rs:184` | | `SUP-4` | applies | `should_retry()` matches only `NetworkTimeout | AdapterUnavailable | RuntimeError | TimedOut` — `supervisor/src/lib.rs` | | `SUP-5` | applies | `ErrKind` enum in `supervisor/src/lib.rs` has only mechanical variants | -| `SUP-6` | applies | Invocation-scoped atomicity preserved by host non-rollback posture — §6.2; `runner.rs:794` | +| `SUP-6` | applies | Invocation-scoped atomicity preserved by host non-rollback posture — §6.2; `runner.rs:793` | | `SUP-7` | applies | `DecisionLog` trait declares only `fn log()` in `crates/kernel/supervisor/src/lib.rs`; `records()` is on the concrete `MemoryDecisionLog`/`CapturingDecisionLog` impls, not on the trait. The write-only property holds verbatim at HEAD. | | `SUP-TICK-1` | applies | `supervisor/src/lib.rs` — Pump scheduling; legacy `Tick` alias in serde `#[serde(alias = "Tick")]` | | `RTHANDLE-META-1` | applies | `crates/kernel/adapter/src/lib.rs` — `RuntimeHandle::run()` forwards `graph_id` and `event_id` into `execute_with_metadata(...)` | @@ -405,7 +408,7 @@ Status values: | `HST-1` | applies | Host applies effects at the boundary; not read back from `DecisionLog` — `runner.rs:576` (drain), `runner.rs:746` (dispatch) | | `HST-2` | applies | `SetContextHandler::apply` in `crates/prod/core/host/src/host/effects.rs` validates declared key, writable, type | | `HST-3` | applies | `runner.rs:599-603` — non-invoke decisions must produce zero effects | -| `HST-4` | applies | Replace semantics — `buffering_invoker.rs:112` — §6.1 | +| `HST-4` | applies | Replace semantics — `buffering_invoker.rs:132` — §6.1 | | `HST-5` | applies | `ensure_handler_coverage` — `crates/prod/core/host/src/host/coverage.rs:50-78` | | `HST-6` | applies | Incoming > store overlay — `runner.rs:721-730` — §5.1 | | `HST-7` | applies | Replace-only, drain-once, commit-non-empty, no rollback — §6 | @@ -417,14 +420,14 @@ Status values: | `SDK-CANON-1` | out-of-scope | SDK-layer delegation; see `docs/system/kernel-prod-separation.md §3` | | `SDK-CANON-2` | out-of-scope | Same | | `SDK-CANON-3` | out-of-scope | Same | -| `REP-1` | applies | `ExternalEventRecord::validate_hash()` — `replay.rs:181-187` | -| `REP-2` | applies | `rehydrate_event` — `replay.rs:356` | +| `REP-1` | applies | `ExternalEventRecord::validate_hash()` — `replay.rs:165-171` | +| `REP-2` | applies | `rehydrate_event` — `replay.rs:340` | | `REP-3` | applies | Fault injection keys on `EventId` — `RTHANDLE-ID-1` mirror | | `REP-4` | applies | Capture types are in `kernel/supervisor/src/capture.rs`; runtime types are in `kernel/runtime`; the two are distinct | | `REP-5` | applies | Supervisor does not read wall-clock time; `schedule_at` is externally supplied | | `REP-6` | closed | `08-replay.md` lines 58–62: "Prior documentation suggesting 'triggers may hold internal state' was a semantic error that conflated execution-local bookkeeping with ontological state. Triggers are stateless (see `TRG-STATE-1`). There is no trigger state to capture. Temporal patterns requiring memory (once, count, latch, debounce) must be implemented as clusters." Closed by clarification 2025-12-28. | -| `REP-7` | applies | `validate_replay_provenance` — `replay.rs:245-271` — §8.2 | -| `REP-8` | applies | `validate_unique_event_ids` — `replay.rs:273-284` — §8.2 | +| `REP-7` | applies | `validate_replay_provenance` — `replay.rs:229-255` — §8.2 | +| `REP-8` | applies | `validate_unique_event_ids` — `replay.rs:257-268` — §8.2 | | `REP-SCOPE` | applies | Scope A (supervisor scheduling + host-owned effect integrity, same-ingestion path) | | `SOURCE-TRUST` | applies | Trust-based; `docs/orchestration/supervisor.md §2.3` | | `INGEST-TIME-1` | deferred | Cross-ingestion normalization parity — explicitly deferred in `08-replay.md` | @@ -438,33 +441,39 @@ declaration and composition, not the host boundary). --- -## 10. Known v1 technical debt (non-normative) +## 10. Known v1 technical debt (historical) -The remaining item below is the last Session 2 migration artifact. It -does not change v1 semantics; it codifies the boundary by tightening -the runtime seam now that S2.1 and S2.3 have landed. +At the original authoring HEAD `7784f46f`, this section listed a +single residual item — S2.2, a public-seam tightening that +`RunResult` remained publicly importable from the kernel adapter +crate while `BufferingRuntimeInvoker` had moved under +`ergo-host::host`. That item was discharged in Session 2 at HEAD +`0218a5f`: -| ID | What | Where | Session 2 work | -|---|---|---|---| -| S2.2 | `RunResult` is publicly importable from the kernel adapter crate while `BufferingRuntimeInvoker` now lives in `ergo-host::host` | `crates/kernel/adapter/src/lib.rs` and `crates/prod/core/host/src/host/buffering_invoker.rs` | Narrowing `RunResult` to `pub(crate)` is no longer viable after S2.3. Remaining options are a no-op (`pub` stays) or a seam redesign. Candidate redesign direction at current HEAD: have `RuntimeHandle::run(...)` return `RunTermination` directly and route effects to the shim through a shim-owned sink (e.g. `&mut dyn EffectSink` or a closure), eliminating `RunResult` as a shared return type. A second direction raised during review — collapsing `RunResult` into a variant on `RunTermination` — is flagged rather than listed. The load-bearing concern is persisted-format surface: `RunTermination` derives `Serialize, Deserialize` and is persisted on `EpisodeInvocationRecord.termination`, so any effect-bearing variant widens the capture bundle's on-disk shape and breaks forward-compatibility for existing captures, regardless of how the supervisor reads the value. The secondary, `SUP-2`-adjacent concern is that any supervisor code pattern-matching with a payload binding would observe effects — avoidable in code but not enforced by the type. Neither remaining direction is fully audited against `RuntimeHandle::run`'s five `RunResult`-producing sites in `adapter/src/lib.rs`; that audit is S2.2's step zero. | +- `RuntimeHandle::run`'s public signature now returns `RunTermination` only. +- A separate adapter-layer type `ReportingRuntimeHandle` carries the low-level reporting seam `run_reporting(..., effects_out: &mut Vec) -> RunTermination`, consumed only by `BufferingRuntimeInvoker` in `ergo-host`. +- `RunResult` is internal to `ergo-adapter` and no longer part of the public or freeze surface. -The item above is **non-semantic**. It brings the remaining public seam -into alignment with the boundary that this document establishes. +§3.2 of this document reflects the post-execution shape. This section +is retained as a historical anchor; no outstanding v1 debt is tracked +here at current HEAD. --- ## 11. Claim verification (read-back pass) -Each row below is a semantic claim made in §§3–8. Every claim must -resolve to the stated file and line range at HEAD -`7784f46f034798de70ab24f8f3dfb31c9e5142ad`. This section is the -final pre-merge gate for Artifact B; if any row does not resolve, the -claim is retracted or rewritten before merge. +Each row below is a semantic claim made in §§3–8. Every claim resolves +to the stated file and line range at HEAD +`0218a5fd01f90649de8da8d3924d694aecec7dae`. This section was the +pre-merge gate at original authoring (HEAD `7784f46f`); it was +re-anchored post-Session 2 at HEAD `0218a5f` to reflect the three +executed transformations (S2.1, S2.2, S2.3). If any row does not +resolve at current HEAD, the claim is retracted or rewritten. | # | Claim (section) | Stated source | Verified | |---|---|---|:---:| | 1 | Supervisor observes only `RunTermination` (§3.1) | `crates/kernel/supervisor/src/lib.rs` — `Supervisor` + `RuntimeInvoker::run` signature in `crates/kernel/adapter/src/lib.rs` | ✓ | -| 2 | Kernel capture initializes `EpisodeInvocationRecord.effects` to `vec![]` before host enrichment (§3.1, §7.3) | `supervisor/src/lib.rs:173-188` — `impl From<&DecisionLogEntry> for EpisodeInvocationRecord` hardcodes `effects: vec![]`; `capture.rs:189-205` pushes that record directly | ✓ | +| 2 | Kernel capture initializes `EpisodeInvocationRecord.effects` to `vec![]` before host enrichment (§3.1, §7.3) | `supervisor/src/lib.rs:172-187` — `impl From<&DecisionLogEntry> for EpisodeInvocationRecord` hardcodes `effects: vec![]`; `capture.rs:187-196` pushes that record directly | ✓ | | 3 | Host builds `ExternalEvent` with context merge (§3.2, §5) | `runner.rs:714-744` `build_external_event` | ✓ | | 4 | Host drains the per-step effect buffer (§3.2, §6.1) | `runner.rs:576` `self.runtime.drain_pending_effects()` | ✓ | | 5 | Host dispatches handler-owned effects into `ContextStore` (§3.2) | `runner.rs:794-796` — `handler.apply(effect, &mut self.context_store, ...)` | ✓ | @@ -476,19 +485,19 @@ claim is retracted or rewritten before merge. | 11 | `runtime_provenance` scheme is `rpv1:sha256:{hex}` (§4.2) | `crates/kernel/runtime/src/provenance.rs:74-78` `format!("{}:sha256:{}", RuntimeProvenanceScheme::Rpv1.prefix(), to_hex(&digest))` with `prefix() == "rpv1"` | ✓ | | 12 | Context merge overlays incoming > store (§5.1) | `runner.rs:721-730` — store keys inserted first (lines 722-726), incoming keys inserted after (lines 728-730) | ✓ | | 13 | Store gate requires manifest declaration + schema-allowed + present in snapshot (§5.2) | `runner.rs:722-726` — conditional on `adapter.provides.context.contains_key(key) && allowed_store_keys.contains(key)`, iterating over `self.context_store.snapshot()` | ✓ | -| 14 | Effect buffer replace on `run()` (§6.1) | `buffering_invoker.rs:112` `guard.pending_effects = result.effects` assignment (not extend), inside `impl RuntimeInvoker for BufferingRuntimeInvoker` at lines 100-115 | ✓ | -| 15 | Effect buffer drain uses `std::mem::take` (§6.1) | `buffering_invoker.rs:86` `std::mem::take(&mut guard.pending_effects)` | ✓ | +| 14 | Effect buffer replace on `run()` (§6.1) | `buffering_invoker.rs:132` `guard.pending_effects = effects;` assignment (not extend), inside `impl RuntimeInvoker for BufferingRuntimeInvoker` at lines 117-136; the sink `Vec` is populated by `self.engine.run_reporting(..., &mut effects)` at lines 126-128 | ✓ | +| 15 | Effect buffer drain uses `std::mem::take` (§6.1) | `buffering_invoker.rs:99` `std::mem::take(&mut guard.pending_effects)` | ✓ | | 16 | Host asserts empty buffer before next `on_event` (§6.1) | `runner.rs:547-551` `if self.runtime.pending_effect_count() != 0 { return Err(HostedStepError::LifecycleViolation { ... }); }` | ✓ | -| 17 | No rollback on handler failure (§6.2) | `runner.rs:794` comment `// SUP-6 alignment: no rollback on handler failure.` | ✓ | +| 17 | No rollback on handler failure (§6.2) | `runner.rs:793` comment `// SUP-6 alignment: no rollback on handler failure.` | ✓ | | 18 | Host enrichment is by decision index (§7.2) | `runner.rs:759-761` — `self.applied_effects.record(decision_index, drained_effects.to_vec())` guarded by `if !drained_effects.is_empty()` | ✓ | -| 19 | Kernel capture writes only the empty `record.effects` placeholder; non-empty effects come from host enrichment (§7.3) | `crates/kernel/supervisor/src/capture.rs:189-205` — `CapturingDecisionLog::log` pushes `EpisodeInvocationRecord::from(&entry)` directly; `runner.rs:650-655` later enriches authoritative host effects | ✓ | -| 20 | Strict replay entrypoint (§8.1) | `replay.rs:200-207` `pub fn replay_checked_strict(...) -> Result, ReplayError>` | ✓ | -| 21 | Preflight version match (§8.2) | `replay.rs:175-179` `if bundle.capture_version != crate::CAPTURE_FORMAT_VERSION` | ✓ | -| 22 | Preflight event hash validation (§8.2) | `replay.rs:181-187` `for record in &bundle.events { if !record.validate_hash() { ... } }` | ✓ | -| 23 | Preflight duplicate `event_id` rejection (§8.2) | `replay.rs:273-284` `validate_unique_event_ids` | ✓ | -| 24 | Provenance match with `"none"` bidirectional guard (§8.2) | `replay.rs:249-261` — `AdapterRequiredForProvenancedCapture` and `UnexpectedAdapterProvidedForNoAdapterCapture` variants | ✓ | -| 25 | Decision comparison covers non-effect fields positionally (§8.3) | `replay.rs:299-309` — `cap.event_id != rep.event_id || cap.decision != rep.decision || ...` | ✓ | -| 26 | Effect comparison uses `(effect, effect_hash)` equality (§8.3) | `replay.rs:328-345` `if cap_eff.effect != rep_eff.effect || cap_eff.effect_hash != rep_eff.effect_hash` | ✓ | +| 19 | Kernel capture writes only the empty `record.effects` placeholder; non-empty effects come from host enrichment (§7.3) | `crates/kernel/supervisor/src/capture.rs:187-196` — `CapturingDecisionLog::log` pushes `EpisodeInvocationRecord::from(&entry)` directly; `runner.rs:650-655` later enriches authoritative host effects | ✓ | +| 20 | Strict replay entrypoint (§8.1) | `replay.rs:184-191` `pub fn replay_checked_strict(...) -> Result, ReplayError>` | ✓ | +| 21 | Preflight version match (§8.2) | `replay.rs:159-163` `if bundle.capture_version != crate::CAPTURE_FORMAT_VERSION` | ✓ | +| 22 | Preflight event hash validation (§8.2) | `replay.rs:165-171` `for record in &bundle.events { if !record.validate_hash() { ... } }` | ✓ | +| 23 | Preflight duplicate `event_id` rejection (§8.2) | `replay.rs:257-268` `validate_unique_event_ids` | ✓ | +| 24 | Provenance match with `"none"` bidirectional guard (§8.2) | `replay.rs:234-239` — `AdapterRequiredForProvenancedCapture` and `UnexpectedAdapterProvidedForNoAdapterCapture` variants | ✓ | +| 25 | Decision comparison covers non-effect fields positionally (§8.3) | `replay.rs:284-293` — `cap.event_id != rep.event_id || cap.decision != rep.decision || ...` | ✓ | +| 26 | Effect comparison uses `(effect, effect_hash)` equality (§8.3) | `replay.rs:312-329` `if cap_eff.effect != rep_eff.effect || cap_eff.effect_hash != rep_eff.effect_hash` | ✓ | Footnote on scope: `INGEST-TIME-1` (cross-ingestion normalization parity) is not verified here because it is explicitly deferred in From 4791466c6cab17c9c7e61242188ebe6798deecc5 Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 19:38:52 -0700 Subject: [PATCH 09/11] Add Session 2 closure audit report --- .../decisions/session-2-closure-audit.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/ledger/decisions/session-2-closure-audit.md diff --git a/docs/ledger/decisions/session-2-closure-audit.md b/docs/ledger/decisions/session-2-closure-audit.md new file mode 100644 index 0000000..cc4e99f --- /dev/null +++ b/docs/ledger/decisions/session-2-closure-audit.md @@ -0,0 +1,170 @@ +# Session 2 Closure Audit + +## Scope and methodology applied + +Audit target: the post-Session-2 tree at HEAD `62738f9`, checked +against the v1 canonical boundary in +`docs/system/host-boundary.md` and the frozen symbol surface in +`docs/system/freeze-v1.md`. + +Method applied: + +1. Post-`0218a5f` commit audit: enumerate every commit after the S2.2 + seam-redesign commit and classify whether it changed architecture or + only docs / hygiene / tooling. +2. Symbol-level diff: re-check every symbol family named in + `freeze-v1.md §3` against current HEAD for path, public shape, and + unacknowledged rename/move risk. +3. Stale-reference grep: search the full tree for references that S2.x + or the re-anchor should have eliminated. +4. Invariant enforcement spot-check: verify the code still enforces the + Session-2-sensitive rules in `host-boundary.md §9`. + +Parallel reviewer passes: + +- Adversarial boundary review: attempt to find a public + `RuntimeHandle`-based path that leaks effects or upgrades into the + reporting seam. +- Full-tree doctrine alignment review: sweep comments/docs for live v1 + framing drift outside purely historical or planning artifacts. + +--- + +## Findings + +### Blocker + +- None. + +### Non-blocker + +1. `docs/system/freeze-v1.md:167` still says "Artifact A, forthcoming" + even though the retrospective now exists at + `docs/ledger/decisions/v1-host-boundary-migration.md`. This is a + stale pointer, not a freeze-surface contradiction. +2. `docs/system/host-boundary.md:398` still frames `RunResult` + visibility as adjacent open debt tracked in `§10 S2.2`. That was + true at original authoring HEAD `7784f46f`, but it is stale at + current HEAD: `RunResult` is now private in + `crates/kernel/adapter/src/lib.rs:182`, and + `RuntimeHandle::run` is termination-only at + `crates/kernel/adapter/src/lib.rs:551-558`. +3. Lower-authority rewrite debt remains in + `docs/orchestration/supervisor.md:144-145,173,247-248,271,283,344`, + which still describes `RunResult` as the current host-facing runtime + surface. This does not block push because the file is version-tagged, + non-canonical for v1, and already deferred to Session 3, but it is + still live framing drift worth cleaning up in that rewrite pass. + +### Informational + +#### 1. Post-`0218a5f` commit lane is clean + +The five commits after `0218a5f` are doc / hygiene / tooling only: + +- `5f5c7dc` — Session 1 companion doc-edits; docs only +- `3fbbe2c` — `shared.rs` header clarification; comment only +- `07f29dc` — `live_run.rs` stale-name header fix; comment only +- `d340846` — `.gitignore` update for `.claude/`; tooling only +- `62738f9` — re-anchor of v1 docs; docs only + +No post-`0218a5f` commit introduced a new architectural change or +quietly modified a frozen symbol without a `freeze-v1.md §6` +acknowledgment. The actual architecture-changing commit in scope, +`0218a5f`, does carry the required acknowledgment for +`RuntimeHandle::run`. + +#### 2. Frozen surface still resolves at current HEAD + +Symbol/path/shape spot-checks all came back consistent with +`freeze-v1.md §3`: + +- `Supervisor`, `DecisionLog`, `NO_ADAPTER_PROVENANCE`, + `EpisodeInvocationRecord`, and `CaptureBundle` all remain at + `crates/kernel/supervisor/src/lib.rs:50,87,156,191,212`. +- `CapturingDecisionLog` and `CapturingSession` remain at + `crates/kernel/supervisor/src/capture.rs:176,198`. +- `RunTermination`, `RuntimeHandle`, `RuntimeHandle::run`, + `ReportingRuntimeHandle`, and `RuntimeInvoker` remain at + `crates/kernel/adapter/src/lib.rs:188,535,551-558,571,709`. +- `ReportingRuntimeHandle::run_reporting(...)` matches the frozen + public signature exactly: + `run_reporting(graph_id, event_id, ctx, deadline, &mut Vec) -> RunTermination` + at `crates/kernel/adapter/src/lib.rs:587-598`. +- `fingerprint(...)` and `compute_runtime_provenance(...)` remain at + `crates/kernel/adapter/src/provenance.rs:10` and + `crates/kernel/runtime/src/provenance.rs:52`. + +I found no frozen symbol renamed or moved without an explicit record. +The only Session-2 symbol change in scope was the pre-authorized S2.2 +seam redesign, and that is acknowledged in the body of commit +`0218a5f`. + +#### 3. No live stale-code references remain + +The stale-reference grep came back clean in live Rust code: + +- No `DecisionLogEntry.effects` or `entry.effects` references remain in + `crates/`. +- No `ergo_adapter::RunResult` imports or other public-path `RunResult` + uses remain in `crates/`; only the private helper struct remains in + `crates/kernel/adapter/src/lib.rs:182,611-667`. +- No `run_fixture_items_driver` references remain anywhere in the tree. +- No `ergo_adapter::host` imports remain in `crates/`. +- No `pub mod host;` exists under `crates/kernel/adapter`; the only live + `pub mod host;` is the correct one at + `crates/prod/core/host/src/lib.rs:38`. +- Remaining references to `crates/kernel/adapter/src/host/`, + `DecisionLogEntry.effects`, and public `RunResult` are confined to + historical or planning docs, which is expected. + +#### 4. Session-2-sensitive rules are still enforced by code + +Spot-checks against `host-boundary.md §9` confirmed the post-S2.x +implementation shape: + +- `SUP-2`: `RuntimeHandle::run` returns `RunTermination` only at + `crates/kernel/adapter/src/lib.rs:551-558`, and there is no public + `From`/`Into`/`AsRef`/`Deref`-style conversion from `RuntimeHandle` + into `ReportingRuntimeHandle` or any other effect-observing seam in + `crates/`. +- `HST-4`: replace semantics still hold at + `crates/prod/core/host/src/host/buffering_invoker.rs:125-133` via + `guard.pending_effects = effects;`, not append. +- `HST-5`: host coverage gate remains at + `crates/prod/core/host/src/host/coverage.rs:46-70`. +- `HST-6`: incoming payload still overrides store state at + `crates/prod/core/host/src/runner.rs:721-730`. +- `HST-7`: drain-once and no-rollback posture still hold across + `buffering_invoker.rs:97-99`, `runner.rs:547-576`, + `runner.rs:650-655`, and `runner.rs:793-795`. + +#### 5. Adversarial boundary result + +The adversarial review did not find a public path that lets a caller +start from an existing `RuntimeHandle` and observe effects or upgrade +that handle into the reporting seam. + +Useful precision note: the enforced guarantee is +"no effect observation through `RuntimeHandle` and no public upgrade +from `RuntimeHandle`," not "effects are globally host-secret." +`ReportingRuntimeHandle` is public and directly constructible from raw +graph/catalog/registries/provides inputs, but no public conversion path +bridges into it from a `RuntimeHandle`, so the narrowed S2.2 contract +still holds. + +--- + +## Closing verdict + +Session 2 is fully closed and ready for push. + +I found no architectural blocker, no post-S2.2 hidden drift, no stale +live code references to removed surfaces, and no break in the +termination-only / host-owned-effect boundary. The remaining items are +small doc-hygiene follow-ups: + +- `freeze-v1.md §6` stale "Artifact A, forthcoming" pointer +- `host-boundary.md §9` stale `SUP-2` adjacency note +- lower-authority `supervisor.md` RunResult framing that is already + deferred to Session 3 From fc8eefc9569aec83a2f48ce1827aa633de8d6c6b Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 21:13:58 -0700 Subject: [PATCH 10/11] v1 doc hygiene: discharge non-blocker findings from closure audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discharges the two non-blocker stale pointers surfaced by the Session 2 closure audit (commit 4791466): - docs/system/freeze-v1.md:167 — replaced the "Session 1 retrospective (Artifact A, forthcoming)" parenthetical with a markdown link to docs/ledger/decisions/v1-host-boundary-migration.md, which now exists. Also links host-boundary.md in the same sentence. - docs/system/host-boundary.md:398 — rewrote the SUP-2 evidence cell to remove the stale "§10 S2.2" adjacency and the "belt-and-braces hardening" framing. Now records that SUP-2 is type-enforced at the public seam post-S2.2: RunResult is private to ergo-adapter at crates/kernel/adapter/src/lib.rs:182, and RuntimeHandle::run's public signature returns RunTermination only. Sentence shape intentionally mirrors freeze-v1.md §4.1's historical note so the SUP-2 enforcement story uses matching language across both canonical docs. Doc edits only; no §3 symbol changes, so no §6 acknowledgment needed. --- docs/system/freeze-v1.md | 2 +- docs/system/host-boundary.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/system/freeze-v1.md b/docs/system/freeze-v1.md index c8ff8c4..4eeaef0 100644 --- a/docs/system/freeze-v1.md +++ b/docs/system/freeze-v1.md @@ -164,7 +164,7 @@ That is the entire protocol. **Rationale note:** The v0 freeze (`freeze.md`) referenced a joint-escalation workflow that was not defined in any reachable doc and was not honored in practice. Lighter discipline that will be followed beats heavier discipline that won't. This is a solo-dev-plus-AI codebase; protocol weight has to be proportionate to enforcement capacity. -The symbol-specific scope of §3 keeps the surface narrow enough that drift on it is notable. When drift does happen, `host-boundary.md` (invariant spec) and the Session 1 retrospective (Artifact A, forthcoming) provide the working memory for cheap reconstruction. +The symbol-specific scope of §3 keeps the surface narrow enough that drift on it is notable. When drift does happen, [`host-boundary.md`](host-boundary.md) (invariant spec) and the Session 1 retrospective at [`v1-host-boundary-migration.md`](../ledger/decisions/v1-host-boundary-migration.md) provide the working memory for cheap reconstruction. --- diff --git a/docs/system/host-boundary.md b/docs/system/host-boundary.md index be7de94..55b932d 100644 --- a/docs/system/host-boundary.md +++ b/docs/system/host-boundary.md @@ -395,7 +395,7 @@ Status values: |---|---|---| | `CXT-1` | clarified | `runner.rs:714-744` enforces adapter-governed context keys; spec prose in `supervisor.md §3` still correctly says "externally supplied and adapter-governed" but should name the host-side enforcement locus | | `SUP-1` | applies | `crates/kernel/supervisor/src/lib.rs` — `Supervisor::graph_id` is private with no setter; set only at construction | -| `SUP-2` | applies | `RuntimeInvoker::run()` returns `RunTermination` only (`crates/kernel/adapter/src/lib.rs`); no kernel supervisor path observes `RunResult`. The rule holds verbatim at HEAD. Adjacent technical debt — `RunResult`'s current adapter-crate visibility while the shim now lives in `ergo-host` — is tracked in §10 S2.2 and is a belt-and-braces hardening, not a rule change. | +| `SUP-2` | applies | `RuntimeInvoker::run()` returns `RunTermination` only (`crates/kernel/adapter/src/lib.rs`); no kernel supervisor path observes `RunResult`. The rule holds verbatim at HEAD. Post-S2.2, `RunResult` is private to `ergo-adapter` (`crates/kernel/adapter/src/lib.rs:182`) and `RuntimeHandle::run`'s public signature returns `RunTermination` only, so `SUP-2` is type-enforced at the public seam rather than preserved by the shim's existence. | | `SUP-3` | applies | Replay harness in `crates/kernel/supervisor/tests/replay_harness.rs`; strict entry at `replay.rs:184` | | `SUP-4` | applies | `should_retry()` matches only `NetworkTimeout | AdapterUnavailable | RuntimeError | TimedOut` — `supervisor/src/lib.rs` | | `SUP-5` | applies | `ErrKind` enum in `supervisor/src/lib.rs` has only mechanical variants | From 8cd8a6ea7de6e429402729db9b0215c7f8fc9cba Mon Sep 17 00:00:00 2001 From: sf19-97 Date: Sun, 19 Apr 2026 23:27:14 -0700 Subject: [PATCH 11/11] Extract oversized inline test modules to sibling files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the oversized inline #[cfg(test)] mod tests { ... } blocks in the three S2.3-relocated host files into sibling tests.rs files, matching the project convention established by crates/kernel/supervisor/src/capture.rs (and documented in AGENTS.md §4.B: the default for out-of-line unit tests is foo.rs + foo/tests.rs). Each of the three relocated modules carried an inline test block well over the ~50-line threshold. Files: - crates/prod/core/host/src/host/buffering_invoker.rs — inline block (~122 lines) replaced with `#[cfg(test)] mod tests;`; body moved verbatim to crates/prod/core/host/src/host/buffering_invoker/tests.rs. - crates/prod/core/host/src/host/effects.rs — inline block (~137 lines) replaced with `#[cfg(test)] mod tests;`; body moved verbatim to crates/prod/core/host/src/host/effects/tests.rs. - crates/prod/core/host/src/host/coverage.rs — inline block (~114 lines) replaced with `#[cfg(test)] mod tests;`; body moved verbatim to crates/prod/core/host/src/host/coverage/tests.rs. Pure layout extraction: no test logic changed; no imports changed beyond being re-anchored at the new module root; `use super::*;` still refers to the parent production module. Production module shape stays as foo.rs with a sibling foo/ directory for tests, matching capture.rs / capture/tests.rs. These blocks were inline at the same sizes in the pre-S2.3 location (crates/kernel/adapter/src/host/) and were carried over verbatim during S2.3's pure-relocation scope. This commit discharges the style debt surfaced by Greptile on PR #80. Verification: cargo test -p ergo-host green (224 passed); cargo test --workspace green (no failures). No §6 invocation: no §3 symbol changes, only test-module layout. --- .../core/host/src/host/buffering_invoker.rs | 123 +--------------- .../host/src/host/buffering_invoker/tests.rs | 120 +++++++++++++++ crates/prod/core/host/src/host/coverage.rs | 115 +-------------- .../prod/core/host/src/host/coverage/tests.rs | 112 ++++++++++++++ crates/prod/core/host/src/host/effects.rs | 138 +----------------- .../prod/core/host/src/host/effects/tests.rs | 135 +++++++++++++++++ 6 files changed, 370 insertions(+), 373 deletions(-) create mode 100644 crates/prod/core/host/src/host/buffering_invoker/tests.rs create mode 100644 crates/prod/core/host/src/host/coverage/tests.rs create mode 100644 crates/prod/core/host/src/host/effects/tests.rs diff --git a/crates/prod/core/host/src/host/buffering_invoker.rs b/crates/prod/core/host/src/host/buffering_invoker.rs index bf160db..ad9d876 100644 --- a/crates/prod/core/host/src/host/buffering_invoker.rs +++ b/crates/prod/core/host/src/host/buffering_invoker.rs @@ -136,125 +136,4 @@ impl RuntimeInvoker for BufferingRuntimeInvoker { } #[cfg(test)] -mod tests { - use super::*; - use ergo_adapter::{ErrKind, EventTime, ExternalEvent, ExternalEventKind}; - use ergo_runtime::common::{EffectWrite, Value}; - - struct ScriptedRun { - termination: RunTermination, - effects: Vec, - } - - struct ScriptedProvider { - queue: Mutex>, - graph_emittable_effect_kinds: HashSet, - } - - impl ScriptedProvider { - fn new(queue: Vec) -> Self { - Self { - queue: Mutex::new(queue), - graph_emittable_effect_kinds: HashSet::new(), - } - } - } - - impl ReportingRuntime for ScriptedProvider { - fn run_reporting( - &self, - _graph_id: &GraphId, - _event_id: &EventId, - _ctx: &ExecutionContext, - _deadline: Option, - effects_out: &mut Vec, - ) -> RunTermination { - let mut guard = self.queue.lock().expect("scripted queue poisoned"); - if guard.is_empty() { - effects_out.clear(); - return RunTermination::Completed; - } - let scripted = guard.remove(0); - *effects_out = scripted.effects; - scripted.termination - } - - fn graph_emittable_effect_kinds(&self) -> HashSet { - self.graph_emittable_effect_kinds.clone() - } - } - - fn effect_for_key(key: &str, value: f64) -> ActionEffect { - ActionEffect { - kind: "set_context".to_string(), - writes: vec![EffectWrite { - key: key.to_string(), - value: Value::Number(value), - }], - intents: vec![], - } - } - - #[test] - fn replaces_pending_effects_on_retry_attempt() { - let provider = Arc::new(ScriptedProvider::new(vec![ - ScriptedRun { - termination: RunTermination::Failed(ErrKind::NetworkTimeout), - effects: vec![effect_for_key("first", 1.0)], - }, - ScriptedRun { - termination: RunTermination::Completed, - effects: vec![effect_for_key("second", 2.0)], - }, - ])); - let invoker = BufferingRuntimeInvoker::new_with_provider(provider); - let ctx = ExternalEvent::mechanical_at( - EventId::new("seed"), - ExternalEventKind::Command, - EventTime::default(), - ) - .context() - .clone(); - let graph_id = GraphId::new("g"); - let event_id = EventId::new("e"); - - let first = invoker.run(&graph_id, &event_id, &ctx, None); - assert_eq!(first, RunTermination::Failed(ErrKind::NetworkTimeout)); - assert_eq!(invoker.pending_effect_count(), 1); - - let second = invoker.run(&graph_id, &event_id, &ctx, None); - assert_eq!(second, RunTermination::Completed); - assert_eq!(invoker.pending_effect_count(), 1); - assert_eq!(invoker.run_call_count(), 2); - - let drained = invoker.drain_pending_effects(); - assert_eq!(drained.len(), 1); - assert_eq!(drained[0].writes[0].key, "second"); - } - - #[test] - fn drain_pending_effects_is_single_use_and_clears_buffer() { - let provider = Arc::new(ScriptedProvider::new(vec![ScriptedRun { - termination: RunTermination::Completed, - effects: vec![effect_for_key("k", 42.0)], - }])); - let invoker = BufferingRuntimeInvoker::new_with_provider(provider); - let ctx = ExternalEvent::mechanical_at( - EventId::new("seed"), - ExternalEventKind::Command, - EventTime::default(), - ) - .context() - .clone(); - - let _ = invoker.run(&GraphId::new("g"), &EventId::new("e"), &ctx, None); - assert_eq!(invoker.pending_effect_count(), 1); - - let first = invoker.drain_pending_effects(); - assert_eq!(first.len(), 1); - assert_eq!(invoker.pending_effect_count(), 0); - - let second = invoker.drain_pending_effects(); - assert!(second.is_empty()); - } -} +mod tests; diff --git a/crates/prod/core/host/src/host/buffering_invoker/tests.rs b/crates/prod/core/host/src/host/buffering_invoker/tests.rs new file mode 100644 index 0000000..40ee0ba --- /dev/null +++ b/crates/prod/core/host/src/host/buffering_invoker/tests.rs @@ -0,0 +1,120 @@ +use super::*; +use ergo_adapter::{ErrKind, EventTime, ExternalEvent, ExternalEventKind}; +use ergo_runtime::common::{EffectWrite, Value}; + +struct ScriptedRun { + termination: RunTermination, + effects: Vec, +} + +struct ScriptedProvider { + queue: Mutex>, + graph_emittable_effect_kinds: HashSet, +} + +impl ScriptedProvider { + fn new(queue: Vec) -> Self { + Self { + queue: Mutex::new(queue), + graph_emittable_effect_kinds: HashSet::new(), + } + } +} + +impl ReportingRuntime for ScriptedProvider { + fn run_reporting( + &self, + _graph_id: &GraphId, + _event_id: &EventId, + _ctx: &ExecutionContext, + _deadline: Option, + effects_out: &mut Vec, + ) -> RunTermination { + let mut guard = self.queue.lock().expect("scripted queue poisoned"); + if guard.is_empty() { + effects_out.clear(); + return RunTermination::Completed; + } + let scripted = guard.remove(0); + *effects_out = scripted.effects; + scripted.termination + } + + fn graph_emittable_effect_kinds(&self) -> HashSet { + self.graph_emittable_effect_kinds.clone() + } +} + +fn effect_for_key(key: &str, value: f64) -> ActionEffect { + ActionEffect { + kind: "set_context".to_string(), + writes: vec![EffectWrite { + key: key.to_string(), + value: Value::Number(value), + }], + intents: vec![], + } +} + +#[test] +fn replaces_pending_effects_on_retry_attempt() { + let provider = Arc::new(ScriptedProvider::new(vec![ + ScriptedRun { + termination: RunTermination::Failed(ErrKind::NetworkTimeout), + effects: vec![effect_for_key("first", 1.0)], + }, + ScriptedRun { + termination: RunTermination::Completed, + effects: vec![effect_for_key("second", 2.0)], + }, + ])); + let invoker = BufferingRuntimeInvoker::new_with_provider(provider); + let ctx = ExternalEvent::mechanical_at( + EventId::new("seed"), + ExternalEventKind::Command, + EventTime::default(), + ) + .context() + .clone(); + let graph_id = GraphId::new("g"); + let event_id = EventId::new("e"); + + let first = invoker.run(&graph_id, &event_id, &ctx, None); + assert_eq!(first, RunTermination::Failed(ErrKind::NetworkTimeout)); + assert_eq!(invoker.pending_effect_count(), 1); + + let second = invoker.run(&graph_id, &event_id, &ctx, None); + assert_eq!(second, RunTermination::Completed); + assert_eq!(invoker.pending_effect_count(), 1); + assert_eq!(invoker.run_call_count(), 2); + + let drained = invoker.drain_pending_effects(); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].writes[0].key, "second"); +} + +#[test] +fn drain_pending_effects_is_single_use_and_clears_buffer() { + let provider = Arc::new(ScriptedProvider::new(vec![ScriptedRun { + termination: RunTermination::Completed, + effects: vec![effect_for_key("k", 42.0)], + }])); + let invoker = BufferingRuntimeInvoker::new_with_provider(provider); + let ctx = ExternalEvent::mechanical_at( + EventId::new("seed"), + ExternalEventKind::Command, + EventTime::default(), + ) + .context() + .clone(); + + let _ = invoker.run(&GraphId::new("g"), &EventId::new("e"), &ctx, None); + assert_eq!(invoker.pending_effect_count(), 1); + + let first = invoker.drain_pending_effects(); + assert_eq!(first.len(), 1); + assert_eq!(invoker.pending_effect_count(), 0); + + let second = invoker.drain_pending_effects(); + assert!(second.is_empty()); +} diff --git a/crates/prod/core/host/src/host/coverage.rs b/crates/prod/core/host/src/host/coverage.rs index b499c9b..ddcf87a 100644 --- a/crates/prod/core/host/src/host/coverage.rs +++ b/crates/prod/core/host/src/host/coverage.rs @@ -78,117 +78,4 @@ pub fn ensure_handler_coverage( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn coverage_only_checks_graph_emittable_intersection() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("set_context".to_string()); - provides.effects.insert("send_notification".to_string()); - - let graph_emittable = HashSet::from(["set_context".to_string()]); - let handlers = BTreeSet::from(["set_context".to_string()]); - let egress = HashSet::new(); - - let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); - assert!(result.is_ok()); - } - - #[test] - fn coverage_fails_when_graph_emittable_accepted_kind_has_no_handler() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("set_context".to_string()); - - let graph_emittable = HashSet::from(["set_context".to_string()]); - let handlers = BTreeSet::new(); - let egress = HashSet::new(); - - let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) - .expect_err("missing handler must fail coverage"); - assert_eq!( - err, - HandlerCoverageError::MissingHandler { - effect_kind: "set_context".to_string() - } - ); - } - - #[test] - fn non_accepted_graph_kind_is_not_coverage_obligation() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("set_context".to_string()); - - let graph_emittable = HashSet::from(["send_notification".to_string()]); - let handlers = BTreeSet::new(); - let egress = HashSet::new(); - - let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); - assert!(result.is_ok()); - } - - #[test] - fn egress_claimed_kind_satisfies_coverage_without_handler() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("place_order".to_string()); - - let graph_emittable = HashSet::from(["place_order".to_string()]); - let handlers = BTreeSet::new(); - let egress = HashSet::from(["place_order".to_string()]); - - let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); - assert!(result.is_ok()); - } - - #[test] - fn coverage_fails_when_kind_is_neither_handler_nor_egress_claimed() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("place_order".to_string()); - - let graph_emittable = HashSet::from(["place_order".to_string()]); - let handlers = BTreeSet::new(); - let egress = HashSet::new(); - - let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) - .expect_err("uncovered kind must fail coverage"); - assert_eq!( - err, - HandlerCoverageError::MissingHandler { - effect_kind: "place_order".to_string() - } - ); - } - - #[test] - fn coverage_fails_when_handler_and_egress_both_claim_same_kind() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("set_context".to_string()); - - let graph_emittable = HashSet::from(["set_context".to_string()]); - let handlers = BTreeSet::from(["set_context".to_string()]); - let egress = HashSet::from(["set_context".to_string()]); - - let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) - .expect_err("duplicate ownership must fail coverage"); - assert_eq!( - err, - HandlerCoverageError::ConflictingCoverage { - effect_kind: "set_context".to_string() - } - ); - } - - #[test] - fn mixed_handler_and_egress_coverage_passes() { - let mut provides = AdapterProvides::default(); - provides.effects.insert("set_context".to_string()); - provides.effects.insert("place_order".to_string()); - - let graph_emittable = HashSet::from(["set_context".to_string(), "place_order".to_string()]); - let handlers = BTreeSet::from(["set_context".to_string()]); - let egress = HashSet::from(["place_order".to_string()]); - - let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); - assert!(result.is_ok()); - } -} +mod tests; diff --git a/crates/prod/core/host/src/host/coverage/tests.rs b/crates/prod/core/host/src/host/coverage/tests.rs new file mode 100644 index 0000000..92dac1b --- /dev/null +++ b/crates/prod/core/host/src/host/coverage/tests.rs @@ -0,0 +1,112 @@ +use super::*; + +#[test] +fn coverage_only_checks_graph_emittable_intersection() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("set_context".to_string()); + provides.effects.insert("send_notification".to_string()); + + let graph_emittable = HashSet::from(["set_context".to_string()]); + let handlers = BTreeSet::from(["set_context".to_string()]); + let egress = HashSet::new(); + + let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); + assert!(result.is_ok()); +} + +#[test] +fn coverage_fails_when_graph_emittable_accepted_kind_has_no_handler() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("set_context".to_string()); + + let graph_emittable = HashSet::from(["set_context".to_string()]); + let handlers = BTreeSet::new(); + let egress = HashSet::new(); + + let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) + .expect_err("missing handler must fail coverage"); + assert_eq!( + err, + HandlerCoverageError::MissingHandler { + effect_kind: "set_context".to_string() + } + ); +} + +#[test] +fn non_accepted_graph_kind_is_not_coverage_obligation() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("set_context".to_string()); + + let graph_emittable = HashSet::from(["send_notification".to_string()]); + let handlers = BTreeSet::new(); + let egress = HashSet::new(); + + let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); + assert!(result.is_ok()); +} + +#[test] +fn egress_claimed_kind_satisfies_coverage_without_handler() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("place_order".to_string()); + + let graph_emittable = HashSet::from(["place_order".to_string()]); + let handlers = BTreeSet::new(); + let egress = HashSet::from(["place_order".to_string()]); + + let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); + assert!(result.is_ok()); +} + +#[test] +fn coverage_fails_when_kind_is_neither_handler_nor_egress_claimed() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("place_order".to_string()); + + let graph_emittable = HashSet::from(["place_order".to_string()]); + let handlers = BTreeSet::new(); + let egress = HashSet::new(); + + let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) + .expect_err("uncovered kind must fail coverage"); + assert_eq!( + err, + HandlerCoverageError::MissingHandler { + effect_kind: "place_order".to_string() + } + ); +} + +#[test] +fn coverage_fails_when_handler_and_egress_both_claim_same_kind() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("set_context".to_string()); + + let graph_emittable = HashSet::from(["set_context".to_string()]); + let handlers = BTreeSet::from(["set_context".to_string()]); + let egress = HashSet::from(["set_context".to_string()]); + + let err = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress) + .expect_err("duplicate ownership must fail coverage"); + assert_eq!( + err, + HandlerCoverageError::ConflictingCoverage { + effect_kind: "set_context".to_string() + } + ); +} + +#[test] +fn mixed_handler_and_egress_coverage_passes() { + let mut provides = AdapterProvides::default(); + provides.effects.insert("set_context".to_string()); + provides.effects.insert("place_order".to_string()); + + let graph_emittable = HashSet::from(["set_context".to_string(), "place_order".to_string()]); + let handlers = BTreeSet::from(["set_context".to_string()]); + let egress = HashSet::from(["place_order".to_string()]); + + let result = ensure_handler_coverage(&provides, &graph_emittable, &handlers, &egress); + assert!(result.is_ok()); +} diff --git a/crates/prod/core/host/src/host/effects.rs b/crates/prod/core/host/src/host/effects.rs index 8d09580..d1c3748 100644 --- a/crates/prod/core/host/src/host/effects.rs +++ b/crates/prod/core/host/src/host/effects.rs @@ -191,140 +191,4 @@ fn runtime_value_to_json(value: &Value) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use ergo_adapter::ContextKeyProvision; - use ergo_runtime::common::EffectWrite; - use std::collections::HashMap; - - fn provides_with_context( - entries: impl IntoIterator, - ) -> AdapterProvides { - let mut provides = AdapterProvides::default(); - let context = entries - .into_iter() - .map(|(key, ty, writable)| { - ( - key.to_string(), - ContextKeyProvision { - ty: ty.to_string(), - required: false, - writable, - }, - ) - }) - .collect::>(); - provides.context = context; - provides - } - - fn set_context_effect(writes: Vec<(&str, Value)>) -> ActionEffect { - ActionEffect { - kind: "set_context".to_string(), - writes: writes - .into_iter() - .map(|(key, value)| EffectWrite { - key: key.to_string(), - value, - }) - .collect(), - intents: vec![], - } - } - - #[test] - fn set_context_applies_declared_writable_key() { - let handler = SetContextHandler; - let mut store = ContextStore::new(); - let provides = provides_with_context([("ema_fast", "Number", true)]); - let effect = set_context_effect(vec![("ema_fast", Value::Number(12.5))]); - - let applied = handler - .apply(&effect, &mut store, &provides) - .expect("set_context write should apply"); - - assert_eq!(applied.len(), 1); - assert_eq!(applied[0].key, "ema_fast"); - assert_eq!(store.get("ema_fast"), Some(&serde_json::json!(12.5))); - } - - #[test] - fn set_context_rejects_undeclared_key() { - let handler = SetContextHandler; - let mut store = ContextStore::new(); - let provides = provides_with_context([("ema_fast", "Number", true)]); - let effect = set_context_effect(vec![("ema_slow", Value::Number(21.0))]); - - let err = handler - .apply(&effect, &mut store, &provides) - .expect_err("undeclared key must be rejected"); - - assert!(matches!( - err, - EffectApplyError::UndeclaredKey { kind, key } - if kind == "set_context" && key == "ema_slow" - )); - assert!(store.snapshot().is_empty()); - } - - #[test] - fn set_context_rejects_non_writable_key() { - let handler = SetContextHandler; - let mut store = ContextStore::new(); - let provides = provides_with_context([("trend_label", "String", false)]); - let effect = set_context_effect(vec![("trend_label", Value::String("up".to_string()))]); - - let err = handler - .apply(&effect, &mut store, &provides) - .expect_err("non-writable key must be rejected"); - - assert!(matches!( - err, - EffectApplyError::NonWritableKey { kind, key } - if kind == "set_context" && key == "trend_label" - )); - assert!(store.snapshot().is_empty()); - } - - #[test] - fn set_context_rejects_type_mismatch() { - let handler = SetContextHandler; - let mut store = ContextStore::new(); - let provides = provides_with_context([("armed", "Bool", true)]); - let effect = set_context_effect(vec![("armed", Value::Number(1.0))]); - - let err = handler - .apply(&effect, &mut store, &provides) - .expect_err("type mismatch must be rejected"); - - assert!(matches!( - err, - EffectApplyError::TypeMismatch { kind, key, expected, got } - if kind == "set_context" && key == "armed" && expected == "Bool" && got == "Number" - )); - assert!(store.snapshot().is_empty()); - } - - #[test] - fn set_context_no_rollback_when_later_write_fails() { - let handler = SetContextHandler; - let mut store = ContextStore::new(); - let provides = provides_with_context([("ema_fast", "Number", true)]); - let effect = set_context_effect(vec![ - ("ema_fast", Value::Number(10.0)), - ("ema_slow", Value::Number(20.0)), - ]); - - let err = handler - .apply(&effect, &mut store, &provides) - .expect_err("second undeclared write should fail"); - - assert!(matches!( - err, - EffectApplyError::UndeclaredKey { kind, key } - if kind == "set_context" && key == "ema_slow" - )); - // SUP-6 alignment: partial writes remain applied; no transactional rollback. - assert_eq!(store.get("ema_fast"), Some(&serde_json::json!(10.0))); - } -} +mod tests; diff --git a/crates/prod/core/host/src/host/effects/tests.rs b/crates/prod/core/host/src/host/effects/tests.rs new file mode 100644 index 0000000..d7753c1 --- /dev/null +++ b/crates/prod/core/host/src/host/effects/tests.rs @@ -0,0 +1,135 @@ +use super::*; +use ergo_adapter::ContextKeyProvision; +use ergo_runtime::common::EffectWrite; +use std::collections::HashMap; + +fn provides_with_context( + entries: impl IntoIterator, +) -> AdapterProvides { + let mut provides = AdapterProvides::default(); + let context = entries + .into_iter() + .map(|(key, ty, writable)| { + ( + key.to_string(), + ContextKeyProvision { + ty: ty.to_string(), + required: false, + writable, + }, + ) + }) + .collect::>(); + provides.context = context; + provides +} + +fn set_context_effect(writes: Vec<(&str, Value)>) -> ActionEffect { + ActionEffect { + kind: "set_context".to_string(), + writes: writes + .into_iter() + .map(|(key, value)| EffectWrite { + key: key.to_string(), + value, + }) + .collect(), + intents: vec![], + } +} + +#[test] +fn set_context_applies_declared_writable_key() { + let handler = SetContextHandler; + let mut store = ContextStore::new(); + let provides = provides_with_context([("ema_fast", "Number", true)]); + let effect = set_context_effect(vec![("ema_fast", Value::Number(12.5))]); + + let applied = handler + .apply(&effect, &mut store, &provides) + .expect("set_context write should apply"); + + assert_eq!(applied.len(), 1); + assert_eq!(applied[0].key, "ema_fast"); + assert_eq!(store.get("ema_fast"), Some(&serde_json::json!(12.5))); +} + +#[test] +fn set_context_rejects_undeclared_key() { + let handler = SetContextHandler; + let mut store = ContextStore::new(); + let provides = provides_with_context([("ema_fast", "Number", true)]); + let effect = set_context_effect(vec![("ema_slow", Value::Number(21.0))]); + + let err = handler + .apply(&effect, &mut store, &provides) + .expect_err("undeclared key must be rejected"); + + assert!(matches!( + err, + EffectApplyError::UndeclaredKey { kind, key } + if kind == "set_context" && key == "ema_slow" + )); + assert!(store.snapshot().is_empty()); +} + +#[test] +fn set_context_rejects_non_writable_key() { + let handler = SetContextHandler; + let mut store = ContextStore::new(); + let provides = provides_with_context([("trend_label", "String", false)]); + let effect = set_context_effect(vec![("trend_label", Value::String("up".to_string()))]); + + let err = handler + .apply(&effect, &mut store, &provides) + .expect_err("non-writable key must be rejected"); + + assert!(matches!( + err, + EffectApplyError::NonWritableKey { kind, key } + if kind == "set_context" && key == "trend_label" + )); + assert!(store.snapshot().is_empty()); +} + +#[test] +fn set_context_rejects_type_mismatch() { + let handler = SetContextHandler; + let mut store = ContextStore::new(); + let provides = provides_with_context([("armed", "Bool", true)]); + let effect = set_context_effect(vec![("armed", Value::Number(1.0))]); + + let err = handler + .apply(&effect, &mut store, &provides) + .expect_err("type mismatch must be rejected"); + + assert!(matches!( + err, + EffectApplyError::TypeMismatch { kind, key, expected, got } + if kind == "set_context" && key == "armed" && expected == "Bool" && got == "Number" + )); + assert!(store.snapshot().is_empty()); +} + +#[test] +fn set_context_no_rollback_when_later_write_fails() { + let handler = SetContextHandler; + let mut store = ContextStore::new(); + let provides = provides_with_context([("ema_fast", "Number", true)]); + let effect = set_context_effect(vec![ + ("ema_fast", Value::Number(10.0)), + ("ema_slow", Value::Number(20.0)), + ]); + + let err = handler + .apply(&effect, &mut store, &provides) + .expect_err("second undeclared write should fail"); + + assert!(matches!( + err, + EffectApplyError::UndeclaredKey { kind, key } + if kind == "set_context" && key == "ema_slow" + )); + // SUP-6 alignment: partial writes remain applied; no transactional rollback. + assert_eq!(store.get("ema_fast"), Some(&serde_json::json!(10.0))); +}