diff --git a/Cargo.lock b/Cargo.lock index 8ab9d2e6232..7169b667b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3524,6 +3524,8 @@ dependencies = [ "ironclaw_events", "ironclaw_filesystem", "ironclaw_host_api", + "ironclaw_runtime_policy", + "ironclaw_trust", "serde", "serde_json", "tempfile", @@ -3580,6 +3582,7 @@ dependencies = [ "ironclaw_extension_contracts", "ironclaw_filesystem", "ironclaw_host_api", + "ironclaw_product_contracts", "ironclaw_secrets", "rand 0.10.2", "secrecy", @@ -4408,7 +4411,6 @@ dependencies = [ "ironclaw_event_streams", "ironclaw_events", "ironclaw_extension_contracts", - "ironclaw_extensions", "ironclaw_filesystem", "ironclaw_first_party_extension_ports", "ironclaw_host_api", @@ -4421,9 +4423,11 @@ dependencies = [ "ironclaw_product_contracts", "ironclaw_projects", "ironclaw_reborn_composition", + "ironclaw_reborn_identity", "ironclaw_reborn_traces", "ironclaw_runner", "ironclaw_safety", + "ironclaw_secrets", "ironclaw_telegram_extension", "ironclaw_threads", "ironclaw_triggers", @@ -4748,7 +4752,6 @@ dependencies = [ "ironclaw_host_api", "ironclaw_product", "ironclaw_product_contracts", - "ironclaw_reborn_openai_compat", "ironclaw_threads", "ironclaw_turns", "serde", @@ -4789,6 +4792,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "tokio", + "tokio-util", "tracing", "uuid", ] @@ -4842,6 +4846,7 @@ dependencies = [ "ironclaw_outbound", "ironclaw_processes", "ironclaw_reborn_event_store", + "ironclaw_reborn_traces", "ironclaw_resources", "ironclaw_runner", "ironclaw_safety", @@ -4863,6 +4868,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", + "uuid", "wat", ] diff --git a/crates/AGENTS.md b/crates/AGENTS.md index c14d0d59d46..b1547485d91 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -167,7 +167,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec - Hooks and prompt context: `ironclaw_hooks` for hook registration/dispatch/failure policy; `ironclaw_prompt_envelope` for model-visible untrusted or trust-labeled snippet wrapping. - Reborn runtime execution: lane crate (`scripts`, `mcp`, `wasm`) first; `ironclaw_capabilities` for the authorized dispatch path; `host_runtime` for secrets/network/resources/redaction; `processes` for background lifecycle; `ironclaw_wasm_limiter` only for shared limiter mechanics. - Reborn turns/agent loop: `ironclaw_turns` for turn coordination; `ironclaw_agent_loop` for strategy/planner/executor contracts; `ironclaw_loop_host` for host support ports. -- Product adapter flow: `ironclaw_product` contracts and `adapter_registry` manifest projection -> `ironclaw_product` orchestration -> concrete adapter crate. +- Product adapter flow: `ironclaw_extension_contracts::product_adapter_section` (the `[product_adapter.*]` schema) -> `ironclaw_extensions::host_api::product_adapter` (host-API manifest contract + resolved projection) -> `ironclaw_product` orchestration -> concrete adapter crate. - Reborn binary/composition: `ironclaw_reborn_config` for boot config; `ironclaw_reborn_composition` for production wiring; the `ironclaw_reborn_cli/` directory (package `ironclaw`) for commands; `ironclaw_runner` for standalone adapters/driver registry; `ironclaw_webui` for host-owned WebChat v2 listener lifecycle. - Model/provider behavior: `ironclaw_llm`; do not leak provider auth/cache/retry concerns into engine or product orchestration. - UI presentation: `ironclaw_webui` owns the Reborn WebChat route surface, Vite SPA, serving, and auth. It is the only UI surface — the v1 TUI and gateway crates are gone. diff --git a/crates/extensions/ironclaw_extension_support/Cargo.toml b/crates/extensions/ironclaw_extension_support/Cargo.toml index 87b186d01ca..eb2f16caea6 100644 --- a/crates/extensions/ironclaw_extension_support/Cargo.toml +++ b/crates/extensions/ironclaw_extension_support/Cargo.toml @@ -11,7 +11,21 @@ repository = "https://github.com/nearai/ironclaw" publish = false [package.metadata.ironclaw] -layer = "loops" +# `runtimes`, not `loops` (WS3, 2026-08-04). The executor/adapter seam this +# crate was re-chartered around in WS3 makes the *kernel* a designed consumer: +# a tool arrives here as an executor and leaves its `FirstPartyCapabilityHandler` +# in `ironclaw_host_runtime`, so `host_runtime -> extension_support` is +# structural, not transitional. A crate the kernel is designed to call cannot +# sit two rungs above it. `runtimes` is the least demotion that legalizes that +# consumer, it matches this crate's own §8.2 posture (mediated services arrive +# by injection; kernel ✗ — the same cell the wasm/mcp/sandbox lanes carry), and +# it costs zero same-layer edges, where `substrates` would hide six of this +# crate's seven dependencies from the layer matrix. Ceiling checked both ways: +# every normal dependency and every domain this crate's charter names +# (memory, traces, triggers) is `substrates` or `contracts`; every consumer is +# `kernel` or above. Consumer set frozen by the `DowngradePin` in +# `reborn_same_layer_edge_inventory.rs`. +layer = "runtimes" [dependencies] async-trait = "0.1" diff --git a/crates/ironclaw_approvals/Cargo.toml b/crates/ironclaw_approvals/Cargo.toml index 831c00c3ebb..d4b86da19f9 100644 --- a/crates/ironclaw_approvals/Cargo.toml +++ b/crates/ironclaw_approvals/Cargo.toml @@ -21,6 +21,14 @@ ironclaw_authorization = { path = "../ironclaw_authorization" } ironclaw_events = { path = "../ironclaw_events" } ironclaw_filesystem = { path = "../ironclaw_filesystem" } ironclaw_host_api = { path = "../ironclaw_host_api" } +# Both arrived with the profile approval gate evicted from the composition root +# (WS6). The gate implements `ironclaw_authorization`'s +# `TrustAwareCapabilityDispatchAuthorizer`, whose signature names +# `ironclaw_trust::TrustDecision`, and it consumes the `MinimalApprovalBypass` +# classification `ironclaw_runtime_policy` owns (§4.4: the one place that +# classification lives). Both are inventoried same-layer kernel edges. +ironclaw_runtime_policy = { path = "../ironclaw_runtime_policy" } +ironclaw_trust = { path = "../ironclaw_trust" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/ironclaw_approvals/src/lib.rs b/crates/ironclaw_approvals/src/lib.rs index 7e51f06bf02..686877db758 100644 --- a/crates/ironclaw_approvals/src/lib.rs +++ b/crates/ironclaw_approvals/src/lib.rs @@ -9,6 +9,8 @@ mod auto_approve; mod capability_permission; mod cas_record; mod policy; +mod profile_gate; +mod profile_gate_policy; #[cfg(any(test, feature = "test-support"))] pub mod test_support; @@ -53,6 +55,15 @@ pub use policy::{ PersistentApprovalPolicyStorePort, PersistentApprovalScope, permission_mode_allows_persistent_approval, persistent_approval_grant_issuer, }; +#[cfg(any(test, feature = "test-support"))] +pub use profile_gate::EmptyApprovalSettingsProvider; +pub use profile_gate::{ + ApprovalSettingsProvider, OriginGateRequirement, ProfileApprovalGatePolicy, + profile_approval_authorizer, +}; +pub use profile_gate_policy::{ + RuntimeProfileApprovalGateEffectSets, RuntimeProfileApprovalGatePolicy, +}; pub type ToolPermissionOverride = CapabilityPermissionOverride; pub type ToolPermissionOverrideInput = CapabilityPermissionOverrideInput; diff --git a/crates/ironclaw_reborn_composition/src/profile_approval_authorization.rs b/crates/ironclaw_approvals/src/profile_gate.rs similarity index 98% rename from crates/ironclaw_reborn_composition/src/profile_approval_authorization.rs rename to crates/ironclaw_approvals/src/profile_gate.rs index 76b383bdd0c..ba19bba8b59 100644 --- a/crates/ironclaw_reborn_composition/src/profile_approval_authorization.rs +++ b/crates/ironclaw_approvals/src/profile_gate.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, sync::Arc}; +use crate::{ToolPermissionOverride, permission_mode_allows_persistent_approval}; use async_trait::async_trait; -use ironclaw_approvals::{ToolPermissionOverride, permission_mode_allows_persistent_approval}; use ironclaw_authorization::{GrantAuthorizer, TrustAwareCapabilityDispatchAuthorizer}; use ironclaw_host_api::{ Timestamp, @@ -36,7 +36,7 @@ use ironclaw_trust::TrustDecision; /// Class-B modulation (tool overrides, leases, auto-approve, always-allow) stays /// entirely between the two tiers, exactly as for the effect gates it mirrors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum OriginGateRequirement { +pub enum OriginGateRequirement { /// The origin may not invoke this capability at all — a hard deny /// (fail-closed), not suppressible by any class-B grant/lease or by the /// Minimal (yolo) approval bypass. @@ -60,7 +60,7 @@ pub(crate) enum OriginGateRequirement { None, } -pub(crate) trait ProfileApprovalGatePolicy: Send + Sync { +pub trait ProfileApprovalGatePolicy: Send + Sync { fn capability_exempt_from_approval(&self, _capability: &CapabilityId) -> bool { false } @@ -119,7 +119,7 @@ pub(crate) trait ProfileApprovalGatePolicy: Send + Sync { /// decision allows the candidate so settings apply without process restart /// while non-runnable candidates do not spend approval-store reads. #[async_trait] -pub(crate) trait ApprovalSettingsProvider: Send + Sync { +pub trait ApprovalSettingsProvider: Send + Sync { async fn tool_override( &self, scope: &ResourceScope, @@ -137,12 +137,17 @@ pub(crate) trait ApprovalSettingsProvider: Send + Sync { } /// No stored overrides and global auto-approve off: the gate behaves exactly as -/// it did before #4959. Test-only — production wires +/// it did before #4959. Test-only — production wires composition's /// `StoreApprovalSettingsProvider`. -#[cfg(test)] -pub(crate) struct EmptyApprovalSettingsProvider; - -#[cfg(test)] +/// +/// Gated on `test-support` rather than `cfg(test)` because composition's own +/// capability-host tests drive the gate through this double; the crate-local +/// `cfg(test)` form it carried inside composition is unreachable across a crate +/// boundary. +#[cfg(any(test, feature = "test-support"))] +pub struct EmptyApprovalSettingsProvider; + +#[cfg(any(test, feature = "test-support"))] #[async_trait] impl ApprovalSettingsProvider for EmptyApprovalSettingsProvider { async fn tool_override( @@ -167,7 +172,7 @@ impl ApprovalSettingsProvider for EmptyApprovalSettingsProvider { } } -pub(crate) fn profile_approval_authorizer( +pub fn profile_approval_authorizer( approval_policy: ApprovalPolicy, gate_policy: Arc, settings: Arc, @@ -509,7 +514,7 @@ fn approval_request( #[cfg(test)] mod tests { - use ironclaw_approvals::persistent_approval_grant_issuer; + use crate::persistent_approval_grant_issuer; use ironclaw_host_api::{ action::NetworkPolicy, capability::{ @@ -1424,7 +1429,7 @@ mod tests { use ironclaw_runtime_policy::MinimalApprovalBypass; use super::*; - use crate::runtime_profile_approval_policy::{ + use crate::profile_gate_policy::{ RuntimeProfileApprovalGateEffectSets, RuntimeProfileApprovalGatePolicy, }; diff --git a/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs b/crates/ironclaw_approvals/src/profile_gate_policy.rs similarity index 95% rename from crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs rename to crates/ironclaw_approvals/src/profile_gate_policy.rs index 0c75e42d341..504c2248591 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs +++ b/crates/ironclaw_approvals/src/profile_gate_policy.rs @@ -6,16 +6,16 @@ use ironclaw_host_api::{ }; use ironclaw_runtime_policy::MinimalApprovalBypass; -use crate::profile_approval_authorization::{OriginGateRequirement, ProfileApprovalGatePolicy}; +use crate::profile_gate::{OriginGateRequirement, ProfileApprovalGatePolicy}; #[derive(Debug, Clone)] -pub(crate) struct RuntimeProfileApprovalGateEffectSets { - pub(crate) ask_writes: Vec, - pub(crate) ask_destructive: Vec, +pub struct RuntimeProfileApprovalGateEffectSets { + pub ask_writes: Vec, + pub ask_destructive: Vec, } impl RuntimeProfileApprovalGateEffectSets { - pub(crate) fn new(ask_writes: Vec, ask_destructive: Vec) -> Self { + pub fn new(ask_writes: Vec, ask_destructive: Vec) -> Self { Self { ask_writes, ask_destructive, @@ -24,7 +24,7 @@ impl RuntimeProfileApprovalGateEffectSets { } #[derive(Debug, Clone)] -pub(crate) struct RuntimeProfileApprovalGatePolicy { +pub struct RuntimeProfileApprovalGatePolicy { /// Whether `ApprovalPolicy::Minimal` may bypass effect gates, as a /// resolved policy *value* — not a deployment profile this type then asks /// about itself (§4.4). `ironclaw_runtime_policy::minimal_approval_bypass` @@ -35,7 +35,7 @@ pub(crate) struct RuntimeProfileApprovalGatePolicy { } impl RuntimeProfileApprovalGatePolicy { - pub(crate) fn new( + pub fn new( minimal_bypass: MinimalApprovalBypass, effects: RuntimeProfileApprovalGateEffectSets, ) -> Self { @@ -46,10 +46,7 @@ impl RuntimeProfileApprovalGatePolicy { } } - pub(crate) fn with_exempt_capabilities( - mut self, - exempt_capabilities: Vec, - ) -> Self { + pub fn with_exempt_capabilities(mut self, exempt_capabilities: Vec) -> Self { self.exempt_capabilities = exempt_capabilities; self } diff --git a/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs b/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs index e6d1a1a55a9..30f4b1ce079 100644 --- a/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs +++ b/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs @@ -201,6 +201,82 @@ fn composition_public_pub_use_surface_matches_snapshot() { ); } +/// CHECKLIST WS6 asks that the re-export wall be reduced *to a documented +/// snapshot* — "every survivor names consumer + enforcing test". The snapshot +/// half is pinned above; this is the documentation half. +/// +/// Every top-level `pub use` in composition's `lib.rs` must carry a +/// `// consumer: … · pinned by: …` line in the comment block directly above it +/// (above any `#[cfg(…)]` attributes, which is also where the snapshot +/// extractor expects them, so annotating never perturbs the snapshot). +/// +/// The rule this enforces: a re-export earns its place only when the consumer +/// cannot reach the symbol at its owning crate. Without the annotation, a wall +/// entry is indistinguishable from an accident, which is how the previous 52 +/// entries accumulated 17 with no consumer at all. +#[test] +fn composition_public_pub_use_entries_name_their_consumer() { + let lib = std::fs::read_to_string(composition_src_path().join("lib.rs")) + .expect("composition lib.rs readable"); + let lines: Vec<&str> = lib.lines().collect(); + + let mut undocumented = Vec::new(); + let mut documented = 0usize; + let mut in_pub_use = false; + for (index, line) in lines.iter().enumerate() { + if in_pub_use { + if line.trim_start().contains(';') { + in_pub_use = false; + } + continue; + } + if !line.starts_with("pub use") { + continue; + } + if !line.trim_start().contains(';') { + in_pub_use = true; + } + + // Walk back over the contiguous attribute / comment block above the + // entry looking for the annotation. + let mut cursor = index; + let mut annotated = false; + while cursor > 0 { + let above = lines[cursor - 1].trim_start(); + let is_block = above.starts_with("#[") + || above.starts_with("//") + || above.starts_with("///") + || above.starts_with("]"); + if !is_block { + break; + } + if above.starts_with("// consumer:") && above.contains("pinned by:") { + annotated = true; + } + cursor -= 1; + } + if annotated { + documented += 1; + } else { + undocumented.push(format!("lib.rs:{}: {}", index + 1, line)); + } + } + + assert!( + documented > 0, + "the annotation scan found no documented entries at all — the scan is broken, \ + not the wall" + ); + assert!( + undocumented.is_empty(), + "every public `pub use` in the composition root must name its consumer and the \ + test that pins it, as a `// consumer: · pinned by: ` line above the \ + entry (above any `#[cfg]`). A re-export whose consumer can reach the symbol at \ + its owning crate should be deleted, not annotated. Undocumented entries:\n{}", + undocumented.join("\n") + ); +} + #[test] fn extension_host_cluster_stays_internal() { let lib = std::fs::read_to_string(composition_src_path().join("lib.rs")) diff --git a/crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs b/crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs index 74336478ac8..53e1ad6dfa7 100644 --- a/crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs +++ b/crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs @@ -1121,8 +1121,16 @@ fn reborn_host_runtime_services_do_not_expose_lower_substrate_handles() { let scripts_manifest = std::fs::read_to_string(crate_path(&root, "crates/ironclaw_sandbox/Cargo.toml")) .expect("sandbox lane Cargo.toml must be readable"); - let mcp = std::fs::read_to_string(crate_path(&root, "crates/ironclaw_mcp/src/lib.rs")) - .expect("MCP runtime lib.rs must be readable"); + // WS6 module charters: the MCP lane is no longer one file. Scanning the + // whole crate source tree keeps the rule non-vacuous across the §6.6.3 + // split (and the family `git mv` still to come) — reading `lib.rs` alone + // would now see only the re-export list and go silently green. + let mcp = concatenated_crate_sources(&crate_path(&root, "crates/ironclaw_mcp/src")); + assert!( + mcp.contains("pub struct McpRuntime"), + "MCP lane scan is vacuous: it found no MCP runtime source under \ + crates/ironclaw_mcp/src (did the lane move again?)" + ); let mcp_manifest = std::fs::read_to_string(crate_path(&root, "crates/ironclaw_mcp/Cargo.toml")) .expect("MCP runtime Cargo.toml must be readable"); @@ -1391,7 +1399,10 @@ fn provider_tool_names_stay_at_model_protocol_boundaries() { // output or synthetic provider tools. "crates/ironclaw_reborn_composition/src/llm_admin/openai_compat_serve.rs", "crates/ironclaw_loop_host/src/synthetic_capability.rs", - "crates/ironclaw_reborn_composition/src/observability/trace_capture.rs", + // Trace capture rebuilds a provider-shaped tool-call transcript from + // stored replay metadata for the Trace Commons envelope; it moved out + // of composition into the turn-runner observer seam (WS6, §6.10.1). + "crates/ironclaw_runner/src/trace_capture.rs", ] .into_iter() .map(|entry| resolve_crate_relative(&root, entry)) @@ -3137,8 +3148,16 @@ struct BoundaryRule { fn boundary_rules() -> Vec { vec![ BoundaryRule { + // `ironclaw_extensions` was on this crate's *guidance* forbidden + // list (`CLAUDE.md`) but never on the enforced one, and the crate + // held the dependency the whole time — the guidance-vs-code + // contradiction PROPOSAL §6.9.1 names. Resolved by CHECKLIST WS5's + // `product` narrows row: `adapter_registry` (product's only + // consumer of the registry) moved to its chartered owners, the + // manifest entry is gone, and the rule is enforced from here on. crate_name: "ironclaw_product", forbidden: vec![ + "ironclaw_extensions", "ironclaw_host_runtime", "ironclaw_mcp", "ironclaw_wasm", @@ -3217,9 +3236,10 @@ fn boundary_rules() -> Vec { "ironclaw_events", "ironclaw_extensions", // `ironclaw_filesystem` is permitted: the durable - // OpenAiCompatRefStore lives behind the - // `storage`/`libsql`/`postgres` features and persists opaque refs - // through the universal RootFilesystem port. + // OpenAiCompatRefStore persists opaque refs through the universal + // RootFilesystem port. It is unconditional — this crate declares no + // cargo features, and WS5 collapsed the LibSql/Postgres ref-store + // newtypes onto that one backend-neutral form. "ironclaw_gateway", "ironclaw_host_runtime", "ironclaw_llm", @@ -3407,6 +3427,49 @@ fn boundary_rules() -> Vec { // response/error DTO primitives used by product-auth HTTP routes; // secret storage and durable auth ownership stay behind // `ironclaw_auth`, not `ironclaw_secrets`. + // + // ✎ **Re-derived 2026-08-04 (WS5 `webui` row) against PROPOSAL + // §6.9.4.** The row asked for the derivation nobody had done. Result: + // **zero removals, nine additions**, all no-op ratchets (webui's ten + // normal workspace deps are `host_api`, `product_contracts`, + // `extension_contracts`, `extension_host`, `host_ingress`, `auth`, + // `attachments`, `common`, `product`, `reborn_openai_compat` — none of + // the nine appears in any dependency kind). + // + // What the derivation is *from*, since §6.9.4 carries no forbidden + // list of its own: §8.2's `product/` row ("**app ✗**", "product still + // ✗ host_runtime/dispatch/lanes"), §8.2's retained named rules + // ("concrete extension crates link only from the binary"), and + // `families/product.md`'s "never touches a lane crate / the extension + // registry or hosting crates". Additions, in that order: + // `ironclaw_reborn_composition` (§8.2 app ✗ — and the direction is + // backwards: this crate *receives* handles from composition; it is on + // 15 other rules and on every other products-layer rule but + // `ironclaw_product`'s), `ironclaw_wasm_limiter` (§8.2 ✗ lanes — the + // **only one of the nine no other gate covers**; zero rules named it + // workspace-wide, and the existing wasm_limiter gate checks its + // outbound deps, not its inbound edges), `ironclaw_slack_extension` + + // `ironclaw_telegram_extension` (concrete extension crates), + // `ironclaw_event_projections` + `ironclaw_event_streams` + + // `ironclaw_extension_support` + `ironclaw_first_party_extension_ports` + // + `ironclaw_storage` (parity with `ironclaw_reborn_openai_compat`, + // the sibling transport, whose list these five closed the gap to). + // + // Explicitly NOT added, because the charter sanctions them: + // `ironclaw_product` (§12.11 D-B, permanent edge — not a pending + // flip), `ironclaw_extension_host` (§6.9.4's 2026-08-02 pairing + // amendment; the pairing *service* core stays in the host by §6.8.2), + // `ironclaw_reborn_openai_compat`, `ironclaw_attachments`, + // `ironclaw_host_api`, `ironclaw_host_ingress`, + // `ironclaw_product_contracts`, `ironclaw_extension_contracts`, + // `ironclaw_auth`, `ironclaw_common`. Memory providers are covered by + // `only_the_sanctioned_residue_names_a_memory_provider`, deliberately. + // + // **Keep this rule normal-deps-only.** Four entries on the list below + // (`ironclaw_secrets`, `ironclaw_loop_host`, `ironclaw_threads`, + // `ironclaw_turns`) are live *dev*-dependencies of `ironclaw_webui`; + // tightening this rule to all dependency kinds would go red on four + // counts on the day it landed. crate_name: "ironclaw_webui", forbidden: vec![ "ironclaw_legacy", @@ -3414,9 +3477,13 @@ fn boundary_rules() -> Vec { "ironclaw_capabilities", "ironclaw_conversations", "ironclaw_engine", + "ironclaw_event_projections", + "ironclaw_event_streams", "ironclaw_events", + "ironclaw_extension_support", "ironclaw_extensions", "ironclaw_filesystem", + "ironclaw_first_party_extension_ports", "ironclaw_gateway", "ironclaw_host_runtime", "ironclaw_llm", @@ -3428,6 +3495,7 @@ fn boundary_rules() -> Vec { "ironclaw_processes", "ironclaw_runner", "ironclaw", + "ironclaw_reborn_composition", "ironclaw_reborn_config", "ironclaw_reborn_event_store", "ironclaw_resources", @@ -3437,11 +3505,15 @@ fn boundary_rules() -> Vec { "ironclaw_sandbox", "ironclaw_secrets", "ironclaw_skills", + "ironclaw_slack_extension", + "ironclaw_storage", + "ironclaw_telegram_extension", "ironclaw_threads", "ironclaw_trust", "ironclaw_tui", "ironclaw_turns", "ironclaw_wasm", + "ironclaw_wasm_limiter", ], }, BoundaryRule { @@ -4408,15 +4480,75 @@ struct LayerMatrixException { /// port inversion (#7159) the `conversations → turns` row — and this batch /// merge is where the union lands: recomputed as `len()` of the merged list /// per the CHECKLIST §11.2.2 union rule. One entry survives. -const WS0_LAYER_MATRIX_EXCEPTION_BASELINE: usize = 1; +/// +/// **1 → 0 (WS3 closeout, 2026-08-04 — `ironclaw_extension_support` re-layered +/// `loops` → `runtimes`). The list is empty: PROPOSAL §11.2.2's end state and +/// CHECKLIST WS12's gate condition, reached.** The last entry was +/// `host_runtime → extension_support`, and it did not fall to the shed its +/// `removes_in` named. Two measurements, both on this tree, decided that: +/// +/// 1. **The blocker the CHECKLIST row recorded was wrong.** The row said the +/// edge "clears only when the last executor family lands". It is held by +/// exactly **two** `use` sites — `first_party_tools/mod.rs` +/// (`extension_support::coding`) and `first_party_tools/skill_management.rs` +/// (`extension_support::skills`) — and both belong to the families whose +/// executors have **already** moved — `coding` before this row was written, +/// `skills` with the row's family 1. The five families still awaiting a move +/// keep their executors in `host_runtime` and hold no edge at all, so moving +/// them could never have cleared this. (`latency.rs`'s third grep hit is a +/// doc comment.) +/// 2. **The seam makes the edge structural, not transitional.** WS3's own +/// executor/adapter seam — PROPOSAL §8.2's 2026-08-03 note and +/// `families/extensions.md` — says a tool arrives in `extension_support` as +/// an *executor* and leaves its `FirstPartyCapabilityHandler`, +/// `CapabilityManifest` and registry wiring on the host side, because that +/// crate's `BoundaryRule` forbids naming `ironclaw_host_runtime`. That makes +/// the kernel a **designed** consumer. Shedding the two adapters upward +/// anyway costs ~8 kernel private→`pub` widenings (`mod post_edit_check` is +/// private in `lib.rs`; `first_party_capability_manifest`, +/// `resource_profile`, `first_party_origin_gate_matrix` are module-private; +/// `bounded_input_size`, `bounded_output_bytes`, +/// `FIRST_PARTY_MAX_OUTPUT_BYTES` are `pub(super)`) and relocates the +/// registration of **builtin** capabilities that 145 references across 31 +/// files reach through `builtin_first_party_*` — a semantic change, +/// not a move. It is the same refutation §6.5.9's binder half already +/// carries: paying a kernel API widening to relocate an adapter whose +/// encapsulation already holds. +/// +/// A crate the kernel is designed to call cannot be declared two rungs above +/// it, so the layer is what was wrong. `runtimes` is the **least** demotion +/// that legalizes a kernel consumer, and it is where this crate's own §8.2 row +/// already places it in posture — "mediated services arrive by injection", +/// kernel ✗, invoked only through capability dispatch, which is the wasm / mcp +/// / sandbox cell verbatim. Checked both directions before flipping it: all +/// seven normal dependencies (`auth`, `extractors`, `filesystem`, +/// `observability`, `safety`, `skills` — `substrates`; `host_api` — +/// `contracts`) and every domain the crate's charter reserves (`memory`, +/// `traces`, `triggers` — all `substrates`) fit the narrower row, and all five +/// consumers (`host_runtime` kernel; `extension_host`, `extension_manager` +/// products; `reborn_composition`, `reborn_cli` app) are `kernel` or above, so +/// the move forbids no existing edge. **Zero same-layer edges are created** — +/// no `runtimes` crate is a dependency or a consumer — where `substrates` +/// would have hidden six of this crate's seven dependencies from the matrix. +/// The widening it *does* buy is frozen by the `DowngradePin` in +/// `reborn_same_layer_edge_inventory.rs`, which is the pin #7149 exists for. +/// +/// Mechanism, not novelty: this is the fourth time the register has moved by a +/// re-layer and the third `loops → *` demotion in this family — WS2's +/// `ironclaw_extensions` (4 entries), WS3's `processes` (1), WS4's `skills` +/// (0). PLAN's Wave 2 note states the rule ("a re-layer *downward* is the +/// cheap kind … expect the exception register to move"). What is **not** +/// closed by it: CHECKLIST WS3's `first_party_tools` row, which is executor +/// consolidation and stays open at five of six families. Only the exception is +/// discharged. +/// +/// **The ratchet is now an equality in effect.** With the list empty, any new +/// entry trips `reborn_layer_matrix_exceptions_ratchet_down_only` immediately; +/// re-arming it requires an owner-approved baseline raise in the same PR, per +/// that test's own failure message. +const WS0_LAYER_MATRIX_EXCEPTION_BASELINE: usize = 0; -const LAYER_MATRIX_EXCEPTIONS: &[LayerMatrixException] = &[LayerMatrixException { - crate_name: "ironclaw_host_runtime", - dependency_name: "ironclaw_extension_support", - introduced: "2026-07-09", - removes_in: "WS3 (first-party activation wiring; ex-July-train label W7)", - reason: "host_runtime still owns first-party extension activation wiring until kernel consolidation separates host policy from loop/product concerns", -}]; +const LAYER_MATRIX_EXCEPTIONS: &[LayerMatrixException] = &[]; /// The tracking metadata every exception must carry to be removable: the edge /// it names, when it was taken on, the milestone that deletes it, and why it @@ -4457,8 +4589,21 @@ fn exception_tracking_defect(exception: &LayerMatrixException) -> Option<&'stati /// the two together mean the list can only move toward empty. #[test] fn reborn_layer_matrix_exceptions_ratchet_down_only() { - assert!( - LAYER_MATRIX_EXCEPTIONS.len() <= WS0_LAYER_MATRIX_EXCEPTION_BASELINE, + // Expressed as "how far above the baseline are we", not as + // `len() <= BASELINE`. The two are the same assertion for every baseline, + // but the comparison form becomes `usize <= 0` once the baseline reaches + // its target of zero, which is a tautology to `-D warnings` + // (`clippy::absurd_extreme_comparisons`) and would have had to be silenced + // with an `allow` on the one gate whose whole job is to be loud. The + // ceiling semantics are unchanged and deliberately kept: a list *below* the + // baseline is fine (the baseline is then lowered in the same PR, per the + // constant's own doc); only growth past it is red. + let above_baseline = LAYER_MATRIX_EXCEPTIONS + .len() + .saturating_sub(WS0_LAYER_MATRIX_EXCEPTION_BASELINE); + assert_eq!( + above_baseline, + 0, "layer-matrix exceptions grew to {} (WS0 baseline {}): the restructure's exception \ list is shrink-only on its way to empty (PROPOSAL §11.2.2). Remove the edge instead \ of allowlisting it — or, if the owner has approved a genuinely new exception, raise \ diff --git a/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs b/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs index ecf289313c1..f8d059cd636 100644 --- a/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs +++ b/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs @@ -71,33 +71,28 @@ const EXTENSION_MANAGER: &str = "ironclaw_extension_manager"; /// actually blocks the survivors is their *request/response* vocabulary, which /// is what each reason now states. `ProductConversationSubjectRouteResolver` /// had no other blocker and was inverted. -const PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS: &[(&str, &str)] = &[ - ( - "AuthChallengeProvider", - "signature returns Result<_, ironclaw_auth::AuthProductError> and carries \ - ironclaw_auth::{AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl}; \ - moving it needs the auth vocabulary narrowed out of the port first", - ), - ( - "ChannelConnectionService", - "returns ChannelAuthAccountState, whose fields are \ - ironclaw_auth::{AuthFlowStatus, CredentialAccountStatus}", - ), - ( - "ConversationBindingService", - "takes ironclaw_product::ResolveBindingRequest and returns \ - ironclaw_product::ResolvedBinding; both are declared in product beside \ - the route-kind grammar that derives them. The error no longer blocks it \ - (WS2.2) — the DTOs do, and they move with the channel_host row", - ), - ( - "ProductActorUserResolver", - "resolves to ResolvedProductActorUser, which carries \ - ironclaw_conversations::ExternalActorBindingEpoch. The error no longer \ - blocks it (WS2.2); the conversations dep is the whole blocker and needs \ - that epoch narrowed out of the response first", - ), -]; +/// +/// **WS2.5 then cleared every vocabulary-blocked row, 4 -> 1, and the reasons +/// above were the map that made it mechanical.** Two of the three did not need +/// their vocabulary narrowed at all: `AuthChallengeProvider` and +/// `ChannelConnectionService` were declared where their vocabulary already +/// lives (`ironclaw_auth`), which is what `.claude/rules/type-placement.md` +/// §2/§3 and `families/contracts.md:46` ask for and costs zero type +/// weakening — the residue clears when a trait stops being *product*-declared, +/// whichever legal home it lands in. The third, +/// `ProductActorUserResolver`, did move to `ironclaw_product_contracts`, +/// because the one type that blocked it (`ExternalActorBindingEpoch`) belonged +/// beside `ExternalActorRef` in `ironclaw_extension_contracts::external` all +/// along. What survives is not vocabulary: `ConversationBindingService`'s DTOs +/// move with the §12.11 D-A factory port, which is unstarted. +const PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS: &[(&str, &str)] = &[( + "ConversationBindingService", + "takes ironclaw_product::ResolveBindingRequest and returns \ + ironclaw_product::ResolvedBinding; both are declared in product beside \ + the route-kind grammar that derives them. The error no longer blocks it \ + (WS2.2) — the DTOs do, and they move with the channel_host row (the \ + §12.11 D-A factory-port scope, unstarted)", +)]; /// The ports this row inverted: defined in `ironclaw_product_contracts` and /// implemented **below** product, paired with the crate that implements each. @@ -127,6 +122,11 @@ const INVERTED_PORT_IMPLEMENTORS: &[(&str, &str)] = &[ ("DeliveryReplyContextSource", EXTENSION_HOST), // WS2.4: the lifecycle product service is the manager's headline surface. ("LifecycleProductService", EXTENSION_MANAGER), + // WS2.5: inverted once `ExternalActorBindingEpoch` moved to + // `ironclaw_extension_contracts::external`, which made + // `ResolvedProductActorUser` contracts-legal. Its request and response + // types moved with it and the error became `ProductOperationFailure`. + ("ProductActorUserResolver", EXTENSION_HOST), // WS2.2: inverted once `ProductOperationFailure` gave it a contracts-legal // error. Its request type and route key moved with it. ("ProductConversationSubjectRouteResolver", EXTENSION_HOST), @@ -136,8 +136,12 @@ const INVERTED_PORT_IMPLEMENTORS: &[(&str, &str)] = &[ /// Ceiling on the residue. Only ever moves down. (WS2.1 froze it at 6; WS2.2 /// inverted `ProductConversationSubjectRouteResolver`; WS2.4 moved -/// `ExtensionCredentialSetupService`'s implementation out of the crate.) -const WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE: usize = 4; +/// `ExtensionCredentialSetupService`'s implementation out of the crate; WS2.5 +/// took the three vocabulary-blocked ports 4 -> 1 — `AuthChallengeProvider` and +/// `ChannelConnectionService` to `ironclaw_auth` beside the vocabulary that +/// blocked them, `ProductActorUserResolver` to `ironclaw_product_contracts` +/// once `ExternalActorBindingEpoch` moved to `ironclaw_extension_contracts`.) +const WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE: usize = 1; /// The manager twin of the host list above. WS2.4 moved /// `ExtensionCredentialSetupService`'s implementation out of the host, which @@ -150,8 +154,10 @@ const PRODUCT_DEFINED_TRAITS_EXTENSION_MANAGER_STILL_IMPLEMENTS: &[(&str, &str)] "ExtensionCredentialSetupService", "implemented in webui_extension_credentials.rs; the port stays declared in \ ironclaw_product because its vocabulary is ironclaw_auth credential-account \ - projections — it moves to ironclaw_product_contracts when that vocabulary \ - is narrowed out (the same blocker as the host's AuthChallengeProvider row)", + projections. WS2.5 showed the cheaper answer for that class: declare the \ + port in ironclaw_auth beside the vocabulary, as AuthChallengeProvider and \ + ChannelConnectionService now are, rather than narrowing the vocabulary out \ + to reach ironclaw_product_contracts", )]; /// **The full `ironclaw_product` reference ledger** — every production file in @@ -160,11 +166,14 @@ const PRODUCT_DEFINED_TRAITS_EXTENSION_MANAGER_STILL_IMPLEMENTS: &[(&str, &str)] /// /// Why this exists when the trait residue above already does: the trait list is /// **trait-shaped** — it sees `impl for …` headers and nothing -/// else. A dependency can also be a constant (`adapter_registry:: -/// PRODUCT_ADAPTER_HOST_API_ID`), a free function (`auth_prompt_view_for_ -/// blocked_auth`), or an inline construction of a concrete product type -/// (`ProductConversationBindingService::new`), and none of those register -/// there. The manifest biconditional below catches the *sum* loudly, but as a +/// else. A dependency can also be a free function or an inline construction +/// of a concrete product type (`ProductConversationBindingService::new`), and +/// none of those register there — the two examples this paragraph used to +/// give are both gone: `adapter_registry::PRODUCT_ADAPTER_HOST_API_ID` +/// retired with the WS5 `adapter_registry` move, and +/// `auth_prompt_view_for_blocked_auth` moved to +/// `ironclaw_auth::product_prompt` with the challenge family (WS2.5). The +/// manifest biconditional below catches the *sum* loudly, but as a /// boolean: it cannot say what remains. The `products → loops` re-layer was /// sized five times from proxies of this set and was wrong five times /// (PROPOSAL §12.11 D-A and its 2026-08-03 amendment; #7092; #7143; #7145) — @@ -180,27 +189,12 @@ const PRODUCT_DEFINED_TRAITS_EXTENSION_MANAGER_STILL_IMPLEMENTS: &[(&str, &str)] /// CHECKLIST WS5's `product` narrows row), `product-fn` (a free function that /// moves with its vocabulary), or `assembly` (the D-A factory-port scope). const EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT: &[(&str, &str)] = &[ - ( - "available_extensions.rs", - "adapter-registry: product_adapter_sections manifest projection \ - (owned by CHECKLIST WS5's product-narrows row; the strategy-alias \ - half of this row fell to #7143's import repoint)", - ), - ( - "channel_connection.rs", - "port: implements ChannelConnectionService and returns \ - ChannelAuthAccountState — the ironclaw_auth vocabulary residue row", - ), ( "channel_host.rs", - "port: implements ConversationBindingService and ProductActorUserResolver \ - (their DTOs are product-declared) + assembly: inline-constructs product's \ - concrete stack — the §12.11 D-A factory-port scope", - ), - ( - "channel_lifecycle.rs", - "adapter-registry: PRODUCT_ADAPTER_HOST_API_ID section filter (the \ - strategy-alias half fell to #7143's import repoint)", + "port: implements ConversationBindingService (its DTOs are \ + product-declared) + assembly: inline-constructs product's concrete \ + stack — the §12.11 D-A factory-port scope. The ProductActorUserResolver \ + half of this row fell to WS2.5's inversion", ), ( "channel_triggered_delivery.rs", @@ -208,36 +202,25 @@ const EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT: &[(&str, &str)] = &[ triggered_run_delivery_settings — covered by the §12.11 D-A ruling \ alongside channel_host.rs", ), - ( - "host_api_contracts.rs", - "adapter-registry: registers product's \ - register_product_adapter_host_api_contract into the manifest contract \ - registry (owned by CHECKLIST WS5's product-narrows row)", - ), - ( - "product_lifecycle.rs", - "port vocabulary (ChannelConnectionService) + \ - ExtensionAccountSetupRegistry (the strategy alias fell to #7143)", - ), - ( - "provider_identity.rs", - "port: implements ProductActorUserResolver, whose response carries \ - ironclaw_conversations::ExternalActorBindingEpoch — the conversations \ - vocabulary residue row", - ), - ( - "run_delivery_ports.rs", - "port: implements AuthChallengeProvider (ironclaw_auth vocabulary residue) \ - + product-fn: calls auth_prompt_view_for_blocked_auth and \ - projection::approval_prompt_context_view, free functions that move with \ - the auth-prompt vocabulary", - ), ]; /// Ceiling on the reference ledger. Only ever moves down — growing the frozen /// list past it needs this constant raised in the same PR, which is the /// deliberate two-edit speed bump against re-widening the edge. -const EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE: usize = 9; +/// +/// **WS2.5 took it 9 -> 5.** Four rows fell together, all by the same move: the +/// port-facing vocabulary went to the crate that owns it, and product maps at +/// its boundary. `channel_connection.rs` and `product_lifecycle.rs` speak +/// `ironclaw_auth::{ChannelConnectionService, ChannelAuthAccountState}` and the +/// `ExtensionAccountSetupReader` port; `provider_identity.rs` speaks +/// `ironclaw_product_contracts::actor_identity`; `run_delivery_ports.rs` speaks +/// `ironclaw_auth::product_prompt` for the challenge family and +/// `ironclaw_product_contracts::approval_prompt` for the approval projection. +/// +/// The five survivors are exactly two classes and neither is vocabulary: three +/// `adapter-registry` rows (CHECKLIST WS5's `product` narrows row) and two +/// `assembly` rows (§12.11 D-A's factory port, unstarted). +const EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE: usize = 2; /// Workspace package metadata, resolved once per test binary. /// @@ -635,17 +618,10 @@ fn inverted_ports_are_declared_in_contracts_and_implemented_below_product() { /// `ironclaw_product_contracts::error::ProductOperationFailure`. A third file /// appearing here means the boundary error was bypassed; a stale entry means a /// port was inverted without deleting its row. -const EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR: &[(&str, &str)] = &[ - ( - "channel_host.rs", - "implements ConversationBindingService and ProductActorUserResolver, both \ - still declared in ironclaw_product", - ), - ( - "provider_identity.rs", - "implements ProductActorUserResolver, still declared in ironclaw_product", - ), -]; +const EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR: &[(&str, &str)] = &[( + "channel_host.rs", + "implements ConversationBindingService, still declared in ironclaw_product", +)]; /// Production files in `crate_name` whose *code* names `type_name`, as paths /// relative to the crate's `src/`. diff --git a/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs b/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs index f23ba46aee3..459f2c1d540 100644 --- a/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs +++ b/crates/ironclaw_architecture/tests/reborn_extension_specificity.rs @@ -1161,10 +1161,23 @@ const ALLOWLIST: &[(&str, &str)] = &[ "crates/ironclaw_webui/frontend/src/pages/chat/components/auth-oauth-card.tsx", "github", ), - ("crates/ironclaw_product/src/adapter_registry.rs", "github"), - ("crates/ironclaw_product/src/adapter_registry.rs", "slack"), + // The inline-secret guard's vendor token prefixes. Repointed (not added) + // when CHECKLIST WS5's `product` narrows row moved `adapter_registry` to + // `ironclaw_extensions::host_api::product_adapter`; the guard stayed with + // the raw-TOML parse stage, so the entries moved file and nothing else. + // The schema half that went to `ironclaw_extension_contracts` carries no + // vendor name — its fixtures were rewritten generically rather than carved, + // the same disposition PROPOSAL §6.1.3 records for `ProductConversationRouteKey`. + ( + "crates/ironclaw_extensions/src/host_api/product_adapter.rs", + "github", + ), + ( + "crates/ironclaw_extensions/src/host_api/product_adapter.rs", + "slack", + ), ( - "crates/ironclaw_product/src/adapter_registry.rs", + "crates/ironclaw_extensions/src/host_api/product_adapter.rs", "telegram", ), ( @@ -1201,7 +1214,6 @@ const ALLOWLIST: &[(&str, &str)] = &[ // forbidden outright, so the example was rewritten generically rather than // re-carved. The entry is deleted, not repointed — the allowlist shrinks. ("crates/ironclaw_product/src/lib.rs", "telegram"), - ("crates/ironclaw_product/src/reborn_services.rs", "slack"), // WS5 port inversion: these three wire-DTO sites moved to the contracts // crate with their code (`NearAiAuthProvider`'s OAuth identity providers and // the project-metadata doc example). Same terms, same debt, new file. @@ -1407,11 +1419,11 @@ const ALLOWLIST: &[(&str, &str)] = &[ "slack", ), ( - "crates/ironclaw_reborn_composition/src/blocked_auth_resume.rs", + "crates/ironclaw_product/src/blocked_auth_resume.rs", "google", ), ( - "crates/ironclaw_reborn_composition/src/blocked_auth_resume.rs", + "crates/ironclaw_product/src/blocked_auth_resume.rs", "slack", ), ( @@ -1654,7 +1666,7 @@ const ALLOWLIST: &[(&str, &str)] = &[ /// above, by the ratchet's own failure message with the constant set to 0: /// the batch union is **123** — #7161's conversions repoint entries in place /// and add none. -const WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE: usize = 123; +const WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE: usize = 122; /// §11.2.8 vendor-scope shrink, armed at the WS0 baseline. /// diff --git a/crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs b/crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs index 654f2253dde..e05044385b7 100644 --- a/crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs +++ b/crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs @@ -32,10 +32,18 @@ use serde_json::Value; /// production build graphs. const DRIVER_LINKED_CRATES: &[&str] = &[ // Substrates that execute SQL directly. + // + // Two of these are the §11.2.6 "ADR-or-converge" exceptions, decided + // 2026-08-04 as KEEP with a written ADR each — `ironclaw_hooks` and + // `ironclaw_triggers`, tagged below. The ADRs state why convergence onto + // the `RootFilesystem` fabric is not available and what would reopen the + // decision; read the one that argues for an entry before removing it. "ironclaw_auth", "ironclaw_filesystem", + // ADR 0004 (`docs/adr/0004-hooks-keeps-its-predicate-state-backends.md`). "ironclaw_hooks", "ironclaw_host_runtime", + // ADR 0003 (`docs/adr/0003-triggers-keeps-hand-written-sql.md`). "ironclaw_triggers", // Owns the TLS/driver cone for durable event/audit logs (§6.3.2). "ironclaw_reborn_event_store", diff --git a/crates/ironclaw_architecture/tests/reborn_restructure_baselines.rs b/crates/ironclaw_architecture/tests/reborn_restructure_baselines.rs index 2e8881eebe5..f059d96cb8e 100644 --- a/crates/ironclaw_architecture/tests/reborn_restructure_baselines.rs +++ b/crates/ironclaw_architecture/tests/reborn_restructure_baselines.rs @@ -83,7 +83,25 @@ const WS0_COMPOSITION_SHARE_BP: usize = 658; /// and the nudge-window assertion below correctly refused a ceiling that /// moved without its record (371 > 200). Measured on the merged tree with /// `bash scripts/ci/check-composition-budget.sh --print`. -const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 45127; +/// +/// ✎ Re-recorded 45_127 → 42_938 on 2026-08-04 by the WS6 service-cluster +/// eviction: the admin-user directory and blocked-auth resume fan-out moved to +/// `ironclaw_product`, and turn-end trace capture split into +/// `ironclaw_reborn_traces::capture` + `ironclaw_runner::trace_capture`. +/// **−2,189 LOC**, and the share metric's blindness shows again in the same +/// run: 654 bp → 622 bp is a 32 bp move for a 4.9% absolute cut, because the +/// denominator barely noticed. The manifest's `loc_ceiling`/`loc_observed` are +/// lowered to match in the same commit, which is the obligation the nudge +/// assertion below exists to make visible. +/// ✎ Re-recorded 45_127 → 42_688 on 2026-08-04 by the WS6 policy evictions +/// (the profile approval gate to `ironclaw_approvals`, fire-time trigger +/// access to `ironclaw_triggers`): −2,439 production LOC, banked as the new +/// floor in the same PR that removed them, with `[gate].loc_ceiling` lowered +/// to match. Measured with `bash scripts/ci/check-composition-budget.sh +/// --print`, not derived by subtracting the diff. +/// ✎ Union re-record 2026-08-04: the two WS6 evictions above are disjoint and +/// their deltas add exactly on the merged batch — 45_127 − 2_189 − 2_439 = 40_499. +const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 40_499; /// Composition dispatch, from the same `--print` run: "composition dispatch: /// 827 Arc (governed prod, excl slack/extension_host)". diff --git a/crates/ironclaw_architecture/tests/reborn_same_layer_edge_inventory.rs b/crates/ironclaw_architecture/tests/reborn_same_layer_edge_inventory.rs index 3226df1a075..045415afaf7 100644 --- a/crates/ironclaw_architecture/tests/reborn_same_layer_edge_inventory.rs +++ b/crates/ironclaw_architecture/tests/reborn_same_layer_edge_inventory.rs @@ -61,6 +61,18 @@ //! promotions (`hooks` `substrates` → `loops`, `runner` `kernel` → `loops`) //! which need no pin because moving up narrows reach. //! +//! ✎ **That census is a snapshot of when this gate was authored and is no +//! longer the live count — read [`DOWNGRADE_PINS`], not this paragraph.** Three +//! more demotions have landed since: `ironclaw_host_ingress` `products` → +//! `substrates` (#7143, the move that motivated the rule), `ironclaw_skills` +//! `loops` → `substrates` (#7141 / WS4), and `ironclaw_extension_support` +//! `loops` → `runtimes` (WS3 closeout). The last of those is worth naming here +//! because it is the first demotion taken *for* the exception register rather +//! than alongside it: it deleted `LAYER_MATRIX_EXCEPTIONS`' final entry, so the +//! consumer-side pin is now the only structural check standing over that edge. +//! Kept as four rows rather than folded into a count, since a pin's whole value +//! is naming who may reach the demoted crate. +//! //! ⚠ **Every test function here must keep its `reborn_` prefix.** The file name //! is not what selects it: `code_style.yml` runs //! `cargo test -p ironclaw_architecture reborn`, and that argument is a **test @@ -178,6 +190,20 @@ const SAME_LAYER_EDGE_INVENTORY: &[SameLayerEdge] = &[ owner: "kernel/", decided_in: "WS3", }, + SameLayerEdge { + crate_name: "ironclaw_approvals", + dependency_name: "ironclaw_runtime_policy", + layer: "kernel", + owner: "kernel/", + decided_in: "WS6 (the profile approval gate evicted from the composition root consumes `MinimalApprovalBypass`, the classification `runtime_policy` owns per §4.4)", + }, + SameLayerEdge { + crate_name: "ironclaw_approvals", + dependency_name: "ironclaw_trust", + layer: "kernel", + owner: "kernel/", + decided_in: "WS6 (same eviction: the gate implements `authorization`'s `TrustAwareCapabilityDispatchAuthorizer`, whose signature names `ironclaw_trust::TrustDecision`)", + }, SameLayerEdge { crate_name: "ironclaw_capabilities", dependency_name: "ironclaw_processes", @@ -656,10 +682,26 @@ const SAME_LAYER_EDGE_INVENTORY: &[SameLayerEdge] = &[ /// so the improvement is banked as a floor rather than left as headroom. /// Recounted on the merged tree, not derived by subtracting three. /// +/// ✎ **72 → 74 (WS6, the composition policy eviction).** Two edges are *added* +/// — the only growth this number has taken — and both are the same kernel crate +/// paying for a module that left the app layer: `approvals → trust` and +/// `approvals → runtime_policy`, both arriving with the profile approval gate +/// (`profile_gate.rs` / `profile_gate_policy.rs`) evicted from +/// `ironclaw_reborn_composition`. Neither is avoidable at the destination: the +/// gate *implements* `ironclaw_authorization`'s +/// `TrustAwareCapabilityDispatchAuthorizer`, whose method signature names +/// `ironclaw_trust::TrustDecision`, and it consumes `MinimalApprovalBypass`, +/// which §4.4 pins to `ironclaw_runtime_policy` as "the one place that +/// classification lives". The trade is deliberate and stated rather than +/// hidden: 2,178 lines of authorization semantics stop living in the assembly +/// root, at the cost of two edges inside a kernel family that already carries +/// twelve (`capabilities` and `host_runtime` hold six each). Growth here is a +/// reviewed decision, not drift — see PROPOSAL §6.5.2/§6.10.1. +/// /// The target is fewer, and every wave that deletes one must lower this number /// in the same PR — the equality below refuses both growth *and* slack, so a /// forgotten decrement is red rather than banked as headroom. -const SAME_LAYER_EDGE_BASELINE: usize = 72; +const SAME_LAYER_EDGE_BASELINE: usize = 74; /// Sanity floors for the metadata walk. A gate that scans nothing must never /// read as success; these are deliberately far below the live values (67 @@ -784,17 +826,50 @@ const DOWNGRADE_PINS: &[DowngradePin] = &[ "ironclaw_reborn_composition", ], }, + DowngradePin { + crate_name: "ironclaw_extension_support", + from_layer: "loops", + to_layer: "runtimes", + demoted_in: "WS3 closeout (the move that emptied LAYER_MATRIX_EXCEPTIONS)", + // The demotion that deleted the register's last entry, + // `host_runtime -> extension_support`. WS3's executor/adapter seam makes + // the kernel a *designed* consumer of this crate — a tool moves here as + // an executor and leaves its handler, manifest and registry wiring in + // `ironclaw_host_runtime` — so a `loops` declaration contradicted the + // design rather than describing it. `runtimes` is the least demotion + // that legalizes a kernel consumer and creates no same-layer edge + // (`substrates` would have hidden six of the crate's seven + // dependencies from the matrix). Frozen at the five consumers that + // existed at the move; a sixth is a reviewed decision, and that review + // is the whole point of the pin, because the widening here reaches down + // two rungs rather than one. + // (`ironclaw` is the binary crate at `crates/ironclaw_reborn_cli/` — + // the pin is keyed on package names, which is what `cargo metadata` + // reports and what makes a row able to fire at all.) + permitted_consumers: &[ + "ironclaw", + "ironclaw_extension_host", + "ironclaw_extension_manager", + "ironclaw_host_runtime", + "ironclaw_reborn_composition", + ], + }, DowngradePin { crate_name: "ironclaw_extensions", from_layer: "loops", to_layer: "substrates", demoted_in: "#7094 (WS2 — Extensions family)", + // `ironclaw_product` dropped off with CHECKLIST WS5's `product` narrows + // row: `adapter_registry` was its only consumer of the registry, and it + // moved to `ironclaw_extensions::host_api::product_adapter` (projection) + // and `ironclaw_extension_contracts::product_adapter_section` (schema). + // The edge is now forbidden outright by the crate's `BoundaryRule` in + // `reborn_dependency_boundaries.rs`, so it cannot come back here. permitted_consumers: &[ "ironclaw_capabilities", "ironclaw_extension_host", "ironclaw_extension_manager", "ironclaw_host_runtime", - "ironclaw_product", "ironclaw_reborn_composition", ], }, diff --git a/crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs index c666f335471..60b3bd499da 100644 --- a/crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs @@ -432,7 +432,13 @@ const FROZEN_PATH_COUNTS: &[FrozenPathCount] = &[ FrozenPathCount { category: "test-support", item_kind: "method", - path: "crates/ironclaw_reborn_composition/src/observability/trace_capture.rs", + // Re-keyed 2026-08-04 (WS6) from + // `ironclaw_reborn_composition/src/observability/trace_capture.rs`: the + // module moved to the turn-runner observer seam. Same struct, same + // `#[cfg(test)] with_history_source` seam, same count — a path-keyed + // inventory entry following its file, which is the WS10 hazard this + // gate exists to make loud rather than a new allowance. + path: "crates/ironclaw_runner/src/trace_capture.rs", count: 1, }, FrozenPathCount { diff --git a/crates/ironclaw_auth/AGENTS.md b/crates/ironclaw_auth/AGENTS.md index cef78630629..a82cf3a314c 100644 --- a/crates/ironclaw_auth/AGENTS.md +++ b/crates/ironclaw_auth/AGENTS.md @@ -6,6 +6,22 @@ - Read `Cargo.toml` for dependencies and feature shape. - Use `docs/reborn/contracts/auth-product.md` and issues #3289 / #3810 / #3883 / #3884 as the source of truth. +## Module Charter — two engines, four owners + +This crate is **two engines** (PROPOSAL §6.4.8), and they do not name each +other: `src/engine/` runs every conversation with a vendor, `src/product_auth/` +runs the durable product-facing lifecycle. Each module's `mod.rs` doc comment +carries its own charter — what it owns and what must never drift in — and +`CLAUDE.md`'s `## Sub-owner map` charts **every** `src/**/*.rs` file across +four owners: the two engines plus `vocabulary` (what both engines stand on and +neither owns) and `test-support`. + +Both halves are enforced by `tests/module_charter.rs`: every file has exactly +one owner and every charted path exists, **and** `engine` must not name +`product_auth` nor `product_auth` name `engine`. Read the map before adding a +file — a new one fails the gate until it is given an owner, and a file only one +engine names belongs to that engine rather than to `vocabulary`. + ## What This Crate Owns - Product-facing Reborn auth setup contracts and implementations: auth flows, @@ -13,7 +29,7 @@ credential accounts, runtime selection/refresh, recovery/account-selection projections, provider exchange/refresh, continuations, recipes, fakes, and cleanup. -- Temporary v1 loopback OAuth callback transport in `loopback_oauth`, re-exported through `oauth`, folded from `ironclaw_oauth` in W2.1 and deleted with v1. +- ~~Temporary v1 loopback OAuth callback transport in `loopback_oauth`, re-exported through `oauth`, folded from `ironclaw_oauth` in W2.1 and deleted with v1.~~ **Struck 2026-08-04 (WS6): deleted.** Neither `loopback_oauth` nor its `urlencoding` dependency is in the tree; §6.4.8's delete clause already landed. Do not re-add a fixed-port callback transport. - Fake in-memory services for contract tests and downstream caller tests. - Redacted DTOs safe for WebUI, CLI, chat, API, and projection rendering. diff --git a/crates/ironclaw_auth/CLAUDE.md b/crates/ironclaw_auth/CLAUDE.md index 8c448c89b85..e89b55851c2 100644 --- a/crates/ironclaw_auth/CLAUDE.md +++ b/crates/ironclaw_auth/CLAUDE.md @@ -1,5 +1,56 @@ # ironclaw_auth Guardrails +## Sub-owner map + +PROPOSAL §6.4.8 asks for the **two-engine split (engine vs product_auth)** to +become "two chartered top-level modules". Both modules exist and are already +severed — neither names the other. What was missing is the charter, and +building it refuted the "two owners" framing: measured symbol-by-symbol, +**6 of the 11 shared top-level modules are named by _both_ engines** +(`credential`, `provider`, `oauth`, `scope`, `ids`, `error`), so charging them +to either engine would make one engine the owner of the other's dependencies. +There are **four** owners, not two. Each engine's own charter — what it owns +and what must never drift in — is in its `mod.rs` doc comment. + +**This table is enforced.** `tests/module_charter.rs` asserts every `.rs` file +under `src/` appears in exactly one row and every path in a row exists, so the +map cannot rot in either direction, and it separately pins the severance the +two-engine split exists for: `engine` must not name `product_auth`, and +`product_auth` must not name `engine`. + +| Sub-owner | Owns | Never contains | Files | +|---|---|---|---| +| `engine` | Every conversation with a vendor: authorize URLs, scope validation against the recipe ceiling, `oauth2_code`+PKCE, `api_key`+probe, RFC 7591 DCR, token exchange/refresh, the keepalive sweep and its leader lock, admission metadata, and the auth-account state machine | A vendor-conditional code path, or any durable product-auth lifecycle | `engine/mod.rs`, `engine/admission.rs`, `engine/dcr.rs`, `engine/exchange.rs`, `engine/http.rs`, `engine/keepalive.rs`, `account_state.rs` | +| `product-auth` | The durable, product-facing lifecycle: auth flows, credential accounts and their selection/recovery/refresh serialization, secure interactions and manual-token submission, ownership-aware cleanup, the filesystem-backed stores, and the OAuth turn-gate | A vendor handshake — that is `engine`, without exception | `product_auth/mod.rs`, `product_auth/api/mod.rs`, `product_auth/api/auth.rs`, `product_auth/api/auth/tests.rs`, `product_auth/credentials/mod.rs`, `product_auth/credentials/manual_token_flow.rs`, `product_auth/credentials/product_auth_refresh_lock.rs`, `product_auth/credentials/runtime_credentials.rs`, `product_auth/credentials/runtime_credentials/host_managed_fallback.rs`, `product_auth/credentials/runtime_credentials/tests.rs`, `product_auth/credentials/runtime_credentials/tests/duplicate_selection.rs`, `product_auth/durable/mod.rs`, `product_auth/durable/accounts.rs`, `product_auth/durable/cleanup.rs`, `product_auth/durable/domain.rs`, `product_auth/durable/flows.rs`, `product_auth/durable/interactions.rs`, `product_auth/durable/paths.rs`, `product_auth/durable/provider.rs`, `product_auth/durable/tests.rs`, `product_auth/oauth/mod.rs`, `product_auth/oauth/oauth_gate.rs`, `cleanup.rs`, `domain.rs`, `flow.rs`, `interaction.rs`, `product_prompt.rs`, `channel_connection.rs` | +| `vocabulary` | What **both** engines stand on and neither owns: the crate's identifiers and hashes, the error taxonomy, the auth scope/surface pair, OAuth protocol types and PKCE helpers, the `AuthProviderClient` port, and credential-account types | Behavior either engine could own alone — if only one engine names it, it belongs to that engine | `lib.rs`, `ids.rs`, `error.rs`, `scope.rs`, `oauth.rs`, `provider.rs`, `credential.rs` | +| `test-support` | Test doubles and the cross-implementation conformance suite, including the published `test-support` feature downstream harnesses consume | Production behavior | `fakes.rs`, `test_support.rs`, `test_support/conformance.rs` | + +Three placement calls worth stating, because each is a file whose *location* +suggests one owner and whose *use* is another: + +- **`account_state.rs` is `engine`, not `vocabulary`**, even though it sits at + the crate root beside the shared types. Measured: `AuthAccountState` is named + by `engine/` and by **zero** files in `product_auth/`, and `engine/mod.rs`'s + own doc already claims "the auth-account state machine" as engine-owned. The + other two symbols in the file (`AuthAccountLastError`, + `project_auth_account_state`) are named by neither engine — they are public + API consumed outside the crate. +- **`cleanup.rs`, `domain.rs`, `flow.rs` and `interaction.rs` are + `product-auth`** despite living at the crate root: measured, every symbol + either engine names in them is named by `product_auth/` only. They are the + four files a later slice could physically `git mv` into `product_auth/` + without touching the shared vocabulary; `domain.rs` would need a rename first + because `product_auth/durable/domain.rs` already holds that name. +- **`credential.rs` is the one genuinely two-owner file.** Of its 25 exported + symbols, 18 are `product_auth`-only and 6 are named by both engines — + including `CredentialAccountService` and + `ProviderBackedCredentialAccountService`, which `engine/keepalive.rs` drives + for the refresh sweep. A file-granular map has to pick one, so it is charged + to `vocabulary` (the shared half is what makes it un-movable), and splitting + the service half out is owed work rather than a defect in this map. + +## Guardrails + - Own product-facing auth vocabulary, durable filesystem-backed product-auth services, fake services, and the recipe-driven `AuthEngine` (extension-runtime workstream D): `oauth2_code` + PKCE, `api_key` + probe, @@ -8,7 +59,7 @@ add a vendor-conditional code path here; a vendor difference belongs in recipe data or (as a last resort, with an ADR) a narrow declared quirk hook. - Engine transport is the injected `RuntimeHttpEgress` port and token storage is the injected `ironclaw_secrets::SecretStore`; every vendor request pins a network policy to the recipe endpoint's host and caps the response body. Vendor response bodies are never logged, stored, or embedded in errors — only stable OAuth error codes are extracted. -- Temporary exception: `loopback_oauth` contains the v1 fixed-port OAuth callback transport folded from `ironclaw_oauth`; do not add Reborn consumers, and delete it with v1. +- ~~Temporary exception: `loopback_oauth` contains the v1 fixed-port OAuth callback transport folded from `ironclaw_oauth`; do not add Reborn consumers, and delete it with v1.~~ **Struck 2026-08-04 (WS6): the module is gone.** PROPOSAL §6.4.8's "Deletes: `loopback_oauth` + its `urlencoding` dep" already landed — neither the file nor the dependency is in the tree. Do not re-add a fixed-port callback transport here; the callback arrives over the WebUI product-auth routes. - Exception: `ProviderBackedCredentialAccountService` may live here because refresh serialization and status projection belong at the `CredentialAccountService` boundary, while raw provider/token material stays behind `AuthProviderClient` and secret boundaries. - Keep Reborn auth code independent from V1 route handlers, V1 pending state, V1 extension manager authority, V1 secret-store implementation details, diff --git a/crates/ironclaw_auth/Cargo.toml b/crates/ironclaw_auth/Cargo.toml index 2218f5c74b1..8a25dcdfda7 100644 --- a/crates/ironclaw_auth/Cargo.toml +++ b/crates/ironclaw_auth/Cargo.toml @@ -33,6 +33,14 @@ ironclaw_events = { path = "../ironclaw_events" } ironclaw_filesystem = { path = "../ironclaw_filesystem" } ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } ironclaw_extension_contracts = { path = "../ironclaw_extension_contracts", version = "0.1.0" } +# The product-facing auth ports declared in `channel_connection.rs` and +# `product_prompt.rs` error with `ProductSurfaceError` and carry +# `ChannelConnectionRequirement` / `BlockedAuthPromptRequest`: their callers are +# product surfaces. `ironclaw_product_contracts` is the neutral product-tier +# contract crate and sits in the `contracts` layer, so this is a downward edge +# like `host_api` — never a dependency on `ironclaw_product` itself. Same shape +# and same rationale as `ironclaw_attachments`' landing/read ports. +ironclaw_product_contracts = { path = "../ironclaw_product_contracts", version = "0.1.0" } ironclaw_secrets = { path = "../ironclaw_secrets" } # Compile-time Mozilla Public Suffix List: DCR issuer/resource origin binding # needs real eTLD+1 resolution (naive label-splitting treats every `*.co.uk` diff --git a/crates/ironclaw_auth/src/channel_connection.rs b/crates/ironclaw_auth/src/channel_connection.rs new file mode 100644 index 00000000000..0f681193556 --- /dev/null +++ b/crates/ironclaw_auth/src/channel_connection.rs @@ -0,0 +1,79 @@ +//! The caller's per-channel connection surface. +//! +//! **Why here.** [`ChannelAuthAccountState`] is literally the argument pair of +//! [`crate::project_auth_account_state`] — two of this crate's own §6.3 enums — +//! and [`ChannelConnectionService`] is the port that produces it. The port's +//! only other vocabulary is `ironclaw_product_contracts::surface`, which this +//! crate may name (a `substrates -> contracts` edge, the same downward shape +//! `ironclaw_attachments` already carries for its landing ports). +//! +//! The projection decision does **not** cross the port: `project_auth_account_state` +//! stays a pure function of the two enums and is called by `ironclaw_product`'s +//! extensions wire, so nothing authoritative moves with the vocabulary. + +use async_trait::async_trait; +use ironclaw_product_contracts::surface::{ProductSurfaceCaller, ProductSurfaceError}; + +use crate::credential::CredentialAccountStatus; +use crate::flow::AuthFlowStatus; + +/// The caller's durable auth-account signal for one channel extension's vendor +/// — the raw inputs the extensions-list service feeds to +/// [`crate::project_auth_account_state`] so an account renders its real +/// §6.3 state (`expired` / `refresh-failed` / `authenticating`) plus a typed +/// last error, instead of the connected/disconnected collapse the +/// [`ChannelConnectionService::caller_channel_connections`] bool alone permits. +/// +/// Both inputs are optional. A service that only knows the caller holds a live +/// grant leaves both `None` and the projection falls back to the connection +/// bool (a live grant backfills to `connected`, MIG-1); a service that reads the +/// durable credential-account status supplies `account_status` (and, mid-flow, +/// `active_flow_status`) so the wire surfaces the real state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ChannelAuthAccountState { + /// The caller's durable credential-account status for the extension's + /// vendor, when the service can read it. + pub account_status: Option, + /// A live (non-terminal) auth flow for the extension's vendor, when one is + /// in progress — projects to `authenticating`. + pub active_flow_status: Option, +} + +/// Per-user channel connection state. Returns, for the calling user, which +/// channel extensions they have personally connected — a per-user vendor OAuth +/// grant, typically. Keyed by channel package id -> `true` when connected. +/// Only channels that have a per-user connection concept appear in the map; +/// absence means "no per-user connection concept for this channel". +#[async_trait] +pub trait ChannelConnectionService: Send + Sync { + async fn caller_channel_connections( + &self, + caller: ProductSurfaceCaller, + ) -> Result, ProductSurfaceError>; + + /// The caller's durable auth-account signal per channel extension, keyed by + /// channel package id — richer than the connected/disconnected bool + /// [`Self::caller_channel_connections`] returns. Lets the extensions wire + /// project the shared §6.3 auth-account state (`expired` / `refresh-failed`) + /// and its typed last error for each vendor account. + /// + /// Default: empty. A service that does not yet read durable credential-account + /// status reports none and the wire falls back to the connection bool; the + /// production channel-connection service overrides this to project each + /// caller's account status. + async fn caller_channel_account_states( + &self, + _caller: ProductSurfaceCaller, + ) -> Result, ProductSurfaceError> + { + Ok(std::collections::HashMap::new()) + } + + async fn disconnect_channel_for_caller( + &self, + _caller: ProductSurfaceCaller, + _channel: &str, + ) -> Result<(), ProductSurfaceError> { + Err(ProductSurfaceError::service_unavailable(false)) + } +} diff --git a/crates/ironclaw_auth/src/engine/mod.rs b/crates/ironclaw_auth/src/engine/mod.rs index 951ad0f6a24..c8780dc86b0 100644 --- a/crates/ironclaw_auth/src/engine/mod.rs +++ b/crates/ironclaw_auth/src/engine/mod.rs @@ -22,6 +22,32 @@ //! //! Vendor response bodies are size-capped and never logged or embedded in //! errors; only stable OAuth error codes (`invalid_grant`, …) are extracted. +//! +//! # Module charter +//! +//! This is **the first of this crate's two engines** (PROPOSAL §6.4.8). +//! +//! **Owns:** every conversation with a vendor. Authorize-URL construction, +//! scope validation against the recipe ceiling, `oauth2_code` + PKCE, +//! `api_key` + probe, RFC 7591 dynamic client registration, token exchange and +//! refresh, bounded JSON-pointer extraction of token/identity fields, the +//! keepalive refresh sweep and its leader lock, authorization-server and +//! protected-resource admission metadata, and the auth-account state machine +//! ([`crate::AuthAccountState`]). +//! +//! **Never contains:** a vendor-conditional code path (a vendor difference is +//! recipe *data*, or — last resort, with an ADR — a narrow declared quirk +//! hook), and none of the durable product-auth lifecycle: flow records, +//! credential-account projections, secure interactions, and cleanup are +//! [`crate::product_auth`]'s. +//! +//! **The severance is the point, and it is enforced.** This module must not +//! name `product_auth`, and `product_auth` must not name this module — +//! measured at zero references in both directions and pinned by +//! `tests/module_charter.rs::the_two_engines_do_not_name_each_other`. The two +//! engines meet only through the shared vocabulary re-exported from the crate +//! root, which is a **third** owner in `CLAUDE.md`'s sub-owner map rather than +//! being charged to either engine. pub mod admission; mod dcr; diff --git a/crates/ironclaw_auth/src/lib.rs b/crates/ironclaw_auth/src/lib.rs index 651285a8e6a..ef321426183 100644 --- a/crates/ironclaw_auth/src/lib.rs +++ b/crates/ironclaw_auth/src/lib.rs @@ -10,6 +10,7 @@ //! pending maps, V1 extension manager authority, or V1 secret stores. mod account_state; +mod channel_connection; mod cleanup; mod credential; pub mod domain; @@ -25,12 +26,14 @@ mod ids; mod interaction; pub mod oauth; pub mod product_auth; +pub mod product_prompt; mod provider; mod scope; #[cfg(any(test, feature = "test-support"))] pub mod test_support; pub use account_state::{AuthAccountLastError, AuthAccountState, project_auth_account_state}; +pub use channel_connection::{ChannelAuthAccountState, ChannelConnectionService}; pub use cleanup::{ CanceledCleanupFlow, SecretCleanupAction, SecretCleanupQuarantine, SecretCleanupQuarantineReason, SecretCleanupReport, SecretCleanupRequest, SecretCleanupService, diff --git a/crates/ironclaw_auth/src/product_auth/mod.rs b/crates/ironclaw_auth/src/product_auth/mod.rs index b90b13d777d..0577059d865 100644 --- a/crates/ironclaw_auth/src/product_auth/mod.rs +++ b/crates/ironclaw_auth/src/product_auth/mod.rs @@ -1,8 +1,32 @@ -//! Reborn product-auth production services. +//! Reborn product-auth production services — **the second of this crate's two +//! engines** (PROPOSAL §6.4.8). //! //! Auth-owned contracts, flow/account stores, refresh helpers, OAuth engine //! helpers, continuations, cleanup, and fakes live here. HTTP route serving and //! product-specific prompt rendering stay in product/host crates. +//! +//! # Module charter +//! +//! **Owns:** the *durable, product-facing* half of auth — the lifecycle a user +//! and a product surface can observe. Starting, resuming, listing and expiring +//! auth flows; credential accounts and their selection, recovery and refresh +//! serialization; secure interactions and manual-token submission; the +//! ownership-aware cleanup lifecycle; the filesystem-backed stores behind all +//! of it; and the OAuth turn-gate that pauses a run until a flow completes. +//! +//! **Never contains:** a vendor handshake. Constructing an authorize URL, +//! validating scopes against a recipe ceiling, exchanging or refreshing a +//! token against a vendor endpoint, and RFC 7591 dynamic client registration +//! are [`crate::engine`]'s, without exception. +//! +//! **The severance is the point, and it is enforced.** This module must not +//! name `engine`, and `engine` must not name this module — measured at zero +//! references in both directions and pinned by +//! `tests/module_charter.rs::the_two_engines_do_not_name_each_other`. The two +//! engines meet only through the shared vocabulary re-exported from the crate +//! root (`credential`, `provider`, `oauth`, `scope`, `ids`, `error`), which is +//! why that vocabulary is a **third** owner in `CLAUDE.md`'s sub-owner map +//! rather than being charged to either engine. pub mod api; pub mod credentials; diff --git a/crates/ironclaw_product/src/auth_prompt.rs b/crates/ironclaw_auth/src/product_prompt.rs similarity index 71% rename from crates/ironclaw_product/src/auth_prompt.rs rename to crates/ironclaw_auth/src/product_prompt.rs index edabb09614a..1fd85090de3 100644 --- a/crates/ironclaw_product/src/auth_prompt.rs +++ b/crates/ironclaw_auth/src/product_prompt.rs @@ -1,26 +1,45 @@ -//! Product-neutral rendering support for blocked-auth prompts. +//! The product-facing auth-challenge surface: the redacted challenge view, the +//! two ports that materialize and cancel a challenge, and the prompt-view +//! constructor both the delivery path and the projection layer render through. //! -//! One owner for the blocked-auth prompt vocabulary: the challenge view, the -//! challenge/cancel ports composition implements, and the prompt-view -//! constructor both the delivery path and the projection layer render -//! through. Composition consumes these — it must not re-declare them. +//! **Why here.** Every type in these signatures is this crate's own vocabulary +//! — [`AuthProviderId`], [`CredentialAccountLabel`], [`OAuthAuthorizationUrl`], +//! [`AuthProductError`] — and the ports have implementors on both sides of the +//! product boundary: `ironclaw_product`'s `RebornProductAuthServices` and +//! `ironclaw_extension_host`'s `RecipeAuthChallengeProvider`. Declaring them in +//! `ironclaw_product_contracts` would mean narrowing four validated auth types +//! down to `String` to satisfy that crate's `host_api` + `extension_contracts` +//! ceiling; declaring them here costs nothing and is what +//! `.claude/rules/type-placement.md` §2/§3 asks for — a domain's port belongs +//! in the domain, not in the vocabulary crate that describes it +//! (`families/contracts.md:46`). +//! +//! The prompt DTOs these produce ([`AuthPromptView`], [`PairingPromptView`], +//! [`ConnectionPromptContext`]) stay in `ironclaw_extension_contracts::auth_prompt`, +//! which owns the channel-rendered half of the family; this module is their +//! auth-side producer. -use crate::{ - AuthPromptChallengeKind, AuthPromptView, ConnectionPromptContext, ProductAdapterError, - RedactedString, -}; use async_trait::async_trait; -use ironclaw_auth::{ - AuthProductError, AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl, +use ironclaw_extension_contracts::auth_prompt::{ + AuthPromptChallengeKind, AuthPromptView, ConnectionPromptContext, PairingPromptView, }; -use ironclaw_extension_contracts::auth_prompt::PairingPromptView; +use ironclaw_host_api::product_adapter_error::{ProductAdapterError, RedactedString}; +use ironclaw_host_api::turn::{TurnRunId, TurnScope}; use ironclaw_host_api::{ capability::RuntimeCredentialAccountSetup, decision::RuntimeCredentialAuthRequirement, ids::UserId, }; use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; -use ironclaw_turns::{TurnRunId, TurnScope}; + +use std::sync::Arc; + +use crate::RebornProductAuthServices; +use crate::error::AuthProductError; +use crate::flow::{AuthChallenge, AuthFlowOwnerScope, TurnGateAuthFlowQuery}; +use crate::ids::{ + AuthGateRef, AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl, TurnRunRef, +}; /// Map a manifest display string onto the projection's optional field: a blank /// value means the affordance does not exist, which is `None` on the wire. The @@ -259,6 +278,144 @@ fn auth_prompt_from_credential_requirement( view } +/// The composed product-auth services as a challenge provider, when a durable +/// flow record source is wired in. +pub fn product_auth_challenge_provider( + product_auth: &Arc, +) -> Option> { + product_auth + .flow_record_source() + .map(|_| Arc::clone(product_auth) as Arc) +} + +pub fn blocked_auth_flow_canceller( + product_auth: &Arc, +) -> Option> { + product_auth + .flow_record_source() + .map(|_| Arc::clone(product_auth) as Arc) +} + +#[async_trait] +impl AuthChallengeProvider for RebornProductAuthServices { + async fn challenge_for_gate( + &self, + scope: &TurnScope, + owner_user_id: &UserId, + run_id: TurnRunId, + gate_ref: &str, + credential_requirements: &[RuntimeCredentialAuthRequirement], + ) -> Result, AuthProductError> { + let gate_ref = AuthGateRef::new(gate_ref.to_string()).map_err(|error| { + tracing::debug!(%error, "invalid gate_ref in auth challenge lookup"); + AuthProductError::BackendUnavailable + })?; + let Some(source) = self.flow_record_source() else { + return Ok(None); + }; + let flow_manager = self.flow_manager(); + if let Some(driver) = self.oauth_gate_driver() + && let Some(flow) = driver + .challenge_for_blocked_gate(crate::OAuthGateChallengeRequest { + flow_manager: &flow_manager, + flow_source: &source, + requirements: credential_requirements, + scope, + owner_user_id, + run_id, + gate_ref: &gate_ref, + }) + .await? + { + let Some(challenge) = flow.challenge.as_ref() else { + return Ok(None); + }; + return Ok(Some(auth_challenge_to_view(challenge, &flow.provider))); + } + let flow = source + .flow_for_turn_gate(TurnGateAuthFlowQuery { + owner: AuthFlowOwnerScope { + tenant_id: scope.tenant_id.clone(), + user_id: owner_user_id.clone(), + agent_id: scope.agent_id.clone(), + project_id: scope.project_id.clone(), + thread_id: scope.thread_id.clone(), + }, + turn_run_ref: TurnRunRef::new(run_id.to_string()).map_err(|error| { + tracing::debug!(%error, "invalid run_id in auth challenge lookup"); + AuthProductError::BackendUnavailable + })?, + gate_ref, + include_terminal: false, + }) + .await?; + let Some(flow) = flow else { + return Ok(None); + }; + let Some(challenge) = flow.challenge.as_ref() else { + return Ok(None); + }; + Ok(Some(auth_challenge_to_view(challenge, &flow.provider))) + } +} + +#[async_trait] +impl BlockedAuthFlowCanceller for RebornProductAuthServices { + async fn cancel_blocked_auth_flow( + &self, + scope: &TurnScope, + owner_user_id: &UserId, + run_id: TurnRunId, + gate_ref: &str, + ) -> Result<(), AuthProductError> { + self.cancel_blocked_auth_flow(scope, owner_user_id, run_id, gate_ref) + .await + } +} + +fn auth_challenge_to_view( + challenge: &AuthChallenge, + provider: &AuthProviderId, +) -> AuthChallengeView { + match challenge { + AuthChallenge::OAuthUrl { + authorization_url, + expires_at, + } => AuthChallengeView { + kind: AuthPromptChallengeKind::OAuthUrl, + provider: provider.clone(), + account_label: None, + authorization_url: Some(authorization_url.clone()), + expires_at: Some(*expires_at), + // Product-auth OAuth relay: no channel-connection context. + pairing: None, + }, + AuthChallenge::ManualTokenRequired { + provider, + label, + expires_at, + .. + } => AuthChallengeView { + kind: AuthPromptChallengeKind::ManualToken, + provider: provider.clone(), + account_label: Some(label.clone()), + authorization_url: None, + expires_at: Some(*expires_at), + pairing: None, + }, + AuthChallenge::AccountSelectionRequired { .. } + | AuthChallenge::ReauthorizeRequired { .. } + | AuthChallenge::SetupRequired { .. } => AuthChallengeView { + kind: AuthPromptChallengeKind::Other, + provider: provider.clone(), + account_label: None, + authorization_url: None, + expires_at: None, + pairing: None, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -277,7 +434,7 @@ mod tests { fn base_view() -> AuthPromptView { AuthPromptView { - turn_run_id: ironclaw_turns::TurnRunId::new(), + turn_run_id: TurnRunId::new(), auth_request_ref: "gate:auth:1".to_string(), invocation_id: None, headline: "Authentication required".to_string(), diff --git a/crates/ironclaw_auth/tests/module_charter.rs b/crates/ironclaw_auth/tests/module_charter.rs new file mode 100644 index 00000000000..2827e76a995 --- /dev/null +++ b/crates/ironclaw_auth/tests/module_charter.rs @@ -0,0 +1,298 @@ +//! The sub-owner map in `CLAUDE.md` is a contract, not a comment — and the +//! two-engine severance it describes is an invariant, not an observation. +//! +//! PROPOSAL §6.4.8 asks this crate for the `engine`-vs-`product_auth` split to +//! become "two chartered top-level modules". Both modules already existed; what +//! was missing was the charter, and a charter nobody checks rots within a +//! release. This file pins the two halves that can rot: +//! +//! 1. **Coverage** — every `src/**/*.rs` has exactly one owner and every path +//! named by an owner exists, so a new file fails until it is charted and a +//! deleted one fails until its entry goes. +//! 2. **Severance** — `engine` must not name `product_auth` and `product_auth` +//! must not name `engine`. That is the property the "two engines" framing +//! *is*; without it the map would describe a split that had quietly closed. +//! +//! Coverage deliberately checks existence, not prose: whether `credential.rs` +//! is really shared vocabulary is a review question; whether every file has +//! exactly one owner is a mechanical one, and that is what is enforced. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Every `.rs` file under `src/`, as a `/`-separated path relative to `src/`. +fn source_files() -> Vec { + fn walk(dir: &Path, root: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|error| panic!("read_dir {}: {error}", dir.display())); + for entry in entries { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + walk(&path, root, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let rel = path + .strip_prefix(root) + .expect("path under src/") + .to_string_lossy() + .replace('\\', "/"); + out.push(rel); + } + } + } + let src = crate_root().join("src"); + let mut out = Vec::new(); + walk(&src, &src, &mut out); + out.sort(); + out +} + +/// Parse the `## Sub-owner map` table into `file -> [sub-owner, ...]`. +/// +/// A file listed under two sub-owners keeps both entries so the caller can +/// report the ambiguity rather than silently taking the last one. +fn charter_assignments() -> BTreeMap> { + let doc = std::fs::read_to_string(crate_root().join("CLAUDE.md")).expect("read CLAUDE.md"); + let section = doc + .split("## Sub-owner map") + .nth(1) + .expect("CLAUDE.md must contain a '## Sub-owner map' section"); + // Stop at the next top-level heading so neighbouring tables are not read. + let section = section.split("\n## ").next().unwrap_or(section); + parse_sub_owner_table(section) +} + +/// The table parser, split out from the file read so a fixture can exercise +/// separator shapes the checked-in `CLAUDE.md` does not currently use. +fn parse_sub_owner_table(section: &str) -> BTreeMap> { + let mut assignments: BTreeMap> = BTreeMap::new(); + let mut saw_row = false; + for line in section.lines() { + let line = line.trim(); + if !line.starts_with('|') { + continue; + } + let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect(); + // Header (`Sub-owner | ...`) and the separator carry no data. The + // separator is matched after stripping alignment colons: a table written + // `|:---|:---|` yields `:---`, which would otherwise parse as a data row + // and be inserted as an assigned path. + let separator_cell = cells[0].trim_matches(':'); + if cells.len() < 4 || cells[0] == "Sub-owner" || separator_cell.starts_with("---") { + continue; + } + let owner = cells[0].trim_matches('`').to_string(); + for path in cells[3] + .split(',') + .map(|entry| entry.trim().trim_matches('`').trim()) + .filter(|entry| !entry.is_empty()) + { + assignments + .entry(path.to_string()) + .or_default() + .push(owner.clone()); + } + saw_row = true; + } + assert!( + saw_row, + "the '## Sub-owner map' section parsed to zero table rows — the table \ + shape changed and this gate silently stopped checking anything" + ); + assignments +} + +#[test] +fn every_source_file_has_exactly_one_sub_owner() { + let files = source_files(); + let assignments = charter_assignments(); + + assert!( + files.len() > 35, + "expected to walk the whole crate; found only {} files — the walk is \ + broken and this gate would pass vacuously", + files.len() + ); + + let unassigned: Vec<&String> = files + .iter() + .filter(|file| !assignments.contains_key(*file)) + .collect(); + assert!( + unassigned.is_empty(), + "{} source file(s) have no sub-owner in CLAUDE.md's '## Sub-owner map'.\n\ + Add each to the row of the concern it belongs to (see PROPOSAL §6.4.8).\n\ + A file that is neither engine's belongs to `vocabulary` only if BOTH \ + engines name it; if only one does, it belongs to that engine:\n{}", + unassigned.len(), + unassigned + .iter() + .map(|file| format!(" {file}")) + .collect::>() + .join("\n") + ); + + let stale: Vec<&String> = assignments + .keys() + .filter(|path| !files.contains(*path)) + .collect(); + assert!( + stale.is_empty(), + "{} sub-owner entr(y/ies) name a file that no longer exists — delete \ + them so the map only shrinks:\n{}", + stale.len(), + stale + .iter() + .map(|path| format!(" {path}")) + .collect::>() + .join("\n") + ); + + let duplicated: Vec = assignments + .iter() + .filter(|(_, owners)| owners.len() > 1) + .map(|(path, owners)| format!(" {path} -> {}", owners.join(", "))) + .collect(); + assert!( + duplicated.is_empty(), + "{} file(s) are claimed by more than one sub-owner. A file has exactly \ + one owner; if it genuinely spans two, split it or charge it to the \ + larger half and say so (see `credential.rs`):\n{}", + duplicated.len(), + duplicated.join("\n") + ); +} + +/// Concatenate every `.rs` file under `src//`, minus `//`/`//!` comment +/// lines. +/// +/// Comments are stripped because both charters *name the other engine in +/// prose* — deliberately, since the severance is the thing they document — and +/// a scan that counted those would be unsatisfiable by construction. +fn module_code(module: &str) -> String { + fn walk(dir: &Path, out: &mut String) { + let mut paths: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|error| panic!("read_dir {}: {error}", dir.display())) + .map(|entry| entry.expect("dir entry").path()) + .collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + for line in text.lines() { + if !line.trim_start().starts_with("//") { + out.push_str(line); + out.push('\n'); + } + } + } + } + } + let dir = crate_root().join("src").join(module); + assert!( + dir.is_dir(), + "expected a top-level `src/{module}/` module; the two-engine split \ + described in CLAUDE.md and PROPOSAL §6.4.8 no longer matches the tree" + ); + let mut out = String::new(); + walk(&dir, &mut out); + assert!( + out.len() > 10_000, + "src/{module}/ concatenated to only {} bytes of code — the walk is \ + broken and this gate would pass vacuously", + out.len() + ); + out +} + +/// The two engines meet only through the crate root's shared vocabulary. +/// +/// This is the property PROPOSAL §6.4.8's "two-engine split" *is*. It held at +/// zero references in both directions when the charter was written; without +/// this test, the first `use crate::engine::…` inside `product_auth` would +/// close the split silently and leave the charter describing something that no +/// longer existed. +#[test] +fn the_two_engines_do_not_name_each_other() { + let engine = module_code("engine"); + let product_auth = module_code("product_auth"); + + for probe in [ + "crate::product_auth", + "super::product_auth", + "product_auth::", + ] { + assert!( + !engine.contains(probe), + "src/engine/ names `{probe}` — the vendor-handshake engine must not \ + reach into the durable product-auth engine. Route the value \ + through the shared vocabulary re-exported from the crate root, or \ + invert the call. (PROPOSAL §6.4.8; charter in engine/mod.rs.)" + ); + } + + for probe in ["crate::engine", "super::engine", "engine::"] { + assert!( + !product_auth.contains(probe), + "src/product_auth/ names `{probe}` — the durable product-auth \ + engine must not reach into the vendor-handshake engine. A vendor \ + call belongs behind the `AuthProviderClient` port, which is shared \ + vocabulary. (PROPOSAL §6.4.8; charter in product_auth/mod.rs.)" + ); + } +} + +/// An **aligned** separator row (`|:---|`) must not parse as data. +/// +/// The regression this pins: matching the separator with +/// `cells[0].starts_with("---")` sees `:---` and lets the row through. `:---` +/// then becomes both a sub-owner and an assigned path, and — worse — `saw_row` +/// goes true, so the zero-rows shape guard stays quiet and the gate reports +/// `:---` as a stale entry instead of diagnosing anything real. +/// +/// The checked-in `CLAUDE.md` uses `|---|`, so without this fixture a reversion +/// of the `trim_matches(':')` guard would still pass. +#[test] +fn an_aligned_separator_row_is_not_parsed_as_data() { + let unaligned = "\n\ + | Sub-owner | Owns | Never contains | Files |\n\ + |---|---|---|---|\n\ + | `engine` | vendor handshake | lifecycle | `engine/dcr.rs` |\n"; + let aligned = "\n\ + | Sub-owner | Owns | Never contains | Files |\n\ + |:---|:---|:---|:---|\n\ + | `engine` | vendor handshake | lifecycle | `engine/dcr.rs` |\n"; + let centred = "\n\ + | Sub-owner | Owns | Never contains | Files |\n\ + |:---:|:---:|:---:|:---:|\n\ + | `engine` | vendor handshake | lifecycle | `engine/dcr.rs` |\n"; + + for (label, table) in [ + ("unaligned", unaligned), + ("left-aligned", aligned), + ("centred", centred), + ] { + let parsed = parse_sub_owner_table(table); + assert_eq!( + parsed.keys().collect::>(), + vec!["engine/dcr.rs"], + "{label}: only the data row may be parsed; a separator must never \ + contribute a path (got {parsed:?})" + ); + assert_eq!( + parsed.get("engine/dcr.rs").map(Vec::as_slice), + Some(["engine".to_string()].as_slice()), + "{label}: the owner must come from the data row, not the separator" + ); + assert!( + !parsed.keys().any(|key| key.contains("---")), + "{label}: a separator cell leaked in as an assigned path: {parsed:?}" + ); + } +} diff --git a/crates/ironclaw_conversations/src/conversation_state_store.rs b/crates/ironclaw_conversations/src/conversation_state_store.rs index 31f6b76f6ce..bb411e0dee4 100644 --- a/crates/ironclaw_conversations/src/conversation_state_store.rs +++ b/crates/ironclaw_conversations/src/conversation_state_store.rs @@ -48,10 +48,10 @@ use crate::{ AcceptedConversationMessageLookup, AcceptedConversationMessageReplay, AdapterInstallationId, AdapterKind, ConditionalUnpairOutcome, ConversationActorPairingService, ConversationBindingResolution, ConversationBindingService, ConversationMessageRecord, - ExpectedExternalActorOwner, ExternalActorBindingEpoch, ExternalConversationIdentity, - InMemoryConversationServices, InboundConversationService, InboundTurnError, - LinkConversationRequest, LinkedConversationBinding, ReplyTargetBinding, - ResolveConversationRequest, ValidateReplyTargetRequest, + ExpectedExternalActorOwner, ExternalConversationIdentity, InMemoryConversationServices, + InboundConversationService, InboundTurnError, LinkConversationRequest, + LinkedConversationBinding, ReplyTargetBinding, ResolveConversationRequest, + ValidateReplyTargetRequest, memory::{ AcceptedMessageReplayKey, ActorKey, BindingKey, BindingRecord, ExternalEventRouteKey, InMemoryState, MessageIdempotencyKey, ReplyTargetRecord, StoredAcceptedMessageReplay, @@ -59,7 +59,7 @@ use crate::{ }, state_store::{ConversationStateRepository, PersistedConversationState}, }; -use ironclaw_extension_contracts::external::ExternalActorRef; +use ironclaw_extension_contracts::external::{ExternalActorBindingEpoch, ExternalActorRef}; const STATE_PREFIX: &str = "/conversations"; diff --git a/crates/ironclaw_conversations/src/lib.rs b/crates/ironclaw_conversations/src/lib.rs index 8992991d3bd..4d87abd073f 100644 --- a/crates/ironclaw_conversations/src/lib.rs +++ b/crates/ironclaw_conversations/src/lib.rs @@ -64,9 +64,8 @@ pub use types::{ AcceptConversationMessageRequest, AcceptedConversationMessage, AcceptedConversationMessageLookup, AcceptedConversationMessageReplay, ConditionalUnpairOutcome, ConversationBindingResolution, ConversationMessageRecord, ConversationRouteKind, - ExpectedExternalActorOwner, ExternalActorBindingEpoch, InboundTurnRequest, InboundTurnResponse, - LinkConversationRequest, LinkedConversationBinding, MessageIdempotencyStatus, - ReplyTargetBinding, ResolveConversationRequest, ResolveStoredReplyTargetRequest, - StoredReplyTargetAccess, StoredReplyTargetBinding, ThreadAccessDecision, - ValidateReplyTargetRequest, + ExpectedExternalActorOwner, InboundTurnRequest, InboundTurnResponse, LinkConversationRequest, + LinkedConversationBinding, MessageIdempotencyStatus, ReplyTargetBinding, + ResolveConversationRequest, ResolveStoredReplyTargetRequest, StoredReplyTargetAccess, + StoredReplyTargetBinding, ThreadAccessDecision, ValidateReplyTargetRequest, }; diff --git a/crates/ironclaw_conversations/src/memory.rs b/crates/ironclaw_conversations/src/memory.rs index 8c3fc4128bc..da6fabcd9b6 100644 --- a/crates/ironclaw_conversations/src/memory.rs +++ b/crates/ironclaw_conversations/src/memory.rs @@ -20,14 +20,15 @@ use crate::{ AcceptedConversationMessageLookup, AcceptedConversationMessageReplay, AdapterInstallationId, AdapterKind, ConditionalUnpairOutcome, ConversationActorPairingService, ConversationBindingResolution, ConversationBindingService, ConversationMessageRecord, - ConversationRouteKind, ExpectedExternalActorOwner, ExternalActorBindingEpoch, - ExternalConversationIdentity, InboundConversationService, InboundTurnError, - LinkConversationRequest, LinkedConversationBinding, MessageIdempotencyStatus, - ReplyTargetBinding, ResolveConversationRequest, ResolveStoredReplyTargetRequest, - StoredReplyTargetAccess, StoredReplyTargetBinding, ThreadAccessDecision, - ValidateReplyTargetRequest, + ConversationRouteKind, ExpectedExternalActorOwner, ExternalConversationIdentity, + InboundConversationService, InboundTurnError, LinkConversationRequest, + LinkedConversationBinding, MessageIdempotencyStatus, ReplyTargetBinding, + ResolveConversationRequest, ResolveStoredReplyTargetRequest, StoredReplyTargetAccess, + StoredReplyTargetBinding, ThreadAccessDecision, ValidateReplyTargetRequest, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorBindingEpoch, ExternalActorRef, ExternalConversationRef, }; -use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; #[derive(Clone)] pub struct InMemoryConversationServices { diff --git a/crates/ironclaw_conversations/src/traits.rs b/crates/ironclaw_conversations/src/traits.rs index 371d2567bd3..397f671671b 100644 --- a/crates/ironclaw_conversations/src/traits.rs +++ b/crates/ironclaw_conversations/src/traits.rs @@ -5,12 +5,11 @@ use crate::{ AcceptConversationMessageRequest, AcceptedConversationMessage, AcceptedConversationMessageLookup, AcceptedConversationMessageReplay, AdapterInstallationId, AdapterKind, ConditionalUnpairOutcome, ConversationBindingResolution, - ExpectedExternalActorOwner, ExternalActorBindingEpoch, InboundTurnError, - LinkConversationRequest, LinkedConversationBinding, ReplyTargetBinding, - ResolveConversationRequest, ResolveStoredReplyTargetRequest, StoredReplyTargetBinding, - ValidateReplyTargetRequest, + ExpectedExternalActorOwner, InboundTurnError, LinkConversationRequest, + LinkedConversationBinding, ReplyTargetBinding, ResolveConversationRequest, + ResolveStoredReplyTargetRequest, StoredReplyTargetBinding, ValidateReplyTargetRequest, }; -use ironclaw_extension_contracts::external::ExternalActorRef; +use ironclaw_extension_contracts::external::{ExternalActorBindingEpoch, ExternalActorRef}; #[async_trait] pub trait ConversationBindingService: Send + Sync { diff --git a/crates/ironclaw_conversations/src/types.rs b/crates/ironclaw_conversations/src/types.rs index 6432394c359..e770fab52b6 100644 --- a/crates/ironclaw_conversations/src/types.rs +++ b/crates/ironclaw_conversations/src/types.rs @@ -7,58 +7,9 @@ use ironclaw_host_api::turn::{ use serde::{Deserialize, Serialize}; use crate::{AdapterInstallationId, AdapterKind, ExternalEventId, InboundMessageContentRef}; -use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(try_from = "String")] -pub struct ExternalActorBindingEpoch(String); - -impl ExternalActorBindingEpoch { - fn validate(value: &str) -> Result<(), crate::InboundTurnError> { - crate::ids::validate_external_id("external_actor_binding_epoch", value) - } - - pub fn new(value: impl Into) -> Result { - let value = value.into(); - Self::validate(&value)?; - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_inner(self) -> String { - self.0 - } -} - -impl TryFrom for ExternalActorBindingEpoch { - type Error = crate::InboundTurnError; - - fn try_from(value: String) -> Result { - Self::validate(&value)?; - Ok(Self(value)) - } -} - -impl AsRef for ExternalActorBindingEpoch { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl std::fmt::Display for ExternalActorBindingEpoch { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.as_str()) - } -} - -impl From for String { - fn from(epoch: ExternalActorBindingEpoch) -> Self { - epoch.0 - } -} +use ironclaw_extension_contracts::external::{ + ExternalActorBindingEpoch, ExternalActorRef, ExternalConversationRef, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConditionalUnpairOutcome { diff --git a/crates/ironclaw_conversations/tests/conversation_state_store_contract.rs b/crates/ironclaw_conversations/tests/conversation_state_store_contract.rs index 468312e9b6b..4775c750ced 100644 --- a/crates/ironclaw_conversations/tests/conversation_state_store_contract.rs +++ b/crates/ironclaw_conversations/tests/conversation_state_store_contract.rs @@ -16,11 +16,13 @@ use ironclaw_conversations::{ AcceptConversationMessageRequest, AcceptedConversationMessageLookup, AcceptedConversationMessageReplay, AdapterInstallationId, AdapterKind, ConditionalUnpairOutcome, ConversationBindingService, ConversationMessageRecord, - ConversationRouteKind, ExpectedExternalActorOwner, ExternalActorBindingEpoch, ExternalEventId, - InboundConversationService, InboundMessageContentRef, InboundTurnError, - MessageIdempotencyStatus, RebornFilesystemConversationServices, ResolveConversationRequest, + ConversationRouteKind, ExpectedExternalActorOwner, ExternalEventId, InboundConversationService, + InboundMessageContentRef, InboundTurnError, MessageIdempotencyStatus, + RebornFilesystemConversationServices, ResolveConversationRequest, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorBindingEpoch, ExternalActorRef, ExternalConversationRef, }; -use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; use ironclaw_filesystem::{CasExpectation, InMemoryBackend, RootFilesystem, ScopedFilesystem}; use ironclaw_host_api::{ ids::{AgentId, ProjectId, TenantId, UserId}, diff --git a/crates/ironclaw_conversations/tests/inbound_contract.rs b/crates/ironclaw_conversations/tests/inbound_contract.rs index 7d5111a78fe..626b5a56321 100644 --- a/crates/ironclaw_conversations/tests/inbound_contract.rs +++ b/crates/ironclaw_conversations/tests/inbound_contract.rs @@ -9,15 +9,16 @@ use ironclaw_conversations::{ AdapterKind, ConditionalUnpairOutcome, ConversationBindingResolution, ConversationBindingService, ConversationInboundClassification, ConversationRouteKind, ConversationTurnSubmission, ConversationTurnSubmitter, ExpectedExternalActorOwner, - ExternalActorBindingEpoch, ExternalConversationIdentity, ExternalEventId, - InMemoryConversationServices, InboundConversationService, InboundMessageContentRef, - InboundTurnError, InboundTurnRequest, InboundTurnService, LinkConversationRequest, - LinkedConversationBinding, MessageIdempotencyStatus, ReplyTargetBinding, - ResolveStoredReplyTargetRequest, StoredReplyTargetAccess, ThreadAccessDecision, - TurnSubmissionError, TurnSubmissionErrorCategory, TurnSubmissionRetry, - ValidateReplyTargetRequest, + ExternalConversationIdentity, ExternalEventId, InMemoryConversationServices, + InboundConversationService, InboundMessageContentRef, InboundTurnError, InboundTurnRequest, + InboundTurnService, LinkConversationRequest, LinkedConversationBinding, + MessageIdempotencyStatus, ReplyTargetBinding, ResolveStoredReplyTargetRequest, + StoredReplyTargetAccess, ThreadAccessDecision, TurnSubmissionError, + TurnSubmissionErrorCategory, TurnSubmissionRetry, ValidateReplyTargetRequest, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorBindingEpoch, ExternalActorRef, ExternalConversationRef, }; -use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; use ironclaw_host_api::ids::{AgentId, ProjectId, TenantId, ThreadId, UserId}; use ironclaw_host_api::turn::{ AcceptedMessageRef, IdempotencyKey, ReplyTargetBindingRef, RunProfileId, RunProfileRequest, diff --git a/crates/ironclaw_extension_contracts/CLAUDE.md b/crates/ironclaw_extension_contracts/CLAUDE.md index d738f703ab5..206779e9e91 100644 --- a/crates/ironclaw_extension_contracts/CLAUDE.md +++ b/crates/ironclaw_extension_contracts/CLAUDE.md @@ -13,9 +13,10 @@ A type is admitted iff all four hold (the contracts-family test, §6.1): 3. two or more consumers need it without importing an owner; 4. it carries no execution, persistence, policy engine, or workflow. -Today that is seventeen modules (WS1.4 corrected the stale "thirteen" carried -over from WS1.3 to sixteen; WS1.5's `verified_inbound` makes seventeen — the -number is checked against `src/lib.rs`, not incremented by hand): +Today that is eighteen modules (WS1.4 corrected the stale "thirteen" carried +over from WS1.3 to sixteen; WS1.5's `verified_inbound` made seventeen; WS5's +`product_adapter_section` makes eighteen — the number is checked against +`src/lib.rs`, not incremented by hand): | Module | Owns | | --- | --- | @@ -30,6 +31,7 @@ number is checked against `src/lib.rs`, not incremented by hand): | `lifecycle_id` | The bounded package-identity newtypes both tiers need: `LifecyclePackageId` (which `hosted_mcp` names structurally) and `LifecycleBlockerRef`. | | `memory` | The `[memory]` manifest surface: `MemoryDescriptor`, `MemoryLifecycleHook`. | | `preference_target` | `PreferenceTargetCodec` + `PreferenceTargetEncodeRequest` — the one vendor-implemented port here. | +| `product_adapter_section` | The `[product_adapter.*]` manifest surface: `PRODUCT_ADAPTER_HOST_API_ID`/`PRODUCT_ADAPTER_SECTION_PREFIX`, `ProductAdapterSectionDeclaration` (the `Deserialize` wire shape), `ProductAdapterSection` (resolved + validated), `HostIngressRoute`, `ProductAdapterSectionError`. Arrived with WS5 from `ironclaw_product::adapter_registry`. Same split as `channel`: the schema and its cross-field invariants are here; the *manifest parsing* — the `HostApiManifestContract`, the raw-TOML inline-secret guard, and pairing a resolved section with its `ManifestSectionPath` — is `ironclaw_extensions::host_api::product_adapter` (§6.8.1), because this crate parses no manifests. | | `recipe` | The auth recipe schema: `VendorAuthRecipe`, `OAuth2CodeRecipe`, `PkceMode`, ingress-verification recipes, and friends. | | `state` | The installation state machine: `InstallationState`, `LifecyclePublicState`. | | `surface` | `CapabilitySurfaceKind` — the manifest surface kinds an extension may declare. | diff --git a/crates/ironclaw_extension_contracts/src/external.rs b/crates/ironclaw_extension_contracts/src/external.rs index 9a015a77c47..2539d43aa2a 100644 --- a/crates/ironclaw_extension_contracts/src/external.rs +++ b/crates/ironclaw_extension_contracts/src/external.rs @@ -63,6 +63,60 @@ impl std::fmt::Display for ExternalEventId { } } +/// Generation marker for an external actor's pairing binding. +/// +/// The epoch is provider-neutral: the conversation layer only preserves and +/// compares it, and product adapters own its meaning. It lives here, beside +/// [`ExternalActorRef`] whose binding it versions, because both sides of the +/// pairing seam name it — the conversation ledger that stores it and the +/// product-tier actor-resolution port that carries it — and neither may +/// import the other. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct ExternalActorBindingEpoch(String); + +impl ExternalActorBindingEpoch { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_external_id("external_actor_binding_epoch", &value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl TryFrom for ExternalActorBindingEpoch { + type Error = ProductAdapterError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl AsRef for ExternalActorBindingEpoch { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for ExternalActorBindingEpoch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl From for String { + fn from(epoch: ExternalActorBindingEpoch) -> Self { + epoch.0 + } +} + /// External actor reference. Equality/hash use only stable identity /// (`kind`, `id`); `display_name` is presentation metadata. #[derive(Debug, Clone, Serialize)] diff --git a/crates/ironclaw_extension_contracts/src/lib.rs b/crates/ironclaw_extension_contracts/src/lib.rs index da065db4e3f..6ad4e6d3215 100644 --- a/crates/ironclaw_extension_contracts/src/lib.rs +++ b/crates/ironclaw_extension_contracts/src/lib.rs @@ -49,6 +49,7 @@ pub mod hosted_mcp; pub mod lifecycle_id; pub mod memory; pub mod preference_target; +pub mod product_adapter_section; pub mod recipe; pub mod runtime; pub mod state; diff --git a/crates/ironclaw_extension_contracts/src/product_adapter_section.rs b/crates/ironclaw_extension_contracts/src/product_adapter_section.rs new file mode 100644 index 00000000000..653ab2075ff --- /dev/null +++ b/crates/ironclaw_extension_contracts/src/product_adapter_section.rs @@ -0,0 +1,617 @@ +//! The `[product_adapter.*]` manifest-surface schema. +//! +//! This is the neutral vocabulary half of the `ironclaw.product_adapter/v1` +//! host-API surface: what an extension **declares** in its manifest, and the +//! cross-field invariants that declaration must satisfy. It is the same shape +//! [`crate::channel`] holds for `[channel]` and [`crate::memory`] holds for +//! `[memory]` — a `Deserialize` declaration plus its validation, with no +//! manifest parsing, no section-path addressing, and no registry types. +//! +//! The *resolved projection* — pairing a resolved section with the +//! `ManifestSectionPath` it was declared at, walking the manifest's host-API +//! list, and the `HostApiManifestContract` that hooks this schema into v2 +//! manifest ingestion — is the registry's, in +//! `ironclaw_extensions::host_api::product_adapter`. That split is PROPOSAL +//! §6.1.2 (this crate: "manifest-surface descriptors", "parses no manifests") +//! against §6.8.1 (the registry: "manifest schemas … resolved + digest"). + +use std::collections::BTreeSet; + +use ironclaw_host_api::ids::ExtensionId; +use ironclaw_host_api::ingress::{IngressAuthPolicy, IngressRouteDescriptor, IngressRouteId}; +use ironclaw_host_api::product_adapter::{ + AuthRequirement, ProductAdapterCapabilities, ProductAdapterId, ProductCapabilityFlag, + ProductSurfaceKind, +}; +use serde::Deserialize; +use thiserror::Error; + +use crate::egress::{DeclaredEgressTarget, EgressCredentialHandle}; + +/// The host-API id a `[product_adapter.*]` section is declared under. +pub const PRODUCT_ADAPTER_HOST_API_ID: &str = "ironclaw.product_adapter/v1"; + +/// The manifest section-path prefix every product-adapter section shares. +pub const PRODUCT_ADAPTER_SECTION_PREFIX: &str = "product_adapter"; + +// --------------------------------------------------------------------------- +// Declared shapes +// --------------------------------------------------------------------------- + +/// A host-ingress route declared by a ProductAdapter manifest section, paired +/// with the credential handles that verify it. +/// +/// The route itself is the host-owned [`IngressRouteDescriptor`] vocabulary +/// (`ironclaw_host_api` owns route/policy validation, including the fail-closed +/// floor that a `PublicWebhook` listener must require `WebhookSignature`). That +/// descriptor deliberately carries **no** credential binding — host_api is +/// route/policy vocabulary only. The manifest layer is therefore where "which +/// credential handle verifies this route" is declared, and this module makes it +/// credential-coherent against the section's `required_credentials` +/// (see [`ProductAdapterSection`]'s validation). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostIngressRoute { + descriptor: IngressRouteDescriptor, + #[serde(default)] + credential_handles: Vec, +} + +impl HostIngressRoute { + /// The host-owned, already-validated ingress route/policy descriptor. + pub fn descriptor(&self) -> &IngressRouteDescriptor { + &self.descriptor + } + + /// Credential handles that verify this route. Every handle is guaranteed to + /// be declared in the owning section's `required_credentials`; an + /// auth-required route names at least one, and a public (no-auth) route + /// names none. + /// + /// The handle type is [`EgressCredentialHandle`] — the single credential- + /// handle newtype this crate owns. It is reused here rather than mirrored + /// into an ingress-specific type (per the type-placement rule); its + /// `Display` renders only the handle string, so no "egress" wording leaks + /// into ingress error messages. + pub fn credential_handles(&self) -> &[EgressCredentialHandle] { + &self.credential_handles + } +} + +/// The wire shape of a `[product_adapter.*]` manifest section. +/// +/// Deserialized by whoever holds the manifest TOML (the registry), then +/// [resolved](Self::resolve) into a validated [`ProductAdapterSection`]. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProductAdapterSectionDeclaration { + surface_kind: ProductSurfaceKind, + auth: DeclaredAuth, + capabilities: DeclaredCapabilities, + #[serde(default)] + required_credentials: Vec, + #[serde(default)] + egress: Vec, + #[serde(default)] + host_ingress: Vec, +} + +impl ProductAdapterSectionDeclaration { + /// Project and validate this declaration into a resolved section. + /// + /// `subsection` is the section path's tail below + /// [`PRODUCT_ADAPTER_SECTION_PREFIX`]; it is combined with `extension_id` + /// into the [`ProductAdapterId`] so that multiple product-adapter sections + /// within the same extension are distinguishable downstream. + pub fn resolve( + self, + extension_id: &ExtensionId, + subsection: &str, + ) -> Result { + let adapter_id_str = format!("{}/{}", extension_id.as_str(), subsection); + let adapter_id = ProductAdapterId::new(&adapter_id_str).map_err(|error| { + ProductAdapterSectionError::InvalidValue { + field: "adapter_id", + reason: error.to_string(), + } + })?; + let auth_requirement = self.auth.into_auth_requirement()?; + let required_credentials = self + .required_credentials + .into_iter() + .map(|c| c.handle) + .collect(); + let projected = ProductAdapterSection { + adapter_id, + surface_kind: self.surface_kind, + capabilities: ProductAdapterCapabilities::new(self.capabilities.flags), + auth_requirement, + declared_egress: self.egress, + required_credentials, + host_ingress: self.host_ingress, + }; + projected.validate()?; + Ok(projected) + } +} + +// --------------------------------------------------------------------------- +// Resolved section +// --------------------------------------------------------------------------- + +/// A validated `[product_adapter.*]` section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductAdapterSection { + adapter_id: ProductAdapterId, + surface_kind: ProductSurfaceKind, + capabilities: ProductAdapterCapabilities, + auth_requirement: AuthRequirement, + declared_egress: Vec, + required_credentials: Vec, + host_ingress: Vec, +} + +impl ProductAdapterSection { + pub fn adapter_id(&self) -> &ProductAdapterId { + &self.adapter_id + } + pub fn surface_kind(&self) -> ProductSurfaceKind { + self.surface_kind + } + pub fn capabilities(&self) -> &ProductAdapterCapabilities { + &self.capabilities + } + pub fn auth_requirement(&self) -> &AuthRequirement { + &self.auth_requirement + } + pub fn declared_egress(&self) -> &[DeclaredEgressTarget] { + &self.declared_egress + } + pub fn required_credentials(&self) -> &[EgressCredentialHandle] { + &self.required_credentials + } + + /// Host-ingress routes this ProductAdapter section declares. Each carries a + /// host-owned [`IngressRouteDescriptor`] and its verifying credential + /// handles; the serve layer projects these into mounted routes. Empty for + /// sections that declare no ingress (the common case today). + pub fn host_ingress(&self) -> &[HostIngressRoute] { + &self.host_ingress + } + + fn validate(&self) -> Result<(), ProductAdapterSectionError> { + validate_auth_requirement(&self.auth_requirement)?; + let mut required = BTreeSet::new(); + for handle in &self.required_credentials { + if !required.insert(handle.clone()) { + return Err(ProductAdapterSectionError::DuplicateCredentialHandle { + handle: handle.clone(), + }); + } + } + let mut pairs = BTreeSet::new(); + for target in &self.declared_egress { + if let Some(handle) = target.credential_handle.as_ref() + && !required.contains(handle) + { + return Err( + ProductAdapterSectionError::UndeclaredEgressCredentialHandle { + handle: handle.clone(), + }, + ); + } + if !pairs.insert((target.host.clone(), target.credential_handle.clone())) { + return Err(ProductAdapterSectionError::DuplicateEgressTarget); + } + } + // Host-ingress credential coherence, fail closed. A route's declared + // verifying credentials must line up with whether it is actually + // authenticated, and every named handle must be declared in + // `required_credentials` (mirroring the egress rule above, so ingress + // handles flow into the same declared set installation bindings are + // validated against). Route ids stay distinct within a section so a + // mounted route can be addressed unambiguously. + let mut route_ids: BTreeSet<&IngressRouteId> = BTreeSet::new(); + for route in &self.host_ingress { + let route_id = route.descriptor.route_id(); + if !route_ids.insert(route_id) { + return Err(ProductAdapterSectionError::DuplicateIngressRoute { + route_id: route_id.clone(), + }); + } + match route.descriptor.policy().auth() { + // An auth-required route with no verifying credential is a route + // nothing could authenticate — reject it. + IngressAuthPolicy::Required { .. } => { + if route.credential_handles.is_empty() { + return Err(ProductAdapterSectionError::IngressRouteMissingCredential { + route_id: route_id.clone(), + }); + } + } + // A public (no-auth) route is verified by nothing, so declaring a + // credential handle on it is incoherent and misleading — a reader + // would assume the route is authenticated by that credential. + IngressAuthPolicy::Public { .. } => { + if !route.credential_handles.is_empty() { + return Err( + ProductAdapterSectionError::PublicIngressRouteHasCredential { + route_id: route_id.clone(), + }, + ); + } + } + } + for handle in &route.credential_handles { + if !required.contains(handle) { + return Err( + ProductAdapterSectionError::UndeclaredIngressCredentialHandle { + handle: handle.clone(), + }, + ); + } + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Why a declared `[product_adapter.*]` section is not a valid section. +/// +/// Deserialization failures are not here: the caller owns the manifest text and +/// reports them in its own vocabulary. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProductAdapterSectionError { + #[error("invalid {field}: {reason}")] + InvalidValue { field: &'static str, reason: String }, + #[error("duplicate credential handle {handle}")] + DuplicateCredentialHandle { handle: EgressCredentialHandle }, + #[error("duplicate egress target")] + DuplicateEgressTarget, + #[error("egress references undeclared credential handle {handle}")] + UndeclaredEgressCredentialHandle { handle: EgressCredentialHandle }, + #[error("host-ingress route references undeclared credential handle {handle}")] + UndeclaredIngressCredentialHandle { handle: EgressCredentialHandle }, + #[error("auth-required host-ingress route {route_id} declares no verifying credential handle")] + IngressRouteMissingCredential { route_id: IngressRouteId }, + #[error( + "public host-ingress route {route_id} declares a verifying credential handle but is not authenticated" + )] + PublicIngressRouteHasCredential { route_id: IngressRouteId }, + #[error("duplicate host-ingress route {route_id}")] + DuplicateIngressRoute { route_id: IngressRouteId }, +} + +// --------------------------------------------------------------------------- +// Internal validation helpers +// --------------------------------------------------------------------------- + +fn validate_auth_requirement( + requirement: &AuthRequirement, +) -> Result<(), ProductAdapterSectionError> { + match requirement { + AuthRequirement::RequestSignature { + header_name, + timestamp_header_name, + } => { + validate_http_token("auth.header_name", header_name)?; + if let Some(t) = timestamp_header_name.as_deref() { + validate_http_token("auth.timestamp_header_name", t)?; + } + } + AuthRequirement::SharedSecretHeader { header_name } => { + validate_http_token("auth.header_name", header_name)?; + } + AuthRequirement::SessionCookie { name } => { + validate_http_token("auth.name", name)?; + } + AuthRequirement::BearerToken => {} + } + Ok(()) +} + +fn validate_http_token(field: &'static str, value: &str) -> Result<(), ProductAdapterSectionError> { + if value.is_empty() { + return Err(ProductAdapterSectionError::InvalidValue { + field, + reason: "must not be empty".to_string(), + }); + } + for c in value.chars() { + if !is_http_tchar(c) { + return Err(ProductAdapterSectionError::InvalidValue { + field, + reason: format!( + "must be an RFC 7230 token (no CTL, whitespace, or separators); got {value:?}" + ), + }); + } + } + Ok(()) +} + +fn is_http_tchar(c: char) -> bool { + matches!( + c, + '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~' + ) || c.is_ascii_alphanumeric() +} + +// --------------------------------------------------------------------------- +// Raw deserialization shapes +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeclaredCapabilities { + flags: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeclaredCredential { + handle: EgressCredentialHandle, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum DeclaredAuth { + RequestSignature { + header_name: String, + #[serde(default)] + timestamp_header_name: Option, + }, + SharedSecretHeader { + header_name: String, + }, + SessionCookie { + name: String, + }, + BearerToken, +} + +impl DeclaredAuth { + fn into_auth_requirement(self) -> Result { + let requirement = match self { + Self::RequestSignature { + header_name, + timestamp_header_name, + } => AuthRequirement::RequestSignature { + header_name, + timestamp_header_name, + }, + Self::SharedSecretHeader { header_name } => { + AuthRequirement::SharedSecretHeader { header_name } + } + Self::SessionCookie { name } => AuthRequirement::SessionCookie { name }, + Self::BearerToken => AuthRequirement::BearerToken, + }; + validate_auth_requirement(&requirement)?; + Ok(requirement) + } +} + +#[cfg(test)] +mod tests { + //! Unit coverage for host-ingress credential coherence — the novel logic + //! this schema adds on top of host_api's already-validated ingress + //! descriptor. Descriptors are built in Rust (not TOML text) so these + //! cases are robust to serde renames; the wire path is covered end-to-end + //! by the registry's `product_adapter_manifest_ingestion` suite. + use super::*; + use ironclaw_host_api::{ + action::NetworkMethod, + ingress::{ + AllowedEffectPath, AuditTraceClass, BodyLimitPolicy, CorsPolicy, IngressAuthScheme, + IngressJustification, IngressPolicy, IngressPolicyParts, IngressScopeSource, + ListenerClass, RateLimitPolicy, RateLimitScope, StreamingMode, WebSocketOriginPolicy, + }, + }; + use serde::Serialize; + use std::num::{NonZeroU32, NonZeroU64}; + + /// A fail-closed public-webhook descriptor mirroring the values a channel + /// package's events policy uses, parameterized by route id. + fn webhook_descriptor(route_id: &str) -> IngressRouteDescriptor { + let policy = IngressPolicy::new(IngressPolicyParts { + listener_class: ListenerClass::PublicWebhook, + auth: IngressAuthPolicy::Required { + schemes: vec![IngressAuthScheme::WebhookSignature], + }, + scope_source: IngressScopeSource::HostResolved, + body_limit: BodyLimitPolicy::Limited { + max_bytes: NonZeroU64::new(262_144).expect("nonzero"), + }, + rate_limit: RateLimitPolicy::Limited { + scope: RateLimitScope::Global, + max_requests: NonZeroU32::new(600).expect("nonzero"), + window_seconds: NonZeroU32::new(60).expect("nonzero"), + }, + cors: CorsPolicy::NotApplicable, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::PublicCallback, + effect_path: AllowedEffectPath::ProductSurface, + }) + .expect("policy validates"); + IngressRouteDescriptor::new( + route_id, + NetworkMethod::Post, + "/webhooks/example/updates", + policy, + ) + .expect("descriptor validates") + } + + /// A valid public (no-auth) route, mirroring the SSO login mount's policy + /// combination (LocalGateway + Public + PublicRoute + NoEffect). + fn public_descriptor(route_id: &str) -> IngressRouteDescriptor { + let policy = IngressPolicy::new(IngressPolicyParts { + listener_class: ListenerClass::LocalGateway, + auth: IngressAuthPolicy::Public { + justification: IngressJustification::new("ingress", "public test route") + .expect("justification"), + }, + scope_source: IngressScopeSource::PublicRoute, + body_limit: BodyLimitPolicy::Limited { + max_bytes: NonZeroU64::new(4096).expect("nonzero"), + }, + rate_limit: RateLimitPolicy::Limited { + scope: RateLimitScope::PerIp, + max_requests: NonZeroU32::new(60).expect("nonzero"), + window_seconds: NonZeroU32::new(60).expect("nonzero"), + }, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::PublicCallback, + effect_path: AllowedEffectPath::NoEffect, + }) + .expect("public policy validates"); + IngressRouteDescriptor::new(route_id, NetworkMethod::Post, "/public/callback", policy) + .expect("descriptor validates") + } + + #[derive(Serialize)] + struct RouteFixture { + descriptor: IngressRouteDescriptor, + credential_handles: Vec, + } + + /// Build a ProductAdapter section declaration with a valid base and the + /// given host-ingress routes, then run it through the real resolution. + fn project( + routes: Vec, + ) -> Result { + let mut value: toml::Value = toml::from_str( + r#" +surface_kind = "external_channel" +[auth] +kind = "shared_secret_header" +header_name = "X-Example-Secret-Token" +[capabilities] +flags = ["inbound_messages"] +[[required_credentials]] +handle = "example_bot_token" +"#, + ) + .expect("base section parses"); + let host_ingress = toml::Value::try_from(routes).expect("routes serialize"); + value + .as_table_mut() + .expect("section is a table") + .insert("host_ingress".to_string(), host_ingress); + + let declaration: ProductAdapterSectionDeclaration = + value.try_into().expect("declaration deserializes"); + let extension_id = ExtensionId::new("example-v2").expect("extension id"); + declaration.resolve(&extension_id, "inbound") + } + + fn route(route_id: &str, credential_handles: &[&str]) -> RouteFixture { + RouteFixture { + descriptor: webhook_descriptor(route_id), + credential_handles: credential_handles.iter().map(|h| h.to_string()).collect(), + } + } + + #[test] + fn host_ingress_route_projects_descriptor_and_handles() { + let section = project(vec![route("example.updates", &["example_bot_token"])]) + .expect("valid section projects"); + assert_eq!(section.host_ingress().len(), 1); + let projected = §ion.host_ingress()[0]; + assert_eq!( + projected.descriptor().route_id().as_str(), + "example.updates" + ); + assert_eq!( + projected.descriptor().route_pattern().as_str(), + "/webhooks/example/updates" + ); + assert_eq!(projected.credential_handles().len(), 1); + assert_eq!( + projected.credential_handles()[0].as_str(), + "example_bot_token" + ); + } + + #[test] + fn host_ingress_undeclared_credential_handle_rejected() { + let err = project(vec![route("example.updates", &["not_declared_token"])]) + .expect_err("undeclared handle must reject"); + assert!( + matches!( + err, + ProductAdapterSectionError::UndeclaredIngressCredentialHandle { .. } + ), + "got {err:?}" + ); + } + + #[test] + fn host_ingress_auth_required_route_needs_credential() { + // Fail closed: an auth-required route with no verifying credential + // handle must reject, not mount a route nothing can authenticate. + let err = project(vec![route("example.updates", &[])]) + .expect_err("auth-required route without a credential must reject"); + assert!( + matches!( + err, + ProductAdapterSectionError::IngressRouteMissingCredential { .. } + ), + "got {err:?}" + ); + } + + #[test] + fn host_ingress_duplicate_route_id_rejected() { + let err = project(vec![ + route("example.updates", &["example_bot_token"]), + route("example.updates", &["example_bot_token"]), + ]) + .expect_err("duplicate route id must reject"); + assert!( + matches!( + err, + ProductAdapterSectionError::DuplicateIngressRoute { .. } + ), + "got {err:?}" + ); + } + + #[test] + fn host_ingress_public_route_must_not_declare_credentials() { + // Fail closed on the dual of the auth-required rule: a public (no-auth) + // route is verified by nothing, so declaring a credential handle on it + // is incoherent and would mislead a reader into assuming it is + // authenticated. + let err = project(vec![RouteFixture { + descriptor: public_descriptor("public.callback"), + credential_handles: vec!["example_bot_token".to_string()], + }]) + .expect_err("public route with a credential handle must reject"); + assert!( + matches!( + err, + ProductAdapterSectionError::PublicIngressRouteHasCredential { .. } + ), + "got {err:?}" + ); + } + + #[test] + fn host_ingress_public_route_without_credentials_projects() { + // The complement: a public route that declares no credentials is valid. + let section = project(vec![RouteFixture { + descriptor: public_descriptor("public.callback"), + credential_handles: vec![], + }]) + .expect("public route with no credentials projects"); + assert_eq!(section.host_ingress().len(), 1); + } +} diff --git a/crates/ironclaw_extension_host/src/available_extensions.rs b/crates/ironclaw_extension_host/src/available_extensions.rs index ad9349c371b..aa31b1d9c1e 100644 --- a/crates/ironclaw_extension_host/src/available_extensions.rs +++ b/crates/ironclaw_extension_host/src/available_extensions.rs @@ -937,15 +937,14 @@ fn channel_directions_from_manifest_record( })); } // Manifest v2: derive from the product-adapter section capability flags. - let sections = - ironclaw_product::adapter_registry::product_adapter_sections(record).map_err(|error| { - ProductOperationFailure::InvalidBindingRequest { - reason: format!("{label} ProductAdapter manifest projection is invalid: {error}"), - } + let sections = ironclaw_extensions::host_api::product_adapter::product_adapter_sections(record) + .map_err(|error| ProductOperationFailure::InvalidBindingRequest { + reason: format!("{label} ProductAdapter manifest projection is invalid: {error}"), })?; let mut directions: Option = None; for section in sections .iter() + .map(|section| section.resolved()) .filter(|section| section.surface_kind() == ProductSurfaceKind::ExternalChannel) { let flags = section.capabilities(); diff --git a/crates/ironclaw_extension_host/src/channel_connection.rs b/crates/ironclaw_extension_host/src/channel_connection.rs index 31f57ec78cb..1eb3a856b47 100644 --- a/crates/ironclaw_extension_host/src/channel_connection.rs +++ b/crates/ironclaw_extension_host/src/channel_connection.rs @@ -21,14 +21,14 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_auth::{ - AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountStatus, SecretCleanupAction, - SecretCleanupReport, SecretCleanupRequest, + AuthProductScope, AuthProviderId, AuthSurface, ChannelAuthAccountState, + ChannelConnectionService, CredentialAccountStatus, SecretCleanupAction, SecretCleanupReport, + SecretCleanupRequest, }; use ironclaw_host_api::{ ids::{ExtensionId, InvocationId, TenantId}, resource::ResourceScope, }; -use ironclaw_product::{ChannelAuthAccountState, ChannelConnectionService}; use ironclaw_product_contracts::surface::{ProductSurfaceCaller, ProductSurfaceError}; use ironclaw_extension_contracts::channel_identity::{ diff --git a/crates/ironclaw_extension_host/src/channel_host.rs b/crates/ironclaw_extension_host/src/channel_host.rs index 00fcfdfc0d6..73eb65653bb 100644 --- a/crates/ironclaw_extension_host/src/channel_host.rs +++ b/crates/ironclaw_extension_host/src/channel_host.rs @@ -45,15 +45,16 @@ use ironclaw_host_api::{ use ironclaw_outbound::{CommunicationPreferenceRepository, DeliveredGateRouteStore}; use ironclaw_product::ProjectFilesystemReader; use ironclaw_product::{ - ApprovalInteractionService, AuthInteractionService, BlockedAuthFlowCanceller, - ConversationBindingService, DefaultInboundTurnService, DefaultProductSurface, - DeliveryCoordinator, IdempotencyLedger, ProductActorUserResolutionRequest, - ProductActorUserResolver, ProductInstallationKey, ProductInstallationScope, - ProductSurfaceFailure, RebornFilesystemIdempotencyLedger, ResolvedProductActorUser, - RunDeliveryObserver, RunDeliveryServices, RunDeliverySettings, - StaticProductInstallationResolver, + ApprovalInteractionService, AuthInteractionService, ConversationBindingService, + DefaultInboundTurnService, DefaultProductSurface, DeliveryCoordinator, IdempotencyLedger, + ProductInstallationKey, ProductInstallationScope, ProductSurfaceFailure, + RebornFilesystemIdempotencyLedger, RunDeliveryObserver, RunDeliveryServices, + RunDeliverySettings, StaticProductInstallationResolver, }; use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::actor_identity::{ + ProductActorUserResolutionRequest, ProductActorUserResolver, ResolvedProductActorUser, +}; use ironclaw_product_contracts::inbound::{ProductInboundAck, ProductInboundEnvelope}; use ironclaw_product_contracts::prompt_source::{ ApprovalPromptContextSource, BlockedAuthPromptSource, @@ -269,7 +270,7 @@ pub struct ChannelHostDeliveryDeps { pub communication_preferences: Arc, pub approval_context: Option>, pub blocked_auth_prompts: Option>, - pub auth_flow_cancel: Option>, + pub auth_flow_cancel: Option>, pub settings: RunDeliverySettings, } @@ -498,7 +499,11 @@ impl GenericChannelHostAssembly { /// Register one extension's vendor extras, then re-reconcile the /// extension against the current snapshot so the remaining extras apply /// to the next build. - pub async fn register_extras(&self, extension_id: &str, extras: ChannelExtras) { + pub async fn register_extras( + &self, + extension_id: &ironclaw_host_api::ids::ExtensionId, + extras: ChannelExtras, + ) { let ChannelExtras { preference_target_codec, subject_route_resolver, @@ -506,7 +511,7 @@ impl GenericChannelHostAssembly { } = extras; if let Ok(mut stored) = self.extras.lock() { stored.insert( - extension_id.to_string(), + extension_id.as_str().to_string(), StoredChannelExtras { preference_target_codec, subject_route_resolver, @@ -515,7 +520,7 @@ impl GenericChannelHostAssembly { ); } let mut reconciled = self.reconciled.lock().await; - reconciled.remove(extension_id); + reconciled.remove(extension_id.as_str()); drop(reconciled); self.reconcile(self.deps.watch.current()).await; } @@ -1187,7 +1192,10 @@ impl ProductActorUserResolver for OperatorActorUserResolver { async fn resolve_product_actor_user( &self, _request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result< + Option, + ironclaw_product_contracts::error::ProductOperationFailure, + > { Ok(Some(ResolvedProductActorUser::new( self.operator_user_id.clone(), ))) diff --git a/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs b/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs index c026002b438..5ba16e24113 100644 --- a/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs +++ b/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs @@ -6,7 +6,7 @@ use ironclaw_extension_contracts::auth_prompt::AuthPromptChallengeKind; use ironclaw_host_api::ids::{AgentId, ProjectId, UserId}; use ironclaw_turns::{TurnRunId, TurnScope}; -use ironclaw_product::{AuthChallengeProvider, AuthChallengeView}; +use ironclaw_auth::product_prompt::{AuthChallengeProvider, AuthChallengeView}; use super::{AGENT, AUTH_GATE, PROJECT, TENANT, USER}; diff --git a/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs b/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs index 5ce49de448b..57afc900ecb 100644 --- a/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs +++ b/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs @@ -110,6 +110,7 @@ use crate::extension_ingress::{ extension_ingress_route_mount, }; use crate::run_delivery_ports::ProductAuthBlockedAuthPromptSource; +use ironclaw_auth::product_prompt::AuthChallengeProvider; use ironclaw_extension_host::{ AdminConfigurationService, ChannelConfigReactivation, ChannelConfigService, FilesystemAdminConfigurationStore, @@ -117,7 +118,6 @@ use ironclaw_extension_host::{ use ironclaw_extension_host::{IngressReplyContextSource, SnapshotChannelDeliveryResolver}; use ironclaw_host_api::user_identity::{RebornUserIdentityLookup, RebornUserIdentityLookupError}; use ironclaw_host_ingress::PublicRouteMount; -use ironclaw_product::AuthChallengeProvider; use ironclaw_product_contracts::prompt_source::BlockedAuthPromptSource; #[path = "e2e_auth_challenge.rs"] @@ -609,7 +609,7 @@ async fn build_harness_with_options(options: HarnessOptions) -> Harness { // them: the preference-target codec — no storage-root override. assembly .register_extras( - "slack", + &ironclaw_host_api::ids::ExtensionId::from_trusted("slack".to_string()), ChannelExtras { preference_target_codec: Some(Arc::new(SlackPreferenceTargetCodec)), subject_route_resolver: None, diff --git a/crates/ironclaw_extension_host/src/channel_identity_store.rs b/crates/ironclaw_extension_host/src/channel_identity_store.rs index f192930c794..67177c8e73b 100644 --- a/crates/ironclaw_extension_host/src/channel_identity_store.rs +++ b/crates/ironclaw_extension_host/src/channel_identity_store.rs @@ -11,6 +11,30 @@ //! bindings. The index is advisory: a missing marker only falls back to the //! full scan, and readers verify the primary record before trusting a //! marker, so a stale marker can never be a false positive. +//! +//! # Not the principal identity store +//! +//! There are two durable external-identity stores in the Reborn stack and this +//! is the *binding* one. It answers "which already-authenticated Reborn user is +//! this channel actor?" and it **never mints a user**. Minting, the user +//! profile, and the verified-email index belong to `ironclaw_reborn_identity`, +//! which keys on `(tenant, surface_kind, provider_kind, provider_instance, +//! subject)` and owns `resolve_or_create`. Neither store subsumes the other and +//! neither is a migration target for the other; see +//! `crates/ironclaw_reborn_identity/CONTRACT.md`, "Two external-identity +//! stores", for the full split. +//! +//! Two consequences worth knowing before changing this file: +//! +//! * **The ports this implements stay in `ironclaw_host_api::user_identity`.** +//! Relocating them into `ironclaw_reborn_identity` was proposed and refuted +//! (2026-08-04): that crate implements none of them, and because it depends on +//! `ironclaw_host_api` rather than the reverse, the move would force *this* +//! crate to take a new dependency purely to name a port it implements. +//! * **This store is fixed to one tenant at construction**, where the principal +//! store takes the tenant per call. That is a deliberate difference, not an +//! oversight — but it is the shape to revisit if multi-tenant channel binding +//! is ever required. use std::{ collections::HashMap, diff --git a/crates/ironclaw_extension_host/src/channel_lifecycle.rs b/crates/ironclaw_extension_host/src/channel_lifecycle.rs index 4d58902e35a..6fbc08175f7 100644 --- a/crates/ironclaw_extension_host/src/channel_lifecycle.rs +++ b/crates/ironclaw_extension_host/src/channel_lifecycle.rs @@ -1,6 +1,6 @@ +use ironclaw_extension_contracts::product_adapter_section::PRODUCT_ADAPTER_HOST_API_ID; use ironclaw_extensions::ExtensionPackage; use ironclaw_host_api::capability::RuntimeCredentialAccountSetup; -use ironclaw_product::adapter_registry::PRODUCT_ADAPTER_HOST_API_ID; use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; use ironclaw_product_contracts::package_lifecycle::{ ChannelConnectStrategy as RebornChannelConnectStrategy, ChannelConnectionRequirement, diff --git a/crates/ironclaw_extension_host/src/channel_pairing/tests.rs b/crates/ironclaw_extension_host/src/channel_pairing/tests.rs index 458ac611171..215849ce919 100644 --- a/crates/ironclaw_extension_host/src/channel_pairing/tests.rs +++ b/crates/ironclaw_extension_host/src/channel_pairing/tests.rs @@ -279,7 +279,7 @@ impl ConversationActorPairingService for RecordingActorPairings { _adapter_installation_id: ironclaw_conversations::AdapterInstallationId, _external_actor_ref: ExternalActorRef, _user_id: UserId, - _epoch: ironclaw_conversations::ExternalActorBindingEpoch, + _epoch: ironclaw_extension_contracts::external::ExternalActorBindingEpoch, ) -> Result<(), InboundTurnError> { Ok(()) } diff --git a/crates/ironclaw_extension_host/src/channel_subject_routes.rs b/crates/ironclaw_extension_host/src/channel_subject_routes.rs index b00071be585..7bd6fdbabda 100644 --- a/crates/ironclaw_extension_host/src/channel_subject_routes.rs +++ b/crates/ironclaw_extension_host/src/channel_subject_routes.rs @@ -351,7 +351,7 @@ supports_threads = false -> Result { let mut registry = ironclaw_extensions::default_host_api_contract_registry()?; - ironclaw_product::adapter_registry::register_product_adapter_host_api_contract( + ironclaw_extensions::host_api::product_adapter::register_product_adapter_host_api_contract( &mut registry, ) .map_err(|error| ironclaw_extensions::ManifestV2Error::Invalid { diff --git a/crates/ironclaw_extension_host/src/generic_host.rs b/crates/ironclaw_extension_host/src/generic_host.rs index d27e1bc6bbd..b4e28146a26 100644 --- a/crates/ironclaw_extension_host/src/generic_host.rs +++ b/crates/ironclaw_extension_host/src/generic_host.rs @@ -40,6 +40,7 @@ use ironclaw_extensions::{ ExtensionInstallationError, ExtensionInstallationStorePort, ExtensionManifest, ExtensionPackage, ResolvedExtensionManifest, }; +use ironclaw_host_api::ids::ExtensionId; use ironclaw_host_api::path::VirtualPath; use ironclaw_host_runtime::{ExtensionLaneToolBinder, ExtensionToolBindError}; use ironclaw_resources::ResourceGovernor; @@ -63,7 +64,7 @@ pub struct GenericExtensionHost { pub struct GenericExtensionHostParams { pub binder: ExtensionLaneToolBinder, pub native_factories: Vec>, - pub channel_adapters: Vec<(String, Arc)>, + pub channel_adapters: Vec<(ExtensionId, Arc)>, pub installation_store: Arc, pub boot_installations: Vec, pub governor: Arc, @@ -293,7 +294,7 @@ struct CompositionExtensionLoader { /// extensions whose TOOLS load via the runtime lanes (P4 ingress cutover). /// An extension without an entry binds the transitional bridge until its /// adapter lands. - channel_adapters: HashMap>, + channel_adapters: HashMap>, governor: Arc, installation_store: Arc, } @@ -381,7 +382,7 @@ impl ExtensionLoader for CompositionExtensionLoader { // rule satisfied until the adapter lands. channel: declares_channel.then(|| { self.channel_adapters - .get(&ctx.extension_id) + .get(&extension_id) .cloned() .unwrap_or_else(|| Arc::new(HostServedChannelBridge) as Arc) }), diff --git a/crates/ironclaw_extension_host/src/host_api_contracts.rs b/crates/ironclaw_extension_host/src/host_api_contracts.rs index c5a61058fe4..583b3b0ab25 100644 --- a/crates/ironclaw_extension_host/src/host_api_contracts.rs +++ b/crates/ironclaw_extension_host/src/host_api_contracts.rs @@ -3,8 +3,10 @@ use ironclaw_extensions::{HostApiContractRegistry, ManifestV2Error}; pub fn product_extension_host_api_contract_registry() -> Result { let mut registry = ironclaw_extensions::default_host_api_contract_registry()?; - ironclaw_product::adapter_registry::register_product_adapter_host_api_contract(&mut registry) - .map_err(|error| ManifestV2Error::Invalid { + ironclaw_extensions::host_api::product_adapter::register_product_adapter_host_api_contract( + &mut registry, + ) + .map_err(|error| ManifestV2Error::Invalid { reason: format!("product adapter host API contract registration failed: {error}"), })?; Ok(registry) diff --git a/crates/ironclaw_extension_host/src/product_lifecycle.rs b/crates/ironclaw_extension_host/src/product_lifecycle.rs index 90fc0ab8c1b..48ddb3c88cc 100644 --- a/crates/ironclaw_extension_host/src/product_lifecycle.rs +++ b/crates/ironclaw_extension_host/src/product_lifecycle.rs @@ -5,6 +5,7 @@ use std::{ }; use async_trait::async_trait; +use ironclaw_auth::ChannelConnectionService; use ironclaw_auth::{ AuthProductScope, AuthProviderId, AuthSurface, SecretCleanupAction, SecretCleanupReport, SecretCleanupRequest, @@ -23,9 +24,8 @@ use ironclaw_host_api::{ ids::{ExtensionId, UserId, VendorId}, resource::ResourceScope, }; -use ironclaw_product::{ChannelConnectionService, ExtensionAccountSetupRegistry}; use ironclaw_product_contracts::account_setup::{ - ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, + ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, ExtensionAccountSetupReader, }; use ironclaw_product_contracts::error::ProductOperationFailure; use ironclaw_product_contracts::package_lifecycle::ChannelConnectStrategy as RebornChannelConnectStrategy; @@ -195,7 +195,9 @@ pub struct ExtensionLifecycleManager { /// connection-requirement overrides). Descriptors are declared during /// composition; the activation success path consults it and the pairing /// seam extends it. - account_setups: ExtensionAccountSetupRegistry, + /// `None` behaves exactly as an empty registry: no descriptor, no missing + /// requirement (`ExtensionAccountSetupReader`'s doc pins the equivalence). + account_setups: Option>, /// Static per-provider instance-config readiness map. Opt-in, defaults /// empty via `new` — a third readiness axis alongside `account_setups` /// (per-user) and the package-level @@ -356,7 +358,7 @@ impl ExtensionLifecycleManager { registry_install_operations: Arc::new(std::sync::Mutex::new(BTreeMap::new())), tenant_operator_user_id, removal_cleanup: Arc::new(ExtensionRemovalCleanupRegistry::empty()), - account_setups: ExtensionAccountSetupRegistry::default(), + account_setups: None, channel_disconnect_slot: Arc::new(std::sync::OnceLock::new()), provider_instance_readiness: std::collections::BTreeMap::new(), } @@ -553,9 +555,9 @@ impl ExtensionLifecycleManager { pub fn with_account_setup_registry( mut self, - account_setups: ExtensionAccountSetupRegistry, + account_setups: Arc, ) -> Self { - self.account_setups = account_setups; + self.account_setups = Some(account_setups); self } @@ -818,11 +820,11 @@ impl ExtensionLifecycleManager { ensure_caller_may_operate(&installation, caller)?; let package = self.lifecycle_package(&extension_id).await?; let mut requirements = package_runtime_credential_auth_requirements(&package); - if let Some(requirement) = self - .account_setups - .missing_requirement(&extension_id, caller) - .await - .map_err(map_account_setup_error)? + if let Some(setups) = self.account_setups.as_ref() + && let Some(requirement) = setups + .missing_requirement(&extension_id, caller) + .await + .map_err(map_account_setup_error)? { requirements.push(requirement); } @@ -1463,7 +1465,9 @@ impl ExtensionLifecycleManager { return Ok(activation_success_response( package_ref, &active_package, - self.account_setups.descriptor(extension_id), + self.account_setups + .as_ref() + .and_then(|setups| setups.descriptor(extension_id)), )); } self.enable_lifecycle_package(extension_id).await?; @@ -1546,7 +1550,11 @@ impl ExtensionLifecycleManager { let visible_capability_ids = package_visible_capability_ids(&active_package); let account_setup = ironclaw_host_api::ids::ExtensionId::new(package_ref.id.as_str()) .ok() - .and_then(|id| self.account_setups.descriptor(&id)); + .and_then(|id| { + self.account_setups + .as_ref() + .and_then(|setups| setups.descriptor(&id)) + }); let message = activation_success_message( &package_ref, &active_package, diff --git a/crates/ironclaw_extension_host/src/provider_identity.rs b/crates/ironclaw_extension_host/src/provider_identity.rs index 9b9b669dfa6..8d68beb82f0 100644 --- a/crates/ironclaw_extension_host/src/provider_identity.rs +++ b/crates/ironclaw_extension_host/src/provider_identity.rs @@ -21,10 +21,10 @@ use ironclaw_host_api::{ ids::UserId, user_identity::{RebornUserIdentityLookup, installation_scoped_provider_user_id}, }; -use ironclaw_product::{ - ProductActorUserResolutionRequest, ProductActorUserResolver, ProductSurfaceFailure, - ResolvedProductActorUser, +use ironclaw_product_contracts::actor_identity::{ + ProductActorUserResolutionRequest, ProductActorUserResolver, ResolvedProductActorUser, }; +use ironclaw_product_contracts::error::ProductOperationFailure; // Positive resolutions only: a revoked binding may keep resolving for up to // this window, but an unbound actor is never cached, so connecting takes @@ -85,9 +85,12 @@ impl ProviderIdentityActorResolver { } } - fn cached_user(&self, provider_user_id: &str) -> Result, ProductSurfaceFailure> { + fn cached_user( + &self, + provider_user_id: &str, + ) -> Result, ProductOperationFailure> { let mut cache = self.resolved_user_cache.lock().map_err(|_| { - ProductSurfaceFailure::BindingResolutionFailed { + ProductOperationFailure::BindingResolutionFailed { reason: "provider identity cache lock poisoned".into(), } })?; @@ -105,10 +108,10 @@ impl ProviderIdentityActorResolver { &self, provider_user_id: String, user_id: UserId, - ) -> Result<(), ProductSurfaceFailure> { + ) -> Result<(), ProductOperationFailure> { self.resolved_user_cache .lock() - .map_err(|_| ProductSurfaceFailure::BindingResolutionFailed { + .map_err(|_| ProductOperationFailure::BindingResolutionFailed { reason: "provider identity cache lock poisoned".into(), })? .insert( @@ -142,11 +145,11 @@ impl ProviderIdentityActorResolver { async fn lookup_user( &self, provider_user_id: &str, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { self.lookup .resolve_user_identity(&self.provider, provider_user_id) .await - .map_err(|error| ProductSurfaceFailure::BindingResolutionFailed { + .map_err(|error| ProductOperationFailure::BindingResolutionFailed { reason: error.to_string(), }) } @@ -174,7 +177,7 @@ impl ProductActorUserResolver for ProviderIdentityActorResolver { async fn resolve_product_actor_user( &self, request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { let Some(provider_user_id) = self.provider_user_id_for_request(&request) else { return Ok(None); }; @@ -192,7 +195,7 @@ impl ProductActorUserResolver for ProviderIdentityActorResolver { &self, request: &ProductActorUserResolutionRequest, expected: &ResolvedProductActorUser, - ) -> Result { + ) -> Result { let Some(provider_user_id) = self.provider_user_id_for_request(request) else { return Ok(false); }; @@ -310,7 +313,7 @@ mod tests { assert!(matches!( err, - ProductSurfaceFailure::BindingResolutionFailed { .. } + ProductOperationFailure::BindingResolutionFailed { .. } )); } diff --git a/crates/ironclaw_extension_host/src/run_delivery_ports.rs b/crates/ironclaw_extension_host/src/run_delivery_ports.rs index 36fb0537b41..023ec9d4454 100644 --- a/crates/ironclaw_extension_host/src/run_delivery_ports.rs +++ b/crates/ironclaw_extension_host/src/run_delivery_ports.rs @@ -1,28 +1,31 @@ -//! Composition implementations of the generic run-delivery ports -//! (`ironclaw_product::run_delivery`): approval-gate context from -//! the projection layer, blocked-auth prompt views from the product-auth +//! Host implementations of the generic run-delivery ports +//! (`ironclaw_product_contracts::prompt_source`): approval-gate context from +//! the approval request store, blocked-auth prompt views from the product-auth //! engine, and the auth-flow cancel bridge. All delivery *semantics* live in -//! the generic components; these adapters only surface composition-owned -//! read models. +//! the generic components; these adapters only surface host-owned read models. use std::sync::Arc; use async_trait::async_trait; +use ironclaw_auth::product_prompt::{ + AuthChallengeProvider, AuthChallengeView, PairingAuthChallengeView, + auth_prompt_view_for_blocked_auth, +}; use ironclaw_auth::{AuthProductError, AuthProviderId}; use ironclaw_extension_contracts::auth_prompt::AuthPromptView; use ironclaw_host_api::product_adapter_error::ProductAdapterError; use ironclaw_host_api::turn::{TurnGateRef, TurnScope}; use ironclaw_host_api::{capability::RuntimeCredentialAccountSetup, ids::UserId}; -use ironclaw_product::{AuthChallengeProvider, AuthChallengeView, PairingAuthChallengeView}; +use ironclaw_product_contracts::approval_prompt::{ + approval_prompt_context_for_request, approval_prompt_lookup_scope, + approval_request_id_from_gate_ref, +}; use ironclaw_product_contracts::outbound::ApprovalPromptContextView; use ironclaw_product_contracts::prompt_source::{ - ApprovalPromptContextSource, BlockedAuthPromptSource, + ApprovalPromptContextSource, BlockedAuthPromptRequest, BlockedAuthPromptSource, }; -use ironclaw_product::auth_prompt_view_for_blocked_auth; - use crate::channel_pairing::ChannelPairingRegistry; -use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; /// One recipe-driven challenge materializer for every product surface. /// Product auth owns OAuth/manual challenges; the canonical channel-pairing @@ -127,6 +130,13 @@ impl AuthChallengeProvider for RecipeAuthChallengeProvider { /// Approval-gate context over the shared projection read model — the same /// source the WebUI gate projection renders from. +/// +/// The store read is here because the store is here +/// (`ironclaw_approvals::ApprovalRequestStorePort`); the gate-ref parse, the +/// lookup scope, and the request→view projection are the *shared* half and live +/// in `ironclaw_product_contracts::approval_prompt`, so this and product's +/// `projection::approval_prompt_context_view` render from one definition +/// instead of this crate reaching up into product for it. pub struct ProjectionApprovalPromptContextSource { approval_requests: Arc, } @@ -145,13 +155,19 @@ impl ApprovalPromptContextSource for ProjectionApprovalPromptContextSource { owner_user_id: &UserId, scope: &TurnScope, ) -> Option { - ironclaw_product::projection::approval_prompt_context_view( - Some(self.approval_requests.as_ref()), - gate_ref, - owner_user_id, - scope, - ) - .await + let request_id = approval_request_id_from_gate_ref(gate_ref)?; + let resource_scope = approval_prompt_lookup_scope(scope, owner_user_id); + match self + .approval_requests + .get(&resource_scope, request_id) + .await + { + Ok(Some(record)) => approval_prompt_context_for_request(&record.request), + // silent-ok: the same documented best-effort degradation product's + // delivery-prompt path applies — a missing or unreadable request + // renders the generic prompt rather than failing the delivery. + Ok(None) | Err(_) => None, + } } } diff --git a/crates/ironclaw_extensions/AGENTS.md b/crates/ironclaw_extensions/AGENTS.md index bd0c15aa5bc..27a107365f5 100644 --- a/crates/ironclaw_extensions/AGENTS.md +++ b/crates/ironclaw_extensions/AGENTS.md @@ -16,7 +16,8 @@ - Manifest discovery/validation and asset-path containment: `ExtensionError`, `ExtensionAssetPath` (`lib.rs`); the in-memory `ExtensionRegistry` (`registry`). - Lifecycle: `ExtensionLifecycleEvent`, `ExtensionLifecycleEventSink`, `ExtensionLifecycleService` (`lifecycle`). - The v2 manifest schema (`v2`): `ExtensionManifestV2`, `CapabilityDeclV2`, `ExtensionRuntimeV2`, `ManifestSource`, `CapabilityVisibility`, `ManifestV2Error`, and the schema-version/size constants. -- The host-API manifest contract projection (`v2`): `HostApiContractRegistry`, `HostApiManifestContract`, `HostApiRefV2`, `HostApiManifestProjection`; plus the capability-provider host-API contract (`host_api/capability_provider`) and the **default registry** that enumerates it, `default_host_api_contract_registry` (`host_api/mod`, moved down from `ironclaw_host_runtime` in WS3 row 3, PROPOSAL §6.5.9). A new built-in manifest contract is registered *there*, beside the contracts it names — not in a kernel caller. +- The host-API manifest contract projection (`v2`): `HostApiContractRegistry`, `HostApiManifestContract`, `HostApiRefV2`, `HostApiManifestProjection`; plus the built-in host-API contracts (`host_api/capability_provider`, `host_api/product_adapter`) and the **default registry** that enumerates the capability-provider one, `default_host_api_contract_registry` (`host_api/mod`, moved down from `ironclaw_host_runtime` in WS3 row 3, PROPOSAL §6.5.9). A new built-in manifest contract is registered *there*, beside the contracts it names — not in a kernel caller. +- `host_api/product_adapter` (arrived with WS5 from `ironclaw_product::adapter_registry`, PROPOSAL §6.8.1): the `ironclaw.product_adapter/v1` contract, `parse_product_adapter_manifest_record`/`product_adapter_sections`, the raw-TOML inline-secret guard, and `ProductAdapterHostApiSection` — a resolved section paired with the `ManifestSectionPath` it was declared at. The declared section **schema** is not here: it is `ironclaw_extension_contracts::product_adapter_section` (§6.1.2), the same split `[channel]` already has. This module is reached at `ironclaw_extensions::host_api::product_adapter::…` and is deliberately **not** re-exported from the crate root — §11.2.4's one-import-path rule. - Crate-local public API, tests, and fixtures needed to prove that ownership. ## Do Not Move In Here diff --git a/crates/ironclaw_extensions/src/host_api/mod.rs b/crates/ironclaw_extensions/src/host_api/mod.rs index 599001392a2..bf9f51bc04d 100644 --- a/crates/ironclaw_extensions/src/host_api/mod.rs +++ b/crates/ironclaw_extensions/src/host_api/mod.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use crate::v2::{HostApiContractRegistry, ManifestV2Error}; pub mod capability_provider; +pub mod product_adapter; /// Build the default set of Extension Manifest v2 host API contracts: every /// contract this module owns, in one [`HostApiContractRegistry`]. diff --git a/crates/ironclaw_extensions/src/host_api/product_adapter.rs b/crates/ironclaw_extensions/src/host_api/product_adapter.rs new file mode 100644 index 00000000000..8eb05a3d340 --- /dev/null +++ b/crates/ironclaw_extensions/src/host_api/product_adapter.rs @@ -0,0 +1,438 @@ +//! The `ironclaw.product_adapter/v1` host-API manifest contract and its +//! resolved projection. +//! +//! The declared section *schema* — what an extension writes under +//! `[product_adapter.*]`, and the cross-field invariants it must satisfy — is +//! `ironclaw_extension_contracts::product_adapter_section` (§6.1.2: this is the +//! neutral host↔extension membrane vocabulary, and that crate parses no +//! manifests). What lives here is the registry's half (§6.8.1): hooking that +//! schema into v2 manifest ingestion, the raw-TOML guards that run before +//! deserialization, and pairing each resolved section with the +//! [`ManifestSectionPath`] it was declared at. +//! +//! The old registry runtime projection (`ProductAdapterRuntimeEntry` and its +//! store scan) was never the production path and was deleted by the +//! extension-runtime P2 dispatch cutover; the active snapshot is the +//! dispatch-time source of truth. + +use std::sync::Arc; + +use ironclaw_extension_contracts::product_adapter_section::{ + PRODUCT_ADAPTER_HOST_API_ID, PRODUCT_ADAPTER_SECTION_PREFIX, ProductAdapterSection, + ProductAdapterSectionDeclaration, ProductAdapterSectionError, +}; +use ironclaw_extension_contracts::surface::CapabilitySurfaceKind; +use ironclaw_host_api::product_adapter::ProductSurfaceKind; +use ironclaw_host_api::{host_port::HostPortCatalog, ids::ExtensionId}; +use thiserror::Error; + +use crate::installations::{ExtensionInstallationError, ExtensionManifestRecord, ManifestHash}; +use crate::resolved::PackageRootBinding; +use crate::v2::{ + ExtensionManifestV2, HostApiContractRegistry, HostApiId, HostApiManifestContext, + HostApiManifestContract, HostApiManifestProjection, HostApiMultiplicity, HostApiRefV2, + HostApiSectionError, ManifestSectionPath, ManifestSource, ManifestV2Error, +}; + +/// Parse an extension manifest with the ProductAdapter host-API contract +/// registered, then project its product-adapter sections to prove they resolve. +pub fn parse_product_adapter_manifest_record( + raw_toml: impl Into, + source: ManifestSource, + host_port_catalog: &HostPortCatalog, + manifest_hash: Option, +) -> Result { + let mut contracts = HostApiContractRegistry::new(); + register_product_adapter_host_api_contract(&mut contracts)?; + let record = ExtensionManifestRecord::from_toml_with_root_binding( + raw_toml, + source, + host_port_catalog, + manifest_hash, + &contracts, + // Contract-projection helper: no package root is materialized here. + PackageRootBinding::FabricateOnLoad, + ) + .map_err(|error| match error { + ExtensionInstallationError::Manifest(error) => RegistryError::Manifest(error), + other => RegistryError::Installation(other), + })?; + product_adapter_sections(&record)?; + Ok(record) +} + +/// Every `[product_adapter.*]` section this manifest declares, resolved. +pub fn product_adapter_sections( + record: &ExtensionManifestRecord, +) -> Result, RegistryError> { + project_product_adapter_sections(record.raw_toml(), record.manifest()) +} + +/// A resolved product-adapter section paired with the manifest section path it +/// was declared at. +/// +/// The section path is the registry's vocabulary (v2 manifest grammar), which +/// is why this type lives here and the resolved section it wraps lives in +/// `ironclaw_extension_contracts`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductAdapterHostApiSection { + section: ManifestSectionPath, + resolved: ProductAdapterSection, +} + +impl ProductAdapterHostApiSection { + fn from_value( + extension_id: &ExtensionId, + section: ManifestSectionPath, + value: toml::Value, + ) -> Result { + reject_inline_secret_material_value(section.as_str(), &value)?; + let declaration: ProductAdapterSectionDeclaration = + value.try_into().map_err(|error: toml::de::Error| { + RegistryError::ManifestSectionParse { + section: section.clone(), + reason: error.to_string(), + } + })?; + // Derive adapter_id from the extension id and section subsection name + // so that multiple product-adapter sections within the same extension + // are distinguishable downstream. + let subsection = section + .as_str() + .strip_prefix(PRODUCT_ADAPTER_SECTION_PREFIX) + .and_then(|rest| rest.strip_prefix('.')) + .unwrap_or("default"); + let resolved = declaration.resolve(extension_id, subsection)?; + Ok(Self { section, resolved }) + } + + /// The manifest section path this section was declared at. + pub fn section(&self) -> &ManifestSectionPath { + &self.section + } + + /// The resolved section itself. Reached through here rather than through + /// per-field delegates, so this type adds the section path and mirrors + /// nothing (`.claude/rules/type-placement.md`). + pub fn resolved(&self) -> &ProductAdapterSection { + &self.resolved + } +} + +// --------------------------------------------------------------------------- +// ProductAdapter host-api contract validator +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub struct ProductAdapterHostApiContract { + id: HostApiId, +} + +impl ProductAdapterHostApiContract { + pub fn new() -> Result { + Ok(Self { + id: HostApiId::new(PRODUCT_ADAPTER_HOST_API_ID)?, + }) + } +} + +pub fn register_product_adapter_host_api_contract( + registry: &mut HostApiContractRegistry, +) -> Result<(), RegistryError> { + registry.register(Arc::new(ProductAdapterHostApiContract::new()?))?; + Ok(()) +} + +impl HostApiManifestContract for ProductAdapterHostApiContract { + fn id(&self) -> &HostApiId { + &self.id + } + + fn multiplicity(&self) -> HostApiMultiplicity { + HostApiMultiplicity::Multiple + } + + fn accepts_section_path(&self, section: &ManifestSectionPath) -> bool { + section.as_str() == PRODUCT_ADAPTER_SECTION_PREFIX + || section + .as_str() + .strip_prefix(PRODUCT_ADAPTER_SECTION_PREFIX) + .is_some_and(|rest| rest.starts_with('.')) + } + + fn validate_section( + &self, + host_api: &HostApiRefV2, + section: &toml::Value, + ) -> Result<(), HostApiSectionError> { + // The contract hook runs while the generic manifest parser is still + // validating the host-api section envelope, before it exposes the real + // extension id to contract implementations. `from_value` needs an id + // only to derive the adapter_id that this shape-only path discards; + // cross-field checks involving the real extension id belong in + // `project_product_adapter_sections` below. + let placeholder = + ExtensionId::new("x").map_err(|e| HostApiSectionError::from(e.to_string()))?; + ProductAdapterHostApiSection::from_value( + &placeholder, + host_api.section.clone(), + section.clone(), + ) + .map(|_| ()) + .map_err(|e| HostApiSectionError::from(e.to_string())) + } + + fn validate_section_with_context( + &self, + context: &HostApiManifestContext<'_>, + host_api: &HostApiRefV2, + section: &toml::Value, + ) -> Result<(), HostApiSectionError> { + ProductAdapterHostApiSection::from_value( + context.extension_id, + host_api.section.clone(), + section.clone(), + ) + .map(|_| ()) + .map_err(|e| HostApiSectionError::from(e.to_string())) + } + + fn project_section_with_context( + &self, + context: &HostApiManifestContext<'_>, + host_api: &HostApiRefV2, + section: &toml::Value, + ) -> Result { + let parsed = ProductAdapterHostApiSection::from_value( + context.extension_id, + host_api.section.clone(), + section.clone(), + ) + .map_err(|e| HostApiSectionError::from(e.to_string()))?; + // External-channel adapter sections are the extension's channel + // surface. The other product surface kinds (`web`, `cli`, + // `synchronous_api`) describe host-native surfaces and project no + // extension surface. + let surfaces = match parsed.resolved().surface_kind() { + ProductSurfaceKind::ExternalChannel => vec![CapabilitySurfaceKind::Channel], + ProductSurfaceKind::Web + | ProductSurfaceKind::Cli + | ProductSurfaceKind::SynchronousApi => Vec::new(), + }; + Ok(HostApiManifestProjection { + capabilities: Vec::new(), + surfaces, + }) + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RegistryError { + #[error(transparent)] + Installation(#[from] ExtensionInstallationError), + #[error(transparent)] + Manifest(#[from] ManifestV2Error), + /// The declared section is not a valid product-adapter section. Rendered + /// transparently so the schema's own wording is what a manifest author + /// reads, wherever the section was declared. + #[error(transparent)] + Section(#[from] ProductAdapterSectionError), + #[error("product adapter manifest section {section} parse failed: {reason}")] + ManifestSectionParse { + section: ManifestSectionPath, + reason: String, + }, + #[error("inline secret material is not allowed in manifest field {field}")] + InlineSecretMaterial { field: String }, + // Four installation-record variants (`UnknownManifest`, + // `UndeclaredCredentialHandle`, `ManifestExtensionMismatch`, + // `ManifestHashMismatch`) were dropped with the move: measured at zero + // constructors and zero match sites workspace-wide, and each duplicated a + // live `ExtensionInstallationError` variant this enum already wraps + // transparently — a mirror inside one crate once the module landed here. +} + +// --------------------------------------------------------------------------- +// Raw-TOML guards +// --------------------------------------------------------------------------- + +fn reject_inline_secret_material_value( + path: &str, + value: &toml::Value, +) -> Result<(), RegistryError> { + match value { + toml::Value::Table(table) => { + for (key, value) in table { + let child_path = format!("{path}.{key}"); + if is_secret_key_name(key) { + return Err(RegistryError::InlineSecretMaterial { field: child_path }); + } + reject_inline_secret_material_value(&child_path, value)?; + } + } + toml::Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + reject_inline_secret_material_value(&format!("{path}[{index}]"), value)?; + } + } + toml::Value::String(value) if looks_like_inline_secret(value) => { + return Err(RegistryError::InlineSecretMaterial { + field: path.to_string(), + }); + } + _ => {} + } + Ok(()) +} + +fn is_secret_key_name(key: &str) -> bool { + let normalised: String = key + .chars() + .map(|c| { + if c == '-' { + '_' + } else { + c.to_ascii_lowercase() + } + }) + .collect(); + matches!( + normalised.as_str(), + "secret" + | "secrets" + | "secret_value" + | "client_secret" + | "webhook_secret" + | "token" + | "raw_token" + | "access_token" + | "refresh_token" + | "bearer_token" + | "oauth_token" + | "auth_token" + | "id_token" + | "api_key" + | "apikey" + | "api_secret" + | "private_key" + | "password" + | "passphrase" + ) +} + +fn looks_like_inline_secret(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if lower.starts_with("sha256:") { + return false; + } + const PREFIXES: &[&str] = &[ + "sk-", // OpenAI / Anthropic style API keys. + "xoxb-", // Slack bot token. + "xoxa-", // Slack app token. + "xoxp-", // Slack user token. + "xoxs-", // Slack service token. + "xoxe-", // Slack configuration token. + "ghp_", // GitHub personal access token. + "gho_", // GitHub OAuth token. + "ghu_", // GitHub user-to-server token. + "ghs_", // GitHub server-to-server token. + "ghr_", // GitHub refresh token. + ]; + PREFIXES.iter().any(|p| lower.starts_with(p)) + || looks_like_aws_access_key(value) + || lower.contains("begin private key") + || lower.contains("begin rsa private key") + || (value.len() >= 30 && value.starts_with("eyJ") && value.contains('.')) + || has_uri_userinfo(value) + || looks_like_telegram_token(value) +} + +fn looks_like_aws_access_key(value: &str) -> bool { + if value.len() != 20 { + return false; + } + let Some(prefix) = value.get(..4) else { + return false; + }; + (prefix.eq_ignore_ascii_case("AKIA") || prefix.eq_ignore_ascii_case("ASIA")) + && value[4..] + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) +} + +fn has_uri_userinfo(value: &str) -> bool { + let Some((_, rest)) = value.split_once("://") else { + return false; + }; + rest.split('/').next().unwrap_or_default().contains('@') +} + +fn looks_like_telegram_token(value: &str) -> bool { + let Some((prefix, suffix)) = value.split_once(':') else { + return false; + }; + prefix.len() >= 6 + && prefix.chars().all(|c| c.is_ascii_digit()) + && suffix.len() >= 10 + && suffix + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +// --------------------------------------------------------------------------- +// Section lookup +// --------------------------------------------------------------------------- + +fn project_product_adapter_sections( + raw_toml: &str, + manifest: &ExtensionManifestV2, +) -> Result, RegistryError> { + // Safety: PRODUCT_ADAPTER_SECTION_PREFIX is a non-empty, control-char-free + // ASCII identifier defined as a module constant. + let root_section = ManifestSectionPath::new(PRODUCT_ADAPTER_SECTION_PREFIX) + .map_err(RegistryError::Manifest)?; + // The manifest parser validates host-api sections from its internal TOML + // section table but does not expose that table as a public projection API. + // Re-parse here rather than reaching through the parser's private + // representation. If profiling shows this is material, add a targeted + // section projection API to the parser instead of caching private state. + let value: toml::Value = + toml::from_str(raw_toml).map_err(|error| RegistryError::ManifestSectionParse { + section: root_section.clone(), + reason: error.to_string(), + })?; + let mut sections = Vec::new(); + for host_api in &manifest.host_apis { + if host_api.id.as_str() != PRODUCT_ADAPTER_HOST_API_ID { + continue; + } + let section_value = section_value(&value, &host_api.section)?; + sections.push(ProductAdapterHostApiSection::from_value( + &manifest.id, + host_api.section.clone(), + section_value.clone(), + )?); + } + Ok(sections) +} + +fn section_value<'a>( + root: &'a toml::Value, + path: &ManifestSectionPath, +) -> Result<&'a toml::Value, RegistryError> { + let mut current = root; + for segment in path.as_str().split('.') { + current = current + .as_table() + .and_then(|table| table.get(segment)) + .ok_or_else(|| RegistryError::ManifestSectionParse { + section: path.clone(), + reason: "section path does not exist".to_string(), + })?; + } + Ok(current) +} diff --git a/crates/ironclaw_product/tests/adapter_registry_contract.rs b/crates/ironclaw_extensions/tests/product_adapter_contract.rs similarity index 96% rename from crates/ironclaw_product/tests/adapter_registry_contract.rs rename to crates/ironclaw_extensions/tests/product_adapter_contract.rs index b46a90071d4..1059d759552 100644 --- a/crates/ironclaw_product/tests/adapter_registry_contract.rs +++ b/crates/ironclaw_extensions/tests/product_adapter_contract.rs @@ -1,11 +1,15 @@ use std::sync::Arc; use chrono::Utc; +use ironclaw_extensions::host_api::product_adapter::{ + parse_product_adapter_manifest_record, product_adapter_sections, + register_product_adapter_host_api_contract, +}; use ironclaw_extensions::{ ExtensionCredentialBinding, ExtensionCredentialHandle, ExtensionInstallation, ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationStore, ExtensionInstallationStorePort, ExtensionManifestRecord, ExtensionManifestRef, - InstallationOwner, MANIFEST_SCHEMA_VERSION, ManifestSource, + InstallationOwner, MANIFEST_SCHEMA_VERSION, ManifestHash, ManifestSource, }; use ironclaw_filesystem::InMemoryBackend; use ironclaw_host_api::{ @@ -13,10 +17,6 @@ use ironclaw_host_api::{ ids::{ExtensionId, SecretHandle}, path::VirtualPath, }; -use ironclaw_product::adapter_registry::{ - ManifestHash, parse_product_adapter_manifest_record, product_adapter_sections, - register_product_adapter_host_api_contract, -}; fn extension_id() -> ExtensionId { ExtensionId::new("telegram-v2").unwrap() @@ -132,7 +132,10 @@ async fn installed_extension_surfaces_product_adapter_runtime_entries() { .expect("manifest for installation"); let sections = product_adapter_sections(&manifest).unwrap(); assert_eq!(sections.len(), 1); - assert_eq!(sections[0].adapter_id().as_str(), "telegram-v2/inbound"); + assert_eq!( + sections[0].resolved().adapter_id().as_str(), + "telegram-v2/inbound" + ); } #[tokio::test] @@ -363,7 +366,7 @@ handle = "outbound_token" assert_eq!(sections.len(), 2, "both PA sections should project"); let ids: Vec<_> = sections .iter() - .map(|section| section.adapter_id().as_str().to_owned()) + .map(|section| section.resolved().adapter_id().as_str().to_owned()) .collect(); assert!(ids.contains(&"multi-adapter/inbound".to_owned())); assert!(ids.contains(&"multi-adapter/outbound".to_owned())); diff --git a/crates/ironclaw_product/tests/adapter_registry_manifest_ingestion.rs b/crates/ironclaw_extensions/tests/product_adapter_manifest_ingestion.rs similarity index 90% rename from crates/ironclaw_product/tests/adapter_registry_manifest_ingestion.rs rename to crates/ironclaw_extensions/tests/product_adapter_manifest_ingestion.rs index 1ac2b0adf2e..a34836121ea 100644 --- a/crates/ironclaw_product/tests/adapter_registry_manifest_ingestion.rs +++ b/crates/ironclaw_extensions/tests/product_adapter_manifest_ingestion.rs @@ -1,12 +1,16 @@ +use ironclaw_extension_contracts::product_adapter_section::ProductAdapterSectionError; use ironclaw_extension_contracts::surface::CapabilitySurfaceKind; +use ironclaw_extensions::host_api::product_adapter::{ + RegistryError, parse_product_adapter_manifest_record, product_adapter_sections, +}; use ironclaw_extensions::{ - CapabilitySurfaceDeclV2, ExtensionManifestRecord, MANIFEST_SCHEMA_VERSION, ManifestSource, + CapabilitySurfaceDeclV2, ExtensionManifestRecord, MANIFEST_SCHEMA_VERSION, ManifestHash, + ManifestSource, }; use ironclaw_host_api::host_port::HostPortCatalog; -use ironclaw_product::adapter_registry::{ - ManifestHash, RegistryError, parse_product_adapter_manifest_record, product_adapter_sections, +use ironclaw_host_api::product_adapter::{ + AuthRequirement, ProductCapabilityFlag, ProductSurfaceKind, }; -use ironclaw_product::{AuthRequirement, ProductCapabilityFlag, ProductSurfaceKind}; fn manifest(extra: &str) -> String { format!( @@ -65,7 +69,8 @@ fn parses_product_adapter_host_api_section_from_extension_manifest_v2() { assert_eq!(record.extension_id().as_str(), "telegram-v2"); let adapters = product_adapter_sections(&record).unwrap(); assert_eq!(adapters.len(), 1); - let adapter = &adapters[0]; + assert_eq!(adapters[0].section().as_str(), "product_adapter.inbound"); + let adapter = adapters[0].resolved(); assert_eq!(adapter.adapter_id().as_str(), "telegram-v2/inbound"); assert_eq!(adapter.surface_kind(), ProductSurfaceKind::ExternalChannel); assert!(matches!( @@ -131,7 +136,8 @@ credential_handle = "undeclared_token" let err = parse(&raw).unwrap_err(); assert!(matches!( err, - RegistryError::UndeclaredEgressCredentialHandle { .. } | RegistryError::Manifest(_) + RegistryError::Section(ProductAdapterSectionError::UndeclaredEgressCredentialHandle { .. }) + | RegistryError::Manifest(_) )); } @@ -145,10 +151,10 @@ fn rejects_auth_header_injection_shape() { let err = parse(&raw).unwrap_err(); assert!(matches!( err, - RegistryError::InvalidValue { + RegistryError::Section(ProductAdapterSectionError::InvalidValue { field: "auth.header_name", .. - } | RegistryError::Manifest(_) + }) | RegistryError::Manifest(_) )); } @@ -173,7 +179,7 @@ fn parses_host_ingress_route_from_manifest() { ))) .unwrap(); let adapters = product_adapter_sections(&record).unwrap(); - let routes = adapters[0].host_ingress(); + let routes = adapters[0].resolved().host_ingress(); assert_eq!(routes.len(), 1); assert_eq!( routes[0].descriptor().route_id().as_str(), @@ -223,7 +229,9 @@ fn rejects_host_ingress_credential_handle_not_declared_as_required() { assert!( matches!( err, - RegistryError::UndeclaredIngressCredentialHandle { .. } | RegistryError::Manifest(_) + RegistryError::Section( + ProductAdapterSectionError::UndeclaredIngressCredentialHandle { .. } + ) | RegistryError::Manifest(_) ), "got {err:?}" ); diff --git a/crates/ironclaw_hooks/CLAUDE.md b/crates/ironclaw_hooks/CLAUDE.md index 79a9a97b540..03911e71af6 100644 --- a/crates/ironclaw_hooks/CLAUDE.md +++ b/crates/ironclaw_hooks/CLAUDE.md @@ -183,18 +183,30 @@ same `Arc`, so a hook poisoned in run N stays poisoned for run N+1. New call sites should reach for `with_hook_dispatcher_factory` for real per-run isolation. -Cross-run isolation was described here as covered by -`crates/ironclaw_runner/tests/hooks_integration.rs` with the tests -`per_build_dispatcher_state_does_not_leak_across_runs` and -`legacy_with_hook_dispatcher_shares_state_across_builds`. **None of those -exist** (`ls crates/ironclaw_runner/tests/`; `rg` for either name returns only -this file). The intended semantic — a fresh dispatcher per build has an -un-poisoned slot and re-applies the fail-closed deny, while the legacy -`with_hook_dispatcher` adapter shares state across builds — is currently -**unpinned by any test**, tracked in -[#6945](https://github.com/nearai/ironclaw/issues/6945). The property holds in -production today (composition wires the isolating -`with_hook_dispatcher_builder_factory`), but nothing fails if that changes. +Cross-run isolation is pinned by +**`poisoned_hook_slot_does_not_leak_into_the_next_run`** in +`tests/integration/hooks.rs` ([#6945](https://github.com/nearai/ironclaw/issues/6945), +landed 2026-08-04 with [ADR 0004](../../docs/adr/0004-hooks-keeps-its-predicate-state-backends.md)). +It drives two turns on one harness — two `build_text_only_host*` calls, so two +dispatcher mints — with a hook that commits a gate-sink protocol violation. +Run 1 fails closed and poisons its slot; run 2 must get a clean slot, fire the +hook **again**, and re-apply the deny. Under the legacy shared-dispatcher +adapter run 2 would skip the poisoned hook and let the capability reach the +wire, so both the fire count and the egress count flip — verified red by +temporarily pointing `ironclaw_runner::runtime` at `with_hook_dispatcher`. + +⚠ **Read the history before trusting any claim in this section.** It previously +named `crates/ironclaw_runner/tests/hooks_integration.rs` and two tests +(`per_build_dispatcher_state_does_not_leak_across_runs`, +`legacy_with_hook_dispatcher_shares_state_across_builds`) that **never +existed**; #6944 corrected the false claim and #6945 tracked the real gap it +was hiding. Verify a named test exists (`rg` for it) before relying on it here. + +Two things remain pinned elsewhere rather than by that test, deliberately: `dispatch/mod.rs::poisoned_during_dispatch_skips_subsequent_invocations` covers -poisoning *within* one dispatcher, not across builds. Treat the legacy adapter -as the explicit opt-in baseline. +poisoning *within* one dispatcher (the legacy adapter's shared-state contract +follows from that plus its one-line delegation to +`with_hook_dispatcher_factory(|| Arc::clone(&d))`), and **predicate counter +state is deliberately NOT asserted isolated** — it is tenant-scoped and shared +across runs by design, so a test asserting isolation for it would pin a +rate-cap bypass. Treat the legacy adapter as the explicit opt-in baseline. diff --git a/crates/ironclaw_mcp/AGENTS.md b/crates/ironclaw_mcp/AGENTS.md index 15069ffa339..4b57643f368 100644 --- a/crates/ironclaw_mcp/AGENTS.md +++ b/crates/ironclaw_mcp/AGENTS.md @@ -9,6 +9,23 @@ - `docs/reborn/contracts/runtime-workflows.md` - `docs/reborn/contracts/processes.md` +## Module Charter + +The crate is **seven private modules**, each with a stated owner; `lib.rs` +carries the charter table (PROPOSAL §6.6.3) and re-exports every public item, +so `ironclaw_mcp::X` remains the only import path. Consult the table in +`src/lib.rs` before adding a file: + +| Module | Owns | +|---|---| +| `contract` | The vocabulary a caller names: config, DTOs, the `McpClient`/`McpExecutor` traits, the `McpError`/`McpClientError` taxonomy | +| `runtime` | Resource-governed execution: descriptor admission, reserve → call → reconcile/release, the manifest credential context | +| `client` | The Streamable-HTTP `McpClient`: handshake, per-invocation session lifecycle, the `tools/list` paging loop | +| `jsonrpc` | The JSON-RPC 2.0 codec and response hygiene: framing, id matching, session id / protocol version, auth challenge, per-method credential routing | +| `discovery` | `tools/list` catalog admission: host ceilings, per-tool classification, schema bounds, tool-name grammar | +| `egress` | The host-mediated HTTP seam: the `McpHostHttp` port and the host-owned egress plan/planner | +| `diagnostics` | Every stable, bounded failure token the lane surfaces | + ## What This Crate Owns - The Reborn MCP runtime lane (fail-closed process policy, host-mediated egress), currently: diff --git a/crates/ironclaw_mcp/CLAUDE.md b/crates/ironclaw_mcp/CLAUDE.md index eb2a02931c5..c61bc50b00a 100644 --- a/crates/ironclaw_mcp/CLAUDE.md +++ b/crates/ironclaw_mcp/CLAUDE.md @@ -1,5 +1,18 @@ # ironclaw_mcp guardrails +- **Where new code goes is a charter question, answered in `src/lib.rs`.** The + crate is seven private modules — `contract`, `runtime`, `client`, `jsonrpc`, + `discovery`, `egress`, `diagnostics` — and the module-charter table in the + `lib.rs` doc comment says what each owns and what must never drift into it + (PROPOSAL §6.6.3). Read it before adding a file or a function. Two rules it + carries are load-bearing here: **no module builds a failure string of its + own** (every reason comes from `diagnostics`' cause enums, so the + model-visible token set stays enumerable in one file), and **`discovery` owns + the catalog rules while `client` owns the paging loop** (both read the same + constants, so the two enforcement points cannot drift). +- The submodules are private and every public item is re-exported from + `lib.rs`, so `ironclaw_mcp::X` stays the single import path for consumers and + a module rename is never a breaking change. - Own the Reborn MCP runtime lane: MCP execution request/result types, client abstraction, host-mediated HTTP adapter, JSON-RPC exchange logic, and MCP-specific resource accounting. - HTTP/SSE transports must go through host-mediated runtime egress. Do not add direct outbound networking, ad-hoc HTTP clients, DNS checks, credential injection, or network policy evaluation here. - Treat plugin/runtime input as untrusted. Inputs may shape JSON-RPC arguments only; network policy, credentials, timeouts, and body limits must come from host-owned planning/handoff data. diff --git a/crates/ironclaw_mcp/src/client.rs b/crates/ironclaw_mcp/src/client.rs new file mode 100644 index 00000000000..b33bfb6613f --- /dev/null +++ b/crates/ironclaw_mcp/src/client.rs @@ -0,0 +1,629 @@ +//! The Streamable-HTTP [`McpClient`] implementation. +//! +//! This module owns the *sequence*: plan a request, run the +//! `initialize` / `notifications/initialized` handshake, then `tools/call` or +//! the `tools/list` paging loop — and the per-invocation session state that +//! sequence depends on. It frames nothing itself (`jsonrpc` does), decides no +//! tool-shape rule (`discovery` does), and sends nothing directly (`egress` +//! does). + +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +use async_trait::async_trait; +use ironclaw_host_api::{ + action::NetworkMethod, + http::CapabilityHostHttpRequest, + ids::{CapabilityId, ExtensionId}, + resource::{ResourceScope, ResourceUsage}, +}; +use serde_json::Value; + +use crate::contract::{ + McpClient, McpClientError, McpClientOutput, McpClientRequest, McpToolDiscoveryOutput, +}; +use crate::diagnostics::{ + McpInvalidToolListCause, McpRequestDeniedCause, McpResponseErrorCause, invalid_tool_list, + request_denied, response_error, +}; +use crate::discovery::{ + MAX_DISCOVERED_MCP_TOOLS, MAX_MCP_TOOLS_CATALOG_BYTES, MAX_MCP_TOOLS_LIST_PAGES, + parse_tools_list_page, +}; +use crate::egress::{ + McpHostHttp, McpHostHttpEgressPlan, McpHostHttpEgressPlanRequest, McpHostHttpEgressPlanner, + effective_mcp_response_body_limit, mcp_client_http_error, requires_host_http_egress, +}; +use crate::jsonrpc::{ + MCP_PROTOCOL_VERSION_HEADER, McpJsonRpcExchange, McpJsonRpcMethod, McpJsonRpcResponse, + encode_json_rpc_request, is_mcp_auth_response_status, json_rpc_initialize_params, + mcp_auth_challenge_from_response, mcp_session_id_from_response, parse_mcp_response, + protocol_version_from_initialize_response, validate_staged_credential_injections, + validate_tools_call_credential_injections, +}; + +#[derive(Debug, Clone)] +pub struct McpHostHttpClient { + http: H, + planner: P, + state: Arc, +} + +#[derive(Debug)] +struct McpHostHttpClientState { + next_id: AtomicU64, + // `std::sync::Mutex` is appropriate here: the lock is held only for O(1) + // HashMap operations (never across an `.await`), and the key includes + // `invocation_id` so concurrent dispatches from different invocations act + // on disjoint map entries with no real contention. + sessions: Mutex>, +} + +struct McpHostHttpSessionCleanup { + state: Arc, + session_key: McpHostHttpSessionKey, +} + +struct PlannedMcpJsonRpc { + id: Option, + method: McpJsonRpcMethod, + url: String, + policy_headers: Vec<(String, String)>, + body: Vec, + plan: McpHostHttpEgressPlan, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct McpHostHttpSession { + session_id: Option, + protocol_version: String, +} + +impl McpHostHttpSessionCleanup { + fn new(state: Arc, session_key: McpHostHttpSessionKey) -> Self { + Self { state, session_key } + } +} + +impl Drop for McpHostHttpSessionCleanup { + fn drop(&mut self) { + if let Ok(mut guard) = self.state.sessions.lock() { + guard.remove(&self.session_key); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct McpHostHttpSessionKey { + tenant_id: String, + user_id: String, + agent_id: Option, + project_id: Option, + mission_id: Option, + thread_id: Option, + invocation_id: String, + provider: String, + url: String, +} + +impl McpHostHttpSessionKey { + fn new(scope: &ResourceScope, provider: &ExtensionId, url: &str) -> Self { + Self { + tenant_id: scope.tenant_id.as_str().to_string(), + user_id: scope.user_id.as_str().to_string(), + agent_id: scope.agent_id.as_ref().map(|id| id.as_str().to_string()), + project_id: scope.project_id.as_ref().map(|id| id.as_str().to_string()), + mission_id: scope.mission_id.as_ref().map(|id| id.as_str().to_string()), + thread_id: scope.thread_id.as_ref().map(|id| id.as_str().to_string()), + invocation_id: scope.invocation_id.to_string(), + provider: provider.as_str().to_string(), + url: url.to_string(), + } + } +} + +impl McpHostHttpClient +where + H: McpHostHttp, + P: McpHostHttpEgressPlanner, +{ + pub fn new(http: H, planner: P) -> Self { + Self { + http, + planner, + state: Arc::new(McpHostHttpClientState { + next_id: AtomicU64::new(1), + sessions: Mutex::new(HashMap::new()), + }), + } + } + + fn next_request_id(&self) -> u64 { + self.state.next_id.fetch_add(1, Ordering::SeqCst) + } + + /// Perform only the MCP initialization handshake. + /// + /// Registration uses this to distinguish credential-free access from an + /// authentication challenge without fetching or admitting the tool + /// catalog. The temporary session is always discarded before returning. + pub async fn probe_auth( + &self, + request: McpClientRequest, + ) -> Result { + if !requires_host_http_egress(&request.transport) { + return Err(McpClientError::client(request_denied( + McpRequestDeniedCause::UnsupportedTransport, + ))); + } + let url = request.url.as_deref().ok_or_else(|| { + McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) + })?; + let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); + let _session_cleanup = + McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); + self.initialize_session(&request, &session_key).await + } + + async fn send_json_rpc( + &self, + request: &McpClientRequest, + session_key: &McpHostHttpSessionKey, + id: Option, + method: McpJsonRpcMethod, + params: Option, + ) -> Result { + let planned = self.plan_json_rpc(request, id, method, params)?; + self.send_planned_json_rpc(request, session_key, planned) + .await + } + + fn plan_json_rpc( + &self, + request: &McpClientRequest, + id: Option, + method: McpJsonRpcMethod, + params: Option, + ) -> Result { + let url = request.url.as_deref().ok_or_else(|| { + McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) + })?; + let body = + encode_json_rpc_request(id, method.as_str(), params).map_err(McpClientError::client)?; + let policy_headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ( + "Accept".to_string(), + "application/json, text/event-stream".to_string(), + ), + ]; + + let plan = self.planner.plan(McpHostHttpEgressPlanRequest { + provider: &request.provider, + capability_id: &request.capability_id, + scope: &request.scope, + transport: &request.transport, + method: NetworkMethod::Post, + url, + headers: &policy_headers, + body: &body, + }); + Ok(PlannedMcpJsonRpc { + id, + method, + url: url.to_string(), + policy_headers, + body, + plan, + }) + } + + async fn send_planned_json_rpc( + &self, + request: &McpClientRequest, + session_key: &McpHostHttpSessionKey, + planned: PlannedMcpJsonRpc, + ) -> Result { + let mut headers = planned.policy_headers; + if let Some(session) = self.current_session(session_key)? { + headers.push(( + MCP_PROTOCOL_VERSION_HEADER.to_string(), + session.protocol_version, + )); + if let Some(session_id) = session.session_id { + headers.push(("Mcp-Session-Id".to_string(), session_id)); + } + } + + let response_body_limit = effective_mcp_response_body_limit( + planned.plan.response_body_limit, + request.max_output_bytes, + ); + let credential_injections = planned + .method + .credential_injections(planned.plan.credential_injections)?; + let response = self + .http + .request(CapabilityHostHttpRequest { + scope: request.scope.clone(), + capability_id: request.capability_id.clone(), + method: NetworkMethod::Post, + url: planned.url, + headers, + body: planned.body, + network_policy: planned.plan.network_policy, + credential_injections, + response_body_limit, + timeout_ms: planned.plan.timeout_ms, + }) + .await + .map_err(mcp_client_http_error)?; + + let usage = ResourceUsage::default().set_network_egress_bytes(response.request_bytes); + + if !(200..300).contains(&response.status) { + if is_mcp_auth_response_status(response.status) { + // Bare `AuthRequired` when the response gives us nothing to + // act on; `AuthChallenge` only when it actually carries + // WWW-Authenticate/resource-metadata to resolve. + let challenge = mcp_auth_challenge_from_response(&response); + return Err( + if challenge.www_authenticate_metadata.is_empty() + && challenge.protected_resource_metadata.is_empty() + { + McpClientError::AuthRequired + } else { + McpClientError::AuthChallenge { challenge } + }, + ); + } + return Err(McpClientError::client(response_error( + McpResponseErrorCause::HttpStatus(response.status), + ))); + } + let session_id = mcp_session_id_from_response(&response).map_err(McpClientError::client)?; + + if response.status == 202 && planned.id.is_none() { + return Ok(McpJsonRpcExchange { + response: McpJsonRpcResponse { + result: None, + error: None, + }, + session_id, + usage, + }); + } + + Ok(McpJsonRpcExchange { + response: parse_mcp_response(&response, planned.id).map_err(McpClientError::client)?, + session_id, + usage, + }) + } + + fn current_session( + &self, + session_key: &McpHostHttpSessionKey, + ) -> Result, McpClientError> { + self.state + .sessions + .lock() + .map(|guard| guard.get(session_key).cloned()) + .map_err(|_| { + McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) + }) + } + + fn store_session( + &self, + session_key: &McpHostHttpSessionKey, + session: McpHostHttpSession, + ) -> Result<(), McpClientError> { + let mut guard = self.state.sessions.lock().map_err(|_| { + McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) + })?; + guard.insert(session_key.clone(), session); + Ok(()) + } + + fn update_session_id( + &self, + session_key: &McpHostHttpSessionKey, + session_id: Option, + ) -> Result<(), McpClientError> { + let Some(session_id) = session_id else { + return Ok(()); + }; + let mut guard = self.state.sessions.lock().map_err(|_| { + McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) + })?; + if let Some(session) = guard.get_mut(session_key) { + session.session_id = Some(session_id); + } + Ok(()) + } + + async fn initialize_session( + &self, + request: &McpClientRequest, + session_key: &McpHostHttpSessionKey, + ) -> Result { + let mut usage = ResourceUsage::default(); + let initialize_id = self.next_request_id(); + let initialize = self + .send_json_rpc( + request, + session_key, + Some(initialize_id), + McpJsonRpcMethod::Initialize, + Some(json_rpc_initialize_params()), + ) + .await?; + accumulate_usage(&mut usage, initialize.usage); + if let Some(error) = initialize.response.error { + return Err(McpClientError::client(response_error( + McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }, + ))); + } + self.store_session( + session_key, + McpHostHttpSession { + session_id: initialize.session_id, + protocol_version: protocol_version_from_initialize_response(&initialize.response) + .map_err(McpClientError::client)?, + }, + )?; + + let initialized = self + .send_json_rpc( + request, + session_key, + None, + McpJsonRpcMethod::InitializedNotification, + None, + ) + .await?; + accumulate_usage(&mut usage, initialized.usage); + self.update_session_id(session_key, initialized.session_id.clone())?; + if let Some(error) = initialized.response.error { + return Err(McpClientError::client(response_error( + McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }, + ))); + } + Ok(usage) + } +} + +#[async_trait] +impl McpClient for McpHostHttpClient +where + H: McpHostHttp, + P: McpHostHttpEgressPlanner, +{ + fn uses_host_mediated_http_egress(&self) -> bool { + true + } + + async fn call_tool( + &self, + request: McpClientRequest, + ) -> Result { + if !requires_host_http_egress(&request.transport) { + return Err(McpClientError::client(request_denied( + McpRequestDeniedCause::UnsupportedTransport, + ))); + } + + let url = request.url.as_deref().ok_or_else(|| { + McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) + })?; + let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); + let _session_cleanup = + McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); + + let tool_name = mcp_tool_name(&request.provider, &request.capability_id); + let tool_call_params = serde_json::json!({ + "name": tool_name, + "arguments": request.input.clone(), + }); + let tool_call_id = self.next_request_id(); + let tool_call_plan = self.plan_json_rpc( + &request, + Some(tool_call_id), + McpJsonRpcMethod::ToolsCall, + Some(tool_call_params), + )?; + validate_tools_call_credential_injections(&tool_call_plan.plan.credential_injections) + .map_err(McpClientError::client)?; + + let mut usage = self.initialize_session(&request, &session_key).await?; + + let call = self + .send_planned_json_rpc(&request, &session_key, tool_call_plan) + .await?; + accumulate_usage(&mut usage, call.usage); + self.update_session_id(&session_key, call.session_id.clone())?; + if let Some(error) = call.response.error { + return Err(McpClientError::client(response_error( + McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }, + ))); + } + let output = call.response.result.ok_or_else(|| { + McpClientError::client(response_error(McpResponseErrorCause::MissingResult)) + })?; + let output_bytes = serde_json::to_vec(&output) + .map(|bytes| bytes.len() as u64) + .map_err(|err| { + McpClientError::client(response_error(McpResponseErrorCause::ParseFailed( + err.to_string(), + ))) + })?; + usage.output_bytes = usage.output_bytes.max(output_bytes); + + Ok(McpClientOutput { + output, + usage, + output_bytes: Some(output_bytes), + }) + } + + async fn discover_tools( + &self, + request: McpClientRequest, + max_tools: u32, + ) -> Result { + if !requires_host_http_egress(&request.transport) { + return Err(McpClientError::client(request_denied( + McpRequestDeniedCause::UnsupportedTransport, + ))); + } + + let url = request.url.as_deref().ok_or_else(|| { + McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) + })?; + let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); + let _session_cleanup = + McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); + + if max_tools == 0 { + return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( + McpInvalidToolListCause::TooManyTools, + ))); + } + + // The first page's plan is built before `initialize_session` runs (not + // inside the loop below) so the planner observes `tools/list` before + // `initialize`/`notifications/initialized`, matching the original + // single-page discovery ordering that callers and tests depend on. + // Only pages after the first are planned lazily inside the loop, once + // a `nextCursor` is known. + let first_tools_list_id = self.next_request_id(); + let first_tools_list_plan = self.plan_json_rpc( + &request, + Some(first_tools_list_id), + McpJsonRpcMethod::ToolsList, + None, + )?; + validate_staged_credential_injections(&first_tools_list_plan.plan.credential_injections) + .map_err(McpClientError::client)?; + + let mut usage = self.initialize_session(&request, &session_key).await?; + let mut discovered = Vec::new(); + let mut accepted_catalog_bytes = 0usize; + let mut cursor = None; + let mut pending_plan = Some(first_tools_list_plan); + for page in 1..=MAX_MCP_TOOLS_LIST_PAGES { + let tools_list_plan = match pending_plan.take() { + Some(plan) => plan, + None => { + let tools_list_id = self.next_request_id(); + let plan = self.plan_json_rpc( + &request, + Some(tools_list_id), + McpJsonRpcMethod::ToolsList, + cursor + .as_ref() + .map(|cursor| serde_json::json!({ "cursor": cursor })), + )?; + validate_staged_credential_injections(&plan.plan.credential_injections) + .map_err(McpClientError::client)?; + plan + } + }; + + let tools = self + .send_planned_json_rpc(&request, &session_key, tools_list_plan) + .await?; + accumulate_usage(&mut usage, tools.usage); + self.update_session_id(&session_key, tools.session_id.clone())?; + if let Some(error) = tools.response.error { + return Err(McpClientError::client(response_error( + McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }, + ))); + } + let result = tools.response.result.ok_or_else(|| { + McpClientError::client(response_error(McpResponseErrorCause::MissingResult)) + })?; + let page_bytes = result + .get("tools") + .and_then(Value::as_array) + .and_then(|tools| serde_json::to_vec(tools).ok()) + .map_or(usize::MAX, |bytes| bytes.len()); + let (page_tools, next_cursor) = + parse_tools_list_page(&result).map_err(McpClientError::invalid_tool_catalog)?; + accepted_catalog_bytes = accepted_catalog_bytes.saturating_add(page_bytes); + if discovered.len().saturating_add(page_tools.len()) > MAX_DISCOVERED_MCP_TOOLS + || discovered.len().saturating_add(page_tools.len()) > max_tools as usize + { + return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( + McpInvalidToolListCause::TooManyTools, + ))); + } + if accepted_catalog_bytes > MAX_MCP_TOOLS_CATALOG_BYTES { + return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( + McpInvalidToolListCause::CatalogTooLarge, + ))); + } + discovered.extend(page_tools); + match next_cursor { + Some(_next_cursor) if page == MAX_MCP_TOOLS_LIST_PAGES => { + return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( + McpInvalidToolListCause::TooManyPages, + ))); + } + Some(next_cursor) => cursor = Some(next_cursor), + None => break, + } + } + Ok(McpToolDiscoveryOutput { + tools: discovered, + usage, + }) + } +} + +fn mcp_tool_name(provider: &ExtensionId, capability_id: &CapabilityId) -> String { + let prefix = format!("{}.", provider.as_str()); + capability_id + .as_str() + .strip_prefix(&prefix) + .unwrap_or_else(|| capability_id.as_str()) + .to_string() +} + +fn accumulate_usage(total: &mut ResourceUsage, usage: ResourceUsage) { + total.network_egress_bytes = total + .network_egress_bytes + .saturating_add(usage.network_egress_bytes); + total.output_bytes = total.output_bytes.saturating_add(usage.output_bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_tool_name_strips_provider_prefix_for_canonical_tool_name() { + let provider = ExtensionId::new("nearai").unwrap(); + let capability_id = CapabilityId::new("nearai.web_search").unwrap(); + + assert_eq!(mcp_tool_name(&provider, &capability_id), "web_search"); + } +} diff --git a/crates/ironclaw_mcp/src/contract.rs b/crates/ironclaw_mcp/src/contract.rs new file mode 100644 index 00000000000..98425dfd818 --- /dev/null +++ b/crates/ironclaw_mcp/src/contract.rs @@ -0,0 +1,267 @@ +//! The vocabulary a caller of `ironclaw_mcp` names. +//! +//! Everything public about this lane is declared here: the host-owned limits, +//! the invocation/request/output shapes, the two traits a composition root +//! wires (`McpClient` inward, `McpExecutor` outward), and the error taxonomy +//! both of them speak. Protocol framing, transport, and resource accounting +//! belong to `jsonrpc`, `egress`, and `runtime` respectively. + +use async_trait::async_trait; +use ironclaw_extension_contracts::hosted_mcp::{HostedMcpDiscoveredTool, McpAuthChallenge}; +use ironclaw_extension_contracts::runtime::ExtensionRuntime; +use ironclaw_host_api::{ + capability::CapabilityDescriptor, + decision::RuntimeCredentialAuthRequirement, + ids::{CapabilityId, ExtensionId, SecretHandle}, + resource::{ + CapabilityHostResult, ResourceEstimate, ResourceReceipt, ResourceReservation, + ResourceScope, ResourceUsage, RuntimeResourceBudget, RuntimeResourceError, + }, + runtime::RuntimeKind, +}; +use serde_json::Value; +use thiserror::Error; + +use crate::diagnostics::{McpRequestDeniedCause, request_denied}; + +/// Host-owned MCP adapter limits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpRuntimeConfig { + pub max_output_bytes: u64, +} + +impl Default for McpRuntimeConfig { + fn default() -> Self { + Self { + max_output_bytes: 1024 * 1024, + } + } +} + +impl McpRuntimeConfig { + pub fn for_testing() -> Self { + Self { + max_output_bytes: 64 * 1024, + } + } +} + +/// JSON invocation passed to a manifest-declared MCP capability. +#[derive(Debug, Clone, PartialEq)] +pub struct McpInvocation { + pub input: Value, +} + +/// Full resource-governed MCP execution request. +#[derive(Debug)] +pub struct McpExecutionRequest<'a> { + /// The extension whose manifest declares this lane. + /// + /// The lane deliberately does **not** receive the `ExtensionPackage`: it + /// read only the id, the capability descriptors, and the runtime stanza, + /// and taking the package forced a `runtimes -> loops` dependency on the + /// registry crate (the W7 `ironclaw_mcp -> ironclaw_extensions` exception). + /// The caller, which owns the package, projects those three. + /// + /// **Caller obligation (the cost of that carve-out).** `extension`, + /// `capabilities`, and `runtime` are three independent borrows, so the type + /// no longer *structurally* guarantees they came from one package the way + /// `&ExtensionPackage` did. `execute_extension_json` re-checks the + /// descriptor half (`descriptor.provider == extension`), but nothing in an + /// `&ExtensionRuntime` identifies its owning extension, so the runtime half + /// cannot be re-derived here — a caller that paired extension A's + /// descriptors with extension B's runtime stanza would authenticate as A + /// and dial B. **Always project all three from the same `ExtensionPackage` + /// in one expression.** The single production caller + /// (`ironclaw_host_runtime::services::runtime_adapters`) does exactly that. + /// Restoring the compile-time binding needs a sealed projection minted by + /// the package owner — it cannot be a check inside this lane, and it must + /// not be a re-addition of the registry edge; tracked with the WS3 lane + /// work. + pub extension: &'a ExtensionId, + pub capabilities: &'a [CapabilityDescriptor], + pub runtime: &'a ExtensionRuntime, + pub capability_id: &'a CapabilityId, + pub scope: ResourceScope, + pub estimate: ResourceEstimate, + pub resource_reservation: Option, + pub invocation: McpInvocation, +} + +/// Host-normalized request handed to the configured MCP client adapter. +#[derive(Debug, Clone, PartialEq)] +pub struct McpClientRequest { + pub provider: ExtensionId, + pub capability_id: CapabilityId, + pub scope: ResourceScope, + pub transport: String, + pub command: Option, + pub args: Vec, + pub url: Option, + pub input: Value, + pub max_output_bytes: u64, +} + +/// Raw MCP adapter output before resource reconciliation. +#[derive(Debug, Clone, PartialEq)] +pub struct McpClientOutput { + pub output: Value, + pub usage: ResourceUsage, + pub output_bytes: Option, +} + +impl McpClientOutput { + pub fn json(value: Value) -> Self { + Self { + output: value, + usage: ResourceUsage::default(), + output_bytes: None, + } + } +} + +/// Result of a hosted MCP schema-discovery pass. +/// +/// Discovered tools use the extension-domain [`HostedMcpDiscoveredTool`] shape +/// directly: `ironclaw_mcp` parses `tools/list` into the same descriptor the +/// extension domain consumes, so there is no separate MCP-local mirror. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpToolDiscoveryOutput { + pub tools: Vec, + pub usage: ResourceUsage, +} + +/// Host-selected MCP client adapter. +/// +/// Implementations must enforce `McpClientRequest::max_output_bytes` while +/// reading MCP server output, before constructing the structured JSON `Value`. +/// The runtime re-checks serialized output size after the adapter returns, but +/// that check is a second line of defense rather than the primary memory bound. +#[async_trait] +pub trait McpClient: Send + Sync { + /// HTTP/SSE MCP transports must be implemented through the shared host-mediated + /// runtime egress boundary. The default is fail-closed so a generic client + /// cannot accidentally perform direct outbound HTTP. + fn uses_host_mediated_http_egress(&self) -> bool { + false + } + + async fn call_tool(&self, request: McpClientRequest) + -> Result; + + async fn discover_tools( + &self, + request: McpClientRequest, + max_tools: u32, + ) -> Result { + let _ = (request, max_tools); + Err(McpClientError::client(request_denied( + McpRequestDeniedCause::UnsupportedTransport, + ))) + } +} + +/// Stable, sanitized MCP client-side failure categories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpClientError { + Client { + reason: String, + }, + /// The server completed `tools/list`, but the advertised catalog violated + /// the host's provider-neutral shape or safety contract. Repeating OAuth + /// or the same request cannot repair this generation. + InvalidToolCatalog { + reason: String, + }, + AuthRequired, + /// A hosted server returned 401/403. The challenge is header-derived and + /// deliberately redacted; it contains no remote response body or tokens. + AuthChallenge { + challenge: McpAuthChallenge, + }, +} + +impl McpClientError { + pub fn client(reason: impl Into) -> Self { + Self::Client { + reason: reason.into(), + } + } + + pub fn invalid_tool_catalog(reason: impl Into) -> Self { + Self::InvalidToolCatalog { + reason: reason.into(), + } + } + + pub fn stable_reason(&self) -> &str { + match self { + Self::Client { reason } | Self::InvalidToolCatalog { reason } => reason, + Self::AuthRequired | Self::AuthChallenge { .. } => "auth_required", + } + } +} + +impl From for McpClientError { + fn from(reason: String) -> Self { + Self::client(reason) + } +} + +/// Full resource-governed MCP execution result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpExecutionResult { + pub result: CapabilityHostResult, + pub receipt: ResourceReceipt, +} + +/// MCP runtime failures. +#[derive(Debug, Error)] +pub enum McpError { + #[error("resource governor error: {0}")] + Resource(RuntimeResourceError), + #[error("MCP client error: {reason}")] + Client { reason: String }, + #[error("MCP server advertised an invalid tool catalog: {reason}")] + InvalidToolCatalog { reason: String }, + #[error("MCP capability requires authentication")] + AuthRequired { + required_secrets: Vec, + credential_requirements: Vec, + }, + #[error("unsupported MCP transport {transport}")] + UnsupportedTransport { transport: String }, + #[error("MCP transport {transport} requires host-mediated HTTP egress")] + HostHttpEgressRequired { transport: String }, + #[error("stdio MCP transport is unsupported until process-level egress controls land")] + ExternalStdioTransportUnsupported, + #[error("extension {extension} uses runtime {actual:?}, not RuntimeKind::Mcp")] + ExtensionRuntimeMismatch { + extension: ExtensionId, + actual: RuntimeKind, + }, + #[error("capability {capability} is not declared by this extension package")] + CapabilityNotDeclared { capability: CapabilityId }, + #[error("MCP descriptor mismatch: {reason}")] + DescriptorMismatch { reason: String }, + #[error("invalid MCP invocation: {reason}")] + InvalidInvocation { reason: String }, + #[error("MCP output limit exceeded: limit {limit}, actual {actual}")] + OutputLimitExceeded { limit: u64, actual: u64 }, +} + +impl From for McpError { + fn from(error: RuntimeResourceError) -> Self { + Self::Resource(error) + } +} + +/// Object-safe MCP executor interface used by the kernel composition layer. +#[async_trait] +pub trait McpExecutor: Send + Sync { + async fn execute_extension_json( + &self, + budget: &dyn RuntimeResourceBudget, + request: McpExecutionRequest<'_>, + ) -> Result; +} diff --git a/crates/ironclaw_mcp/src/diagnostics.rs b/crates/ironclaw_mcp/src/diagnostics.rs new file mode 100644 index 00000000000..669be80a3ef --- /dev/null +++ b/crates/ironclaw_mcp/src/diagnostics.rs @@ -0,0 +1,209 @@ +//! Stable, bounded failure tokens for the MCP lane. +//! +//! Every reason string the lane surfaces is built here, from one of the three +//! cause enums below. Modules classify a failure; this module is the only one +//! that names it. That is what keeps the model-visible token set enumerable in +//! one file and every untrusted fragment bounded. + +/// Maximum byte length for a diagnostic reason string surfaced to the +/// runtime/model. These tokens carry protocol codes, HTTP statuses, and +/// bounded JSON-RPC messages through a private cause channel. They are still +/// untrusted and may contain secrets until the downstream model-visible scrub +/// seam processes them, so every reason is capped here as defense in depth. +pub(crate) const MAX_MCP_REASON_BYTES: usize = 512; + +/// Bound an untrusted diagnostic fragment to [`MAX_MCP_REASON_BYTES`], +/// truncating on a char boundary and appending an ellipsis marker so the +/// reader knows the value was clipped. +pub(crate) fn bound_mcp_reason_detail(detail: &str) -> String { + const ELLIPSIS: &str = "..."; + let normalized: String = detail + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + if normalized.len() <= MAX_MCP_REASON_BYTES { + return normalized; + } + let budget = MAX_MCP_REASON_BYTES.saturating_sub(ELLIPSIS.len()); + let mut end = budget; + while end > 0 && !normalized.is_char_boundary(end) { + end -= 1; + } + format!("{}{ELLIPSIS}", &normalized[..end]) +} + +/// Per-cause request-side (pre-send / planning) failure tokens. Each carries +/// a stable prefix so callers and the model can classify the failure, plus +/// bounded diagnostic detail where available. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum McpRequestDeniedCause { + /// JSON-RPC request body could not be encoded. + EncodeFailed(String), + /// The planned request has no target URL. + MissingUrl, + /// The requested transport is not host-mediated HTTP/SSE. + UnsupportedTransport, + /// A credential injection used a denied source over this boundary. + DeniedCredentialSource, + /// The in-memory session map lock was poisoned. + SessionStatePoisoned, +} + +impl McpRequestDeniedCause { + fn into_reason(self) -> String { + match self { + Self::EncodeFailed(detail) => { + format!( + "mcp_request_encode_failed: {}", + bound_mcp_reason_detail(&detail) + ) + } + Self::MissingUrl => "mcp_missing_url".to_string(), + Self::UnsupportedTransport => "mcp_unsupported_transport".to_string(), + Self::DeniedCredentialSource => "mcp_denied_credential_source".to_string(), + Self::SessionStatePoisoned => "mcp_session_state_poisoned".to_string(), + } + } +} + +/// Per-cause response-side failure tokens. Each carries a stable prefix plus +/// bounded diagnostic detail (HTTP status, JSON-RPC code/message, +/// parse-failure cause) for the private model-visible cause channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum McpResponseErrorCause { + /// Non-2xx HTTP status from the MCP endpoint. + HttpStatus(u16), + /// JSON-RPC `error` object with code and bounded message. + JsonRpcError { + code: Option, + message: Option, + }, + /// Response body failed JSON parsing. + ParseFailed(String), + /// A successful response carried no `result` field. + MissingResult, + /// The endpoint returned an unsafe/oversized `Mcp-Session-Id`. + InvalidSessionId, + /// The `initialize` response carried an unsafe/missing protocol version. + InvalidProtocolVersion, + /// JSON-RPC response `id` did not match the request id. + IdMismatch, + /// Response did not contain a usable JSON-RPC payload (e.g. SSE with no + /// matching data frame). + NoPayload, + /// Discovered `tools/list` result was malformed (shape/limits violation). + InvalidToolList(McpInvalidToolListCause), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum McpInvalidToolListCause { + MissingToolsArray, + TooManyTools, + InvalidToolName, + InvalidDescription, + MissingInputSchema, + UnsafeInputSchema, + InvalidAnnotations, + InvalidCursor, + TooManyPages, + CatalogTooLarge, +} + +impl McpInvalidToolListCause { + pub(crate) const fn stable_token(self) -> &'static str { + match self { + Self::MissingToolsArray => "missing_tools_array", + Self::TooManyTools => "too_many_tools", + Self::InvalidToolName => "invalid_tool_name", + Self::InvalidDescription => "invalid_description", + Self::MissingInputSchema => "missing_input_schema", + Self::UnsafeInputSchema => "unsafe_input_schema", + Self::InvalidAnnotations => "invalid_annotations", + Self::InvalidCursor => "invalid_cursor", + Self::TooManyPages => "too_many_pages", + Self::CatalogTooLarge => "catalog_too_large", + } + } +} + +impl McpResponseErrorCause { + fn into_reason(self) -> String { + match self { + Self::HttpStatus(status) => format!("mcp_http_status_{status}"), + Self::JsonRpcError { code, message } => { + let mut reason = String::from("mcp_jsonrpc_error"); + if let Some(code) = code { + reason.push_str(&format!(" code={code}")); + } + if let Some(message) = message { + reason.push_str(": "); + reason.push_str(&message); + } + reason + } + Self::ParseFailed(detail) => { + format!("mcp_parse_failed: {}", bound_mcp_reason_detail(&detail)) + } + Self::MissingResult => "mcp_missing_result".to_string(), + Self::InvalidSessionId => "mcp_invalid_session_id".to_string(), + Self::InvalidProtocolVersion => "mcp_invalid_protocol_version".to_string(), + Self::IdMismatch => "mcp_jsonrpc_id_mismatch".to_string(), + Self::NoPayload => "mcp_no_payload".to_string(), + Self::InvalidToolList(cause) => { + format!("mcp_invalid_tool_list: {}", cause.stable_token()) + } + } + } +} + +pub(crate) fn request_denied(cause: McpRequestDeniedCause) -> String { + cause.into_reason() +} + +pub(crate) fn response_error(cause: McpResponseErrorCause) -> String { + cause.into_reason() +} + +pub(crate) fn invalid_tool_list(cause: McpInvalidToolListCause) -> String { + response_error(McpResponseErrorCause::InvalidToolList(cause)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_denied_causes_map_to_stable_tokens() { + assert_eq!( + request_denied(McpRequestDeniedCause::MissingUrl), + "mcp_missing_url" + ); + assert_eq!( + request_denied(McpRequestDeniedCause::UnsupportedTransport), + "mcp_unsupported_transport" + ); + assert_eq!( + request_denied(McpRequestDeniedCause::DeniedCredentialSource), + "mcp_denied_credential_source" + ); + assert_eq!( + request_denied(McpRequestDeniedCause::SessionStatePoisoned), + "mcp_session_state_poisoned" + ); + let encode = request_denied(McpRequestDeniedCause::EncodeFailed("eof".to_string())); + assert!(encode.starts_with("mcp_request_encode_failed: ")); + assert!(encode.contains("eof")); + } + + #[test] + fn reason_detail_is_bounded_and_strips_control_chars() { + let long = "a".repeat(10_000); + let bounded = bound_mcp_reason_detail(&long); + assert!(bounded.len() <= MAX_MCP_REASON_BYTES); + assert!(bounded.ends_with("...")); + + let with_control = bound_mcp_reason_detail("line\nbreak\u{0000}null"); + assert!(!with_control.contains('\n')); + assert!(!with_control.contains('\u{0000}')); + } +} diff --git a/crates/ironclaw_mcp/src/discovery.rs b/crates/ironclaw_mcp/src/discovery.rs new file mode 100644 index 00000000000..0e250cd6908 --- /dev/null +++ b/crates/ironclaw_mcp/src/discovery.rs @@ -0,0 +1,625 @@ +//! `tools/list` catalog admission. +//! +//! A hosted MCP server advertises tools; this module decides which of them the +//! host will publish. It owns the ceilings (tool count, page count, aggregate +//! bytes), the per-tool classification that separates a shape-only defect from +//! a security/bounds violation, and the grammar a discovered tool name must +//! satisfy before it becomes a Reborn capability suffix. It never sends or +//! receives a request — `client` runs the loop and reads these same constants, +//! so the two enforcement points cannot drift apart. + +use ironclaw_extension_contracts::hosted_mcp::{ + HostedMcpDiscoveredTool, HostedMcpDiscoveredToolAnnotations, +}; +use serde_json::Value; + +use crate::diagnostics::{McpInvalidToolListCause, invalid_tool_list}; + +/// Maximum number of tools accepted from a hosted MCP `tools/list` discovery +/// pass, across all pages. Shared by the discovery loop's running-total check +/// and [`parse_tools_list_result`]'s per-page cap so the two enforcement +/// points cannot drift apart. +pub(crate) const MAX_DISCOVERED_MCP_TOOLS: usize = 1024; + +/// Maximum number of `tools/list` pagination pages followed during a single +/// discovery pass. +pub(crate) const MAX_MCP_TOOLS_LIST_PAGES: usize = 50; + +/// Maximum aggregate serialized bytes accepted across all `tools/list` pages +/// during a single discovery pass. +pub(crate) const MAX_MCP_TOOLS_CATALOG_BYTES: usize = 16 * 1024 * 1024; + +pub(crate) fn parse_tools_list_result( + value: &Value, + manifest_max_tools: u32, +) -> Result, String> { + const MAX_TOOL_NAME_BYTES: usize = 128; + const MAX_TOOL_DESCRIPTION_BYTES: usize = 2048; + const MAX_SCHEMA_DEPTH: u8 = 32; + const MAX_SCHEMA_NODES: usize = 8192; + const MAX_SCHEMA_STRING_BYTES: usize = 16 * 1024; + + let tools = value + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| invalid_tool_list(McpInvalidToolListCause::MissingToolsArray))?; + let manifest_max_tools = usize::try_from(manifest_max_tools) + .unwrap_or(MAX_DISCOVERED_MCP_TOOLS) + .min(MAX_DISCOVERED_MCP_TOOLS); + if manifest_max_tools == 0 || tools.len() > manifest_max_tools { + return Err(invalid_tool_list(McpInvalidToolListCause::TooManyTools)); + } + + // Catalog acceptance distinguishes shape-only defects from security/bounds + // violations. A single tool with a shape-only defect (an unsupported name, + // an invalid description, or malformed annotations) is dropped from this + // generation and recorded, so one malformed entry cannot brick an otherwise + // valid integration that has no prior generation to fall back to. A + // security/bounds violation (missing or unsafe input schema — checked first + // per tool so a co-occurring cosmetic defect cannot downgrade it — or a + // catalog that overflows the host cap) still rejects the whole generation + // with a stable safe subcause; the previous published generation, if any, + // remains authoritative until a complete bounded catalog is discovered. + let mut published = Vec::with_capacity(tools.len()); + let mut first_skipped_cause: Option = None; + for (index, tool) in tools.iter().enumerate() { + match classify_discovered_tool( + tool, + MAX_TOOL_NAME_BYTES, + MAX_TOOL_DESCRIPTION_BYTES, + MAX_SCHEMA_DEPTH, + MAX_SCHEMA_NODES, + MAX_SCHEMA_STRING_BYTES, + ) + .map_err(invalid_tool_list)? + { + DiscoveredToolClassification::Published(discovered) => published.push(discovered), + DiscoveredToolClassification::SkippedShapeViolation(cause) => { + first_skipped_cause.get_or_insert(cause); + // Bounded, provider-neutral record: the tool index and stable + // cause token only — never the raw provider-supplied content. + tracing::debug!( + tool_index = index, + skip_cause = cause.stable_token(), + "skipping shape-nonconforming hosted MCP tool from discovery catalog" + ); + } + } + } + if published.is_empty() + && let Some(cause) = first_skipped_cause + { + // Every advertised tool was shape-nonconforming: there is nothing to + // publish, so fail this generation non-retryably with a stable subcause + // rather than activating on an empty catalog. An empty provider list + // (no tools advertised, nothing skipped) is left as an empty result the + // caller treats as "no tools discovered yet". + return Err(invalid_tool_list(cause)); + } + Ok(published) +} + +pub(crate) fn parse_tools_list_page( + value: &Value, +) -> Result<(Vec, Option), String> { + let tools = parse_tools_list_result(value, MAX_DISCOVERED_MCP_TOOLS as u32)?; + let next_cursor = match value.get("nextCursor") { + None | Some(Value::Null) => None, + Some(Value::String(cursor)) + if !cursor.is_empty() + && cursor.len() <= 4_096 + && !cursor.chars().any(|character| character.is_control()) => + { + Some(cursor.clone()) + } + Some(_) => return Err(invalid_tool_list(McpInvalidToolListCause::InvalidCursor)), + }; + Ok((tools, next_cursor)) +} + +/// Result of classifying one advertised MCP tool during discovery. +enum DiscoveredToolClassification { + /// The tool conforms to the host contract and is published. + Published(HostedMcpDiscoveredTool), + /// The tool violates a shape-only, non-security rule and is dropped from + /// this generation while the rest of a bounded catalog still publishes. + SkippedShapeViolation(McpInvalidToolListCause), +} + +/// Classify a single advertised tool. Security/bounds violations (missing or +/// unsafe input schema) return `Err(cause)` and reject the whole generation; +/// they are evaluated first so a co-occurring cosmetic defect cannot downgrade +/// them to a per-tool skip. Shape-only defects return +/// `Ok(SkippedShapeViolation(cause))`. +fn classify_discovered_tool( + tool: &Value, + max_name_bytes: usize, + max_description_bytes: usize, + max_schema_depth: u8, + max_schema_nodes: usize, + max_schema_string_bytes: usize, +) -> Result { + let input_schema = tool + .get("inputSchema") + .filter(|schema| schema.is_object()) + .cloned() + .ok_or(McpInvalidToolListCause::MissingInputSchema)?; + if !is_supported_mcp_input_schema( + &input_schema, + max_schema_depth, + max_schema_nodes, + max_schema_string_bytes, + ) { + return Err(McpInvalidToolListCause::UnsafeInputSchema); + } + // Discovered tool names become Reborn capability suffixes, so discovery + // skips unsupported names instead of normalizing them into potentially + // colliding capability IDs. + let Some(name) = tool + .get("name") + .and_then(Value::as_str) + .filter(|name| is_supported_mcp_tool_name(name, max_name_bytes)) + else { + return Ok(DiscoveredToolClassification::SkippedShapeViolation( + McpInvalidToolListCause::InvalidToolName, + )); + }; + let description = tool + .get("description") + .and_then(Value::as_str) + .unwrap_or(""); + let Some(description) = bound_mcp_tool_description(description, max_description_bytes) else { + return Ok(DiscoveredToolClassification::SkippedShapeViolation( + McpInvalidToolListCause::InvalidDescription, + )); + }; + let annotations = match parse_tool_annotations(tool.get("annotations")) { + Ok(annotations) => annotations, + Err(cause) => return Ok(DiscoveredToolClassification::SkippedShapeViolation(cause)), + }; + Ok(DiscoveredToolClassification::Published( + HostedMcpDiscoveredTool { + name: name.to_string(), + description: description.to_string(), + input_schema, + annotations, + }, + )) +} + +fn is_supported_mcp_input_schema( + schema: &Value, + max_depth: u8, + max_nodes: usize, + max_string_bytes: usize, +) -> bool { + let mut nodes = 0usize; + validate_mcp_schema_value( + schema, + 0, + max_depth, + max_nodes, + max_string_bytes, + &mut nodes, + ) +} + +fn validate_mcp_schema_value( + value: &Value, + depth: u8, + max_depth: u8, + max_nodes: usize, + max_string_bytes: usize, + nodes: &mut usize, +) -> bool { + if depth > max_depth { + return false; + } + *nodes = nodes.saturating_add(1); + if *nodes > max_nodes { + return false; + } + match value { + Value::String(value) => { + value.len() <= max_string_bytes && !value.chars().any(is_unsupported_description_char) + } + Value::Array(values) => values.iter().all(|value| { + validate_mcp_schema_value( + value, + depth + 1, + max_depth, + max_nodes, + max_string_bytes, + nodes, + ) + }), + Value::Object(values) => values.iter().all(|(key, value)| { + key.len() <= max_string_bytes + && !key.chars().any(is_unsupported_description_char) + && validate_mcp_schema_value( + value, + depth + 1, + max_depth, + max_nodes, + max_string_bytes, + nodes, + ) + }), + _ => true, + } +} + +fn is_unsupported_description_char(value: char) -> bool { + value.is_control() && !matches!(value, '\n' | '\r' | '\t') +} + +/// Preserve a provider's otherwise-valid tool catalog when only descriptive +/// prose exceeds the host display/prompt budget. Names and schemas remain +/// fail-closed because truncating either could change capability semantics; +/// descriptions are presentation metadata and can be safely bounded. +fn bound_mcp_tool_description(value: &str, max_bytes: usize) -> Option { + if value.chars().any(is_unsupported_description_char) { + return None; + } + if value.len() <= max_bytes { + return Some(value.to_string()); + } + + const TRUNCATION_MARKER: &str = "..."; + if max_bytes <= TRUNCATION_MARKER.len() { + return Some(".".repeat(max_bytes)); + } + + let mut end = max_bytes - TRUNCATION_MARKER.len(); + while !value.is_char_boundary(end) { + end -= 1; + } + let prefix = value.get(..end)?; + let mut bounded = String::with_capacity(max_bytes); + bounded.push_str(prefix); + bounded.push_str(TRUNCATION_MARKER); + Some(bounded) +} + +fn parse_tool_annotations( + value: Option<&Value>, +) -> Result { + let Some(value) = value else { + return Ok(HostedMcpDiscoveredToolAnnotations::default()); + }; + let object = value + .as_object() + .ok_or(McpInvalidToolListCause::InvalidAnnotations)?; + let title = object + .get("title") + .map(|value| { + value + .as_str() + .and_then(|title| bound_mcp_tool_description(title, 2_048)) + .ok_or(McpInvalidToolListCause::InvalidAnnotations) + }) + .transpose()?; + Ok(HostedMcpDiscoveredToolAnnotations { + title, + destructive_hint: object + .get("destructiveHint") + .and_then(Value::as_bool) + .unwrap_or(false), + side_effects_hint: object + .get("sideEffectsHint") + .and_then(Value::as_bool) + .unwrap_or(false), + read_only_hint: object + .get("readOnlyHint") + .and_then(Value::as_bool) + .unwrap_or(false), + idempotent_hint: object.get("idempotentHint").and_then(Value::as_bool), + open_world_hint: object.get("openWorldHint").and_then(Value::as_bool), + }) +} + +fn is_supported_mcp_tool_name(value: &str, max_bytes: usize) -> bool { + if value.is_empty() || value.len() > max_bytes || value.contains("..") { + return false; + } + value.split('.').all(is_supported_mcp_tool_name_segment) +} + +fn is_supported_mcp_tool_name_segment(segment: &str) -> bool { + let Some(first) = segment.as_bytes().first().copied() else { + return false; + }; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + segment.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_tools_list_result_rejects_oversized_tool_list() { + let tools = (0..129) + .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) + .collect::>(); + + let error = parse_tools_list_result(&json!({ "tools": tools }), 128) + .expect_err("tool discovery must cap returned tools"); + + assert_eq!(error, "mcp_invalid_tool_list: too_many_tools"); + } + + #[test] + fn parse_tools_list_result_honors_manifest_budget_under_host_cap() { + let tools = (0..129) + .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) + .collect::>(); + + let discovered = parse_tools_list_result(&json!({ "tools": tools }), 256) + .expect("the manifest may declare a catalog larger than the old hidden limit"); + + assert_eq!(discovered.len(), 129); + } + + #[test] + fn parse_tools_list_result_caps_manifest_budget_at_host_maximum() { + let tools = (0..1025) + .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) + .collect::>(); + + let error = parse_tools_list_result(&json!({ "tools": tools }), u32::MAX) + .expect_err("provider-declared budgets cannot exceed the host ceiling"); + + assert_eq!(error, "mcp_invalid_tool_list: too_many_tools"); + } + + #[test] + fn parse_tools_list_result_rejects_unsupported_description_control_char() { + let mut tool = valid_tool("search", json!({"type": "object"})); + tool["description"] = json!("bad\u{0000}description"); + + let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) + .expect_err("unsupported description control characters must fail"); + + assert_eq!(error, "mcp_invalid_tool_list: invalid_description"); + } + + #[test] + fn parse_tools_list_result_bounds_utf8_description_at_character_boundary() { + let mut tool = valid_tool("search", json!({"type": "object"})); + tool["description"] = json!("🔧".repeat(600)); + + let tools = parse_tools_list_result(&json!({ "tools": [tool] }), 128) + .expect("descriptive prose must not invalidate the catalog"); + let description = &tools[0].description; + + assert!(description.len() <= 2_048); + assert!(description.ends_with("...")); + assert!(description.is_char_boundary(description.len())); + } + + #[test] + fn parse_tools_list_result_accepts_bounded_real_world_openapi_schema_shape() { + // OpenAPI-derived MCP catalogs legitimately exceed the old depth-8 / + // 512-node parser constants. The response body remains independently + // bounded by the host egress plan, so a safe, finite schema within the + // catalog budget must not make the whole extension unactivatable. + let tool = valid_tool( + "update-resource", + json!({ + "type": "object", + "properties": { + "nested": nested_schema(5), + "wide": wide_schema(600) + } + }), + ); + + let tools = parse_tools_list_result(&json!({ "tools": [tool] }), 128) + .expect("bounded OpenAPI-derived schemas must remain discoverable"); + + assert_eq!(tools.len(), 1); + } + + #[test] + fn parse_tools_list_result_rejects_missing_or_non_object_schema() { + let mut missing_schema = valid_tool("missing-schema", json!({"type": "object"})); + missing_schema + .as_object_mut() + .expect("test tool object") + .remove("inputSchema"); + let non_object_schema = valid_tool("bad-schema", json!("object please")); + + for tool in [missing_schema, non_object_schema] { + let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) + .expect_err("schema must be present and object-shaped"); + + assert_eq!(error, "mcp_invalid_tool_list: missing_input_schema"); + } + } + + #[test] + fn parse_tools_list_result_rejects_unsafe_schema_strings_and_shape() { + let cases = [ + valid_tool( + "control", + json!({"type": "object", "description": "bad\u{0008}schema"}), + ), + valid_tool( + "long-string", + json!({"type": "object", "description": "a".repeat(16 * 1024 + 1)}), + ), + valid_tool("too-deep", nested_schema(17)), + valid_tool("too-many-nodes", wide_schema(8193)), + ]; + + for tool in cases { + let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) + .expect_err("unsafe schema strings and shape must fail"); + + assert_eq!(error, "mcp_invalid_tool_list: unsafe_input_schema"); + } + } + + #[test] + #[tracing_test::traced_test] + fn parse_tools_list_result_skips_shape_invalid_tools_and_publishes_bounded_remainder() { + // A real MCP server can advertise a mostly-valid catalog alongside a + // few shape-nonconforming entries (an uppercase tool name, a + // control-char description). Those individual tools are dropped and + // recorded, but the remaining valid tools must still publish so one + // malformed entry cannot brick the whole integration on first install. + let mut tools = (0..24) + .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) + .collect::>(); + tools[5]["name"] = json!("UppercaseName"); + tools[10]["description"] = json!("bad\u{0000}description"); + + let published = parse_tools_list_result(&json!({ "tools": tools }), 128) + .expect("a bounded catalog must survive a few shape-nonconforming tools"); + + assert_eq!(published.len(), 22); + assert!( + published.iter().all(|tool| tool.name != "UppercaseName"), + "the uppercase-named tool must not be published" + ); + assert!( + published.iter().any(|tool| tool.name == "tool-0"), + "valid tools before the skipped entries must still publish" + ); + assert!( + published.iter().any(|tool| tool.name == "tool-23"), + "valid tools after the skipped entries must still publish" + ); + assert!(logs_contain("skipping shape-nonconforming hosted MCP tool")); + assert!(logs_contain("invalid_tool_name")); + assert!(logs_contain("invalid_description")); + } + + #[test] + fn parse_tools_list_result_fails_whole_catalog_when_unsafe_schema_amid_valid_tools() { + // Security/bounds violations are never downgraded to a per-tool skip: + // a single over-deep (DoS-shaped) input schema fails the entire + // generation even when it is surrounded by otherwise-valid tools, so a + // hostile entry cannot smuggle itself in by riding a valid catalog. + let mut tools = vec![ + valid_tool("alpha", json!({"type": "object"})), + valid_tool("beta", json!({"type": "object"})), + ]; + tools.insert(1, valid_tool("too-deep", nested_schema(64))); + + let error = parse_tools_list_result(&json!({ "tools": tools }), 128) + .expect_err("an unsafe schema must fail the whole catalog even with valid neighbors"); + + assert_eq!(error, "mcp_invalid_tool_list: unsafe_input_schema"); + } + + #[test] + fn parse_tools_list_result_fails_when_every_tool_is_shape_invalid() { + // When nothing survives the shape filter there is nothing to publish, + // so discovery still fails non-retryably with a stable subcause rather + // than activating on an empty catalog. + let tools = vec![ + valid_tool("Uppercase-A", json!({"type": "object"})), + valid_tool("Uppercase-B", json!({"type": "object"})), + ] + .into_iter() + .map(|mut tool| { + let bad = tool["name"].as_str().unwrap().to_string(); + tool["name"] = json!(bad); + tool + }) + .collect::>(); + + let error = parse_tools_list_result(&json!({ "tools": tools }), 128) + .expect_err("a catalog with no shape-valid tools must not activate"); + + assert_eq!(error, "mcp_invalid_tool_list: invalid_tool_name"); + } + + #[test] + fn parse_tools_list_result_preserves_empty_provider_catalog_as_empty() { + // An empty provider list (no advertised tools, nothing skipped) is not + // a shape failure: it stays an empty result the caller treats as "no + // tools discovered yet", distinct from the all-skipped failure above. + let published = parse_tools_list_result(&json!({ "tools": [] }), 128) + .expect("an empty provider catalog is not a shape failure"); + + assert!(published.is_empty()); + } + + #[test] + fn is_supported_mcp_tool_name_boundary_cases() { + let exactly_128 = "a".repeat(128); + let too_long = "a".repeat(129); + + assert!(!is_supported_mcp_tool_name("", 128)); + assert!(is_supported_mcp_tool_name(&exactly_128, 128)); + assert!(!is_supported_mcp_tool_name(&too_long, 128)); + assert!(!is_supported_mcp_tool_name("search..issues", 128)); + assert!(!is_supported_mcp_tool_name("Search", 128)); + assert!(!is_supported_mcp_tool_name("search._private", 128)); + } + + #[test] + fn tools_list_page_preserves_accepted_catalog_fields_exactly() { + let schema = json!({"type": "object", "properties": {"q": {"type": "string"}}}); + let value = json!({ + "tools": [{ + "name": "search.docs", + "description": "Find docs\nwithout rewriting provider text.", + "inputSchema": schema, + "annotations": {"readOnlyHint": true} + }], + "nextCursor": "second-page" + }); + + let (tools, cursor) = parse_tools_list_page(&value).expect("valid page"); + assert_eq!(cursor.as_deref(), Some("second-page")); + assert_eq!(tools[0].name, "search.docs"); + assert_eq!( + tools[0].description, + "Find docs\nwithout rewriting provider text." + ); + assert_eq!(tools[0].input_schema, schema); + assert!(tools[0].annotations.read_only_hint); + } + + #[test] + fn tools_list_page_rejects_non_string_cursor() { + let error = parse_tools_list_page(&json!({ + "tools": [valid_tool("search", json!({"type": "object"}))], + "nextCursor": 12 + })) + .expect_err("cursor is protocol data, not a value to normalize"); + assert_eq!(error, "mcp_invalid_tool_list: invalid_cursor"); + } + + fn valid_tool(name: &str, input_schema: Value) -> Value { + json!({ + "name": name, + "description": "Search hosted data", + "inputSchema": input_schema + }) + } + + fn nested_schema(depth: usize) -> Value { + let mut value = json!({"type": "string"}); + for _ in 0..depth { + value = json!({"type": "object", "properties": {"next": value}}); + } + value + } + + fn wide_schema(nodes: usize) -> Value { + let properties = (0..nodes) + .map(|index| (format!("field_{index}"), json!({"type": "string"}))) + .collect::>(); + json!({"type": "object", "properties": properties}) + } +} diff --git a/crates/ironclaw_mcp/src/egress.rs b/crates/ironclaw_mcp/src/egress.rs new file mode 100644 index 00000000000..d37326c1169 --- /dev/null +++ b/crates/ironclaw_mcp/src/egress.rs @@ -0,0 +1,190 @@ +//! The host-mediated HTTP seam. +//! +//! The MCP lane performs no networking of its own. It hands a +//! `CapabilityHostHttpRequest` to the [`McpHostHttp`] port, and the host-owned +//! [`McpHostHttpEgressPlanner`] — not the plugin input — supplies network +//! policy, credential handles, response limits, and timeouts. Everything that +//! decides *what* to send lives in `client`/`jsonrpc`; this module only decides +//! *how it leaves*. + +use std::panic::AssertUnwindSafe; +use std::sync::Arc; + +use async_trait::async_trait; +use futures_util::FutureExt as _; +use ironclaw_host_api::{ + action::{NetworkMethod, NetworkPolicy}, + http::{ + CapabilityHostHttpRequest, RuntimeCredentialInjection, RuntimeHttpEgress, + RuntimeHttpEgressError, RuntimeHttpEgressResponse, + }, + ids::{CapabilityId, ExtensionId}, + resource::ResourceScope, + runtime::RuntimeKind, +}; +use thiserror::Error; + +use crate::contract::McpClientError; + +pub type McpHostHttpResponse = RuntimeHttpEgressResponse; + +#[derive(Debug, Error)] +pub enum McpHostHttpError { + #[error("MCP host HTTP error: {reason}")] + Egress { reason: String }, +} + +#[derive(Debug, Clone)] +pub struct McpRuntimeHttpAdapter { + egress: E, +} + +impl McpRuntimeHttpAdapter +where + E: RuntimeHttpEgress, +{ + pub fn new(egress: E) -> Self { + Self { egress } + } + + pub async fn request( + &self, + request: CapabilityHostHttpRequest, + ) -> Result { + AssertUnwindSafe( + self.egress + .execute(request.into_runtime_request(RuntimeKind::Mcp)), + ) + .catch_unwind() + .await + .map_err(|_| McpHostHttpError::Egress { + reason: "runtime_http_egress_panicked".to_string(), + })? + .map_err(mcp_http_error) + } +} + +fn mcp_http_error(error: RuntimeHttpEgressError) -> McpHostHttpError { + McpHostHttpError::Egress { + reason: error.stable_runtime_reason().to_string(), + } +} + +#[async_trait] +pub trait McpHostHttp: Send + Sync { + async fn request( + &self, + request: CapabilityHostHttpRequest, + ) -> Result; +} + +#[async_trait] +impl McpHostHttp for McpRuntimeHttpAdapter +where + E: RuntimeHttpEgress + Send + Sync, +{ + async fn request( + &self, + request: CapabilityHostHttpRequest, + ) -> Result { + McpRuntimeHttpAdapter::request(self, request).await + } +} + +#[async_trait] +impl McpHostHttp for Arc +where + T: McpHostHttp + ?Sized + Send + Sync, +{ + async fn request( + &self, + request: CapabilityHostHttpRequest, + ) -> Result { + self.as_ref().request(request).await + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct McpHostHttpEgressPlan { + pub network_policy: NetworkPolicy, + pub credential_injections: Vec, + pub response_body_limit: Option, + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct McpHostHttpEgressPlanRequest<'a> { + pub provider: &'a ExtensionId, + pub capability_id: &'a CapabilityId, + pub scope: &'a ResourceScope, + pub transport: &'a str, + pub method: NetworkMethod, + pub url: &'a str, + pub headers: &'a [(String, String)], + pub body: &'a [u8], +} + +/// Host-owned egress planner for MCP HTTP/SSE requests. +/// +/// The planner is intentionally separate from [`McpClientRequest::input`](crate::McpClientRequest::input): +/// runtime/plugin inputs can affect the JSON-RPC body, but only this host-owned +/// planner can provide network policy, credential handles, response limits, and +/// timeouts for the shared egress service. +/// +/// `plan` must be deterministic and side-effect-free. The concrete HTTP client +/// plans the real `tools/call` body once before the MCP handshake, validates +/// its credential sources, then threads that plan into the later `tools/call` +/// transport send. Planner-visible headers are stable policy headers only; the +/// dynamic MCP session header is added by the protocol client after planning. +/// Hosted MCP providers may require authentication for the entire JSON-RPC +/// session, including initialization, so staged credentials must remain scoped +/// to the invocation until the capability dispatch completes. +pub trait McpHostHttpEgressPlanner: Send + Sync { + fn plan(&self, request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan; +} + +impl McpHostHttpEgressPlanner for Arc +where + T: McpHostHttpEgressPlanner + ?Sized, +{ + fn plan(&self, request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan { + self.as_ref().plan(request) + } +} + +#[derive(Debug, Clone)] +pub struct StaticMcpHostHttpEgressPlanner { + plan: McpHostHttpEgressPlan, +} + +impl StaticMcpHostHttpEgressPlanner { + pub fn new(plan: McpHostHttpEgressPlan) -> Self { + Self { plan } + } +} + +impl McpHostHttpEgressPlanner for StaticMcpHostHttpEgressPlanner { + fn plan(&self, _request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan { + self.plan.clone() + } +} + +pub(crate) fn mcp_client_http_error(error: McpHostHttpError) -> McpClientError { + match error { + McpHostHttpError::Egress { reason } => McpClientError::client(reason), + } +} + +pub(crate) fn effective_mcp_response_body_limit( + host_limit: Option, + client_limit: u64, +) -> Option { + Some(match host_limit { + Some(limit) => limit.min(client_limit), + None => client_limit, + }) +} + +pub(crate) fn requires_host_http_egress(transport: &str) -> bool { + matches!(transport, "http" | "sse") +} diff --git a/crates/ironclaw_mcp/src/jsonrpc.rs b/crates/ironclaw_mcp/src/jsonrpc.rs new file mode 100644 index 00000000000..c50a177b5f5 --- /dev/null +++ b/crates/ironclaw_mcp/src/jsonrpc.rs @@ -0,0 +1,658 @@ +//! The JSON-RPC 2.0 codec and MCP response hygiene. +//! +//! One request is encoded here and one response is parsed here, in either +//! framing the client advertises (`application/json` and `text/event-stream`). +//! Everything this module validates is untrusted remote input: the response +//! `id`, the `Mcp-Session-Id`, the negotiated protocol version, and the +//! auth-challenge headers. Session *state* belongs to `client`; tool-shape +//! rules belong to `discovery`. + +use ironclaw_extension_contracts::hosted_mcp::McpAuthChallenge; +use ironclaw_host_api::{ + http::{RuntimeCredentialInjection, RuntimeCredentialSource}, + resource::ResourceUsage, +}; +use serde_json::Value; + +use crate::diagnostics::{ + McpRequestDeniedCause, McpResponseErrorCause, bound_mcp_reason_detail, request_denied, + response_error, +}; +use crate::egress::McpHostHttpResponse; + +pub(crate) const STREAMABLE_HTTP_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; +pub(crate) const MCP_PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version"; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct McpJsonRpcResponse { + pub(crate) result: Option, + pub(crate) error: Option, +} + +/// Bounded view of a JSON-RPC `error` object surfaced through the private +/// model-visible cause channel. The server-provided `message` remains untrusted: +/// it is scrubbed at the model-visible diagnostic seam before reaching the model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct JsonRpcErrorInfo { + pub(crate) code: Option, + pub(crate) message: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct McpJsonRpcExchange { + pub(crate) response: McpJsonRpcResponse, + pub(crate) session_id: Option, + pub(crate) usage: ResourceUsage, +} + +/// Known MCP JSON-RPC methods whose credential-routing behavior is host-owned. +/// +/// Hosted MCP providers may require bearer authentication for the whole +/// JSON-RPC session, including `initialize` and notifications. The host egress +/// planner remains the source of truth for which staged credentials may be +/// sent to the provider URL, and direct secret-store leases are rejected before +/// outbound transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum McpJsonRpcMethod { + Initialize, + InitializedNotification, + ToolsList, + ToolsCall, +} + +impl McpJsonRpcMethod { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::InitializedNotification => "notifications/initialized", + Self::ToolsList => "tools/list", + Self::ToolsCall => "tools/call", + } + } + + pub(crate) fn credential_injections( + self, + credential_injections: Vec, + ) -> Result, String> { + if credential_injections + .iter() + .any(|injection| matches!(injection.source, RuntimeCredentialSource::SecretStoreLease)) + { + return Err(request_denied( + McpRequestDeniedCause::DeniedCredentialSource, + )); + } + Ok(credential_injections) + } +} + +/// Validate credential injections planned for a `tools/call` request without +/// consuming the list, so the caller can reuse it in the actual send. +/// +/// Returns `Err(denied)` if any injection uses a [`RuntimeCredentialSource::SecretStoreLease`], +/// which is not permitted over the MCP `tools/call` boundary. +pub(crate) fn validate_tools_call_credential_injections( + credential_injections: &[RuntimeCredentialInjection], +) -> Result<(), String> { + validate_staged_credential_injections(credential_injections) +} + +pub(crate) fn validate_staged_credential_injections( + credential_injections: &[RuntimeCredentialInjection], +) -> Result<(), String> { + if credential_injections + .iter() + .any(|injection| matches!(injection.source, RuntimeCredentialSource::SecretStoreLease)) + { + return Err(request_denied( + McpRequestDeniedCause::DeniedCredentialSource, + )); + } + Ok(()) +} + +pub(crate) fn is_mcp_auth_response_status(status: u16) -> bool { + matches!(status, 401 | 403) +} + +pub(crate) fn mcp_auth_challenge_from_response(response: &McpHostHttpResponse) -> McpAuthChallenge { + let mut www_authenticate_metadata = Vec::new(); + let mut protected_resource_metadata = Vec::new(); + for (name, value) in &response.headers { + if name.eq_ignore_ascii_case("www-authenticate") { + www_authenticate_metadata.extend( + ironclaw_extension_contracts::hosted_mcp::extract_mcp_auth_metadata_locations( + value, + ), + ); + } else if name.eq_ignore_ascii_case("protected-resource-metadata") { + protected_resource_metadata.extend( + ironclaw_extension_contracts::hosted_mcp::extract_mcp_auth_metadata_locations( + value, + ), + ); + } + } + McpAuthChallenge { + status: response.status, + www_authenticate_metadata, + protected_resource_metadata, + } +} + +fn is_safe_mcp_session_id(value: &str) -> bool { + const MAX_MCP_SESSION_ID_BYTES: usize = 1024; + !value.is_empty() + && value.len() <= MAX_MCP_SESSION_ID_BYTES + && value.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) +} + +pub(crate) fn mcp_session_id_from_response( + response: &McpHostHttpResponse, +) -> Result, String> { + let Some((_, value)) = response + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("Mcp-Session-Id")) + else { + return Ok(None); + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if !is_safe_mcp_session_id(trimmed) { + return Err(response_error(McpResponseErrorCause::InvalidSessionId)); + } + Ok(Some(trimmed.to_string())) +} + +fn is_safe_mcp_protocol_version(value: &str) -> bool { + const MAX_MCP_PROTOCOL_VERSION_BYTES: usize = 64; + !value.is_empty() + && value.len() <= MAX_MCP_PROTOCOL_VERSION_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +pub(crate) fn protocol_version_from_initialize_response( + response: &McpJsonRpcResponse, +) -> Result { + let Some(protocol_version) = response + .result + .as_ref() + .and_then(|result| result.get("protocolVersion")) + .and_then(Value::as_str) + else { + return Err(response_error( + McpResponseErrorCause::InvalidProtocolVersion, + )); + }; + if !is_safe_mcp_protocol_version(protocol_version) { + return Err(response_error( + McpResponseErrorCause::InvalidProtocolVersion, + )); + } + Ok(protocol_version.to_string()) +} + +pub(crate) fn encode_json_rpc_request( + id: Option, + method: &str, + params: Option, +) -> Result, String> { + let mut object = serde_json::Map::new(); + object.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); + if let Some(id) = id { + object.insert( + "id".to_string(), + Value::Number(serde_json::Number::from(id)), + ); + } + object.insert("method".to_string(), Value::String(method.to_string())); + if let Some(params) = params { + object.insert("params".to_string(), params); + } + serde_json::to_vec(&Value::Object(object)) + .map_err(|err| request_denied(McpRequestDeniedCause::EncodeFailed(err.to_string()))) +} + +pub(crate) fn parse_mcp_response( + response: &McpHostHttpResponse, + expected_id: Option, +) -> Result { + if response_is_sse(response) { + parse_mcp_sse_response(&response.body, expected_id) + } else { + let value = serde_json::from_slice::(&response.body) + .map_err(|err| response_error(McpResponseErrorCause::ParseFailed(err.to_string())))?; + parse_mcp_json_rpc_value(&value, expected_id) + } +} + +fn response_is_sse(response: &McpHostHttpResponse) -> bool { + response.headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("content-type") + && value.to_ascii_lowercase().contains("text/event-stream") + }) +} + +fn parse_mcp_sse_response( + body: &[u8], + expected_id: Option, +) -> Result { + let text = std::str::from_utf8(body) + .map_err(|err| response_error(McpResponseErrorCause::ParseFailed(err.to_string())))?; + let mut event_data = String::new(); + for line in text.lines().chain(std::iter::once("")) { + if !line.is_empty() { + let Some(payload) = line.strip_prefix("data:") else { + continue; + }; + let payload = payload.strip_prefix(' ').unwrap_or(payload); + if !event_data.is_empty() { + event_data.push('\n'); + } + event_data.push_str(payload); + continue; + } + if event_data.trim().is_empty() { + event_data.clear(); + continue; + } + let value = serde_json::from_str::(&event_data); + event_data.clear(); + let Ok(value) = value else { + continue; + }; + let parsed_id = json_rpc_id(&value); + if expected_id.is_none() || parsed_id == expected_id { + return parse_mcp_json_rpc_value(&value, expected_id); + } + } + Err(response_error(McpResponseErrorCause::NoPayload)) +} + +fn parse_mcp_json_rpc_value( + value: &Value, + expected_id: Option, +) -> Result { + let parsed_id = json_rpc_id(value); + if let Some(expected) = expected_id + && parsed_id != Some(expected) + { + return Err(response_error(McpResponseErrorCause::IdMismatch)); + } + Ok(McpJsonRpcResponse { + result: value.get("result").cloned(), + error: parse_json_rpc_error_info(value.get("error")), + }) +} + +/// Extract a bounded view of a JSON-RPC `error` object. Returns +/// `None` when no `error` member is present. A non-object `error` member still +/// counts as an error, but carries no structured code/message. +fn parse_json_rpc_error_info(error: Option<&Value>) -> Option { + let error = error?; + let code = error.get("code").and_then(Value::as_i64); + let message = error + .get("message") + .and_then(Value::as_str) + .map(bound_mcp_reason_detail); + Some(JsonRpcErrorInfo { code, message }) +} + +fn json_rpc_id(value: &Value) -> Option { + match value.get("id") { + Some(Value::Number(number)) => number.as_u64(), + Some(Value::String(value)) => value.parse::().ok(), + _ => None, + } +} + +pub(crate) fn json_rpc_initialize_params() -> Value { + serde_json::json!({ + "protocolVersion": STREAMABLE_HTTP_MCP_PROTOCOL_VERSION, + "capabilities": { + "roots": { "listChanged": false }, + "sampling": {} + }, + "clientInfo": { + "name": "ironclaw", + "version": env!("CARGO_PKG_VERSION") + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_mcp_sse_response_skips_empty_data_keepalives() { + let body = b"event: ping\ndata:\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n"; + + let response = parse_mcp_sse_response(body, Some(7)) + .expect("empty SSE data lines should not abort parsing"); + + assert_eq!(response.result, Some(json!({"ok": true}))); + assert!(response.error.is_none()); + } + + #[test] + fn parse_mcp_sse_response_joins_one_events_data_lines() { + let body = br##"event: message +data: { +data: "jsonrpc": "2.0", +data: "id": 7, +data: "result": { +data: "content": [ +data: {"type": "text", "text": "# NEAR AI\nURL: https://cloud-api.near.ai"} +data: ] +data: } +data: } + +"##; + + let response = parse_mcp_sse_response(body, Some(7)) + .expect("one SSE event may split its JSON over repeated data lines"); + + assert_eq!( + response.result, + Some(json!({ + "content": [{ + "type": "text", + "text": "# NEAR AI\nURL: https://cloud-api.near.ai" + }] + })) + ); + assert!(response.error.is_none()); + } + + /// Build an `McpHostHttpResponse` with a caller-chosen `content-type` and + /// raw body bytes — the two inputs `parse_mcp_response` sniffs to pick the + /// SSE vs plain-JSON branch. Fixtures below are hand-authored (there are no + /// live-captured MCP response bodies under `tests/fixtures/`), but their + /// framings mirror what a spec-compliant Streamable-HTTP MCP server emits. + fn mcp_response(content_type: &str, body: &[u8]) -> McpHostHttpResponse { + McpHostHttpResponse { + status: 200, + headers: vec![("content-type".to_string(), content_type.to_string())], + body: body.to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: body.len() as u64, + redaction_applied: false, + } + } + + /// Format matrix for the single `parse_mcp_response` dispatch that every + /// JSON-RPC leg (`initialize`/`tools/list`/`tools/call`) funnels through. + /// The client advertises `Accept: application/json, text/event-stream` + /// (two content types), so the parser must accept BOTH framings for the + /// same logical response — this pins that parity at the dispatch entry + /// point, not just at `parse_mcp_sse_response` (already covered above). + #[test] + fn parse_mcp_response_accepts_both_advertised_framings() { + let id = Some(7u64); + let ok_body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; + + // Plain JSON framing (content-type application/json). + let json = parse_mcp_response(&mcp_response("application/json", ok_body), id) + .expect("plain JSON framing parses"); + assert_eq!(json.result, Some(json!({"ok": true}))); + assert!(json.error.is_none()); + + // SSE single-event framing (content-type text/event-stream). + let sse_single = parse_mcp_response( + &mcp_response( + "text/event-stream", + b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n", + ), + id, + ) + .expect("SSE single-event framing parses"); + assert_eq!(sse_single.result, Some(json!({"ok": true}))); + + // SSE multi-event framing with a leading keepalive ping — the real + // frame ordering a streaming server emits. + let sse_multi = parse_mcp_response( + &mcp_response( + "text/event-stream; charset=utf-8", + b"event: ping\ndata:\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n", + ), + id, + ) + .expect("SSE multi-event framing parses past the keepalive"); + assert_eq!(sse_multi.result, Some(json!({"ok": true}))); + } + + /// Error-object framing (a JSON-RPC `error` member) is surfaced as + /// `error == true` — in BOTH framings — rather than mis-parsed as success + /// or dropped. This is the recoverable, model-visible tool-error leg. + #[test] + fn parse_mcp_response_flags_error_object_in_both_framings() { + let id = Some(3u64); + let json_err = parse_mcp_response( + &mcp_response( + "application/json", + br#"{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"bad"}}"#, + ), + id, + ) + .expect("JSON error-object is a valid response, not a parse failure"); + assert!( + json_err.error.is_some(), + "plain-JSON error object flags error" + ); + assert_eq!(json_err.result, None, "error object carries no result"); + + let sse_err = parse_mcp_response( + &mcp_response( + "text/event-stream", + b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":3,\"error\":{\"code\":-32602,\"message\":\"bad\"}}\n\n", + ), + id, + ) + .expect("SSE error-object is a valid response, not a parse failure"); + assert!( + sse_err.error.is_some(), + "SSE-framed error object flags error" + ); + assert_eq!(sse_err.result, None, "error object carries no result"); + } + + /// Empty / malformed bodies are rejected in both framings (mutation guard: + /// a parser that returned an empty-`result` success here would flip these + /// `Err`s to `Ok`). An empty plain-JSON body has no JSON value; an SSE body + /// with only keepalives has no `data:` payload carrying the expected id. + #[test] + fn parse_mcp_response_rejects_empty_bodies_in_both_framings() { + let id = Some(9u64); + // Per-cause diagnostic tokens replaced the flat "response_error": an + // unparseable JSON body reports `mcp_parse_failed` (with a bounded + // serde detail), and an SSE stream with no id-matching data reports + // `mcp_no_payload`. Both remain hard errors, not silent successes. + let empty_json_err = parse_mcp_response(&mcp_response("application/json", b""), id) + .expect_err("empty plain-JSON body must not parse as a success"); + assert!( + empty_json_err.starts_with("mcp_parse_failed"), + "empty plain-JSON body must report a parse failure, got {empty_json_err:?}" + ); + assert_eq!( + parse_mcp_response( + &mcp_response("text/event-stream", b"event: ping\ndata:\n\n"), + id, + ) + .unwrap_err(), + "mcp_no_payload", + "SSE body with only keepalives (no id-matching data) must not parse" + ); + } + + fn json_response(status: u16, body: Value) -> McpHostHttpResponse { + McpHostHttpResponse { + status, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: serde_json::to_vec(&body).expect("serialize test body"), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + } + } + + #[test] + fn non_2xx_http_status_reason_carries_status_code() { + // The 404 path is a direct `response_error(HttpStatus(..))` at the + // send call site; the cause-to-token mapping is the load-bearing part. + let reason = response_error(McpResponseErrorCause::HttpStatus(404)); + assert_eq!(reason, "mcp_http_status_404"); + assert!(reason.contains("404")); + + let reason = response_error(McpResponseErrorCause::HttpStatus(503)); + assert_eq!(reason, "mcp_http_status_503"); + } + + #[test] + fn json_rpc_error_response_reason_carries_code_and_message() { + let response = json_response( + 200, + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32601, "message": "Method not found" } + }), + ); + + let parsed = parse_mcp_response(&response, Some(1)).expect("parse json-rpc error response"); + let error = parsed.error.expect("error object captured"); + + // Drive the same reason construction the call sites use. + let reason = response_error(McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }); + assert!( + reason.contains("-32601"), + "reason should carry the standardized protocol code: {reason}" + ); + assert!( + reason.contains("Method not found"), + "backend diagnostic should reach the private cause channel: {reason}" + ); + assert!(reason.starts_with("mcp_jsonrpc_error")); + } + + #[test] + fn json_rpc_error_without_structured_fields_still_classifies() { + let response = json_response(200, json!({ "jsonrpc": "2.0", "id": 4, "error": "boom" })); + + let parsed = parse_mcp_response(&response, Some(4)).expect("parse non-object error"); + let error = parsed.error.expect("error present even when non-object"); + assert_eq!(error.code, None); + assert_eq!(error.message, None); + let reason = response_error(McpResponseErrorCause::JsonRpcError { + code: error.code, + message: error.message, + }); + assert_eq!(reason, "mcp_jsonrpc_error"); + } + + #[test] + fn auth_challenge_redacts_response_body_and_preserves_only_metadata_locations() { + let response = McpHostHttpResponse { + status: 401, + headers: vec![ + ( + "WWW-Authenticate".to_string(), + "Bearer resource_metadata=\"https://issuer.example.test/.well-known/oauth-protected-resource?access_token=secret\"".to_string(), + ), + ( + "protected-resource-metadata".to_string(), + "https://resource.example.test/.well-known/oauth-protected-resource#secret" + .to_string(), + ), + ], + body: b"token=super-secret remote diagnostic".to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 42, + redaction_applied: false, + }; + + let challenge = mcp_auth_challenge_from_response(&response); + assert_eq!(challenge.status, 401); + assert_eq!( + challenge.www_authenticate_metadata[0].as_str(), + "https://issuer.example.test/.well-known/oauth-protected-resource" + ); + assert_eq!( + challenge.protected_resource_metadata[0].as_str(), + "https://resource.example.test/.well-known/oauth-protected-resource" + ); + let rendered = format!("{challenge:?}"); + assert!(!rendered.contains("super-secret")); + assert!(!rendered.contains("access_token")); + } + + #[test] + fn malformed_json_body_reason_names_parse_failure() { + let response = McpHostHttpResponse { + status: 200, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: b"{ this is not json".to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + }; + + let reason = parse_mcp_response(&response, Some(1)).expect_err("malformed body must fail"); + assert!( + reason.starts_with("mcp_parse_failed:"), + "reason should name parse failure: {reason}" + ); + } + + #[test] + fn successful_result_response_has_no_error() { + let response = json_response( + 200, + json!({ "jsonrpc": "2.0", "id": 9, "result": { "ok": true } }), + ); + + let parsed = parse_mcp_response(&response, Some(9)).expect("success path unchanged"); + assert_eq!(parsed.result, Some(json!({ "ok": true }))); + assert!(parsed.error.is_none()); + } + + #[test] + fn id_mismatch_reason_is_stable_token() { + let response = json_response( + 200, + json!({ "jsonrpc": "2.0", "id": 2, "result": { "ok": true } }), + ); + + let reason = parse_mcp_response(&response, Some(1)).expect_err("id mismatch must fail"); + assert_eq!(reason, "mcp_jsonrpc_id_mismatch"); + } + + #[test] + fn invalid_session_and_protocol_reasons_are_distinct_tokens() { + assert_eq!( + response_error(McpResponseErrorCause::InvalidSessionId), + "mcp_invalid_session_id" + ); + assert_eq!( + response_error(McpResponseErrorCause::InvalidProtocolVersion), + "mcp_invalid_protocol_version" + ); + assert_eq!( + response_error(McpResponseErrorCause::MissingResult), + "mcp_missing_result" + ); + } +} diff --git a/crates/ironclaw_mcp/src/lib.rs b/crates/ironclaw_mcp/src/lib.rs index 35103fa79ed..f7b2e54b926 100644 --- a/crates/ironclaw_mcp/src/lib.rs +++ b/crates/ironclaw_mcp/src/lib.rs @@ -1,2767 +1,61 @@ -// arch-exempt: large_file, targeted catalog parsing fix remains beside the existing MCP protocol parser pending the adapter module split, plan #4088 //! MCP adapter contracts for IronClaw Reborn. //! //! `ironclaw_mcp` adapts manifest-declared MCP tools into IronClaw //! capabilities. It does not grant MCP servers ambient filesystem, secret, or //! network authority; the host-selected client is the only integration point and //! resource accounting still happens host-side, through the narrow -//! [`RuntimeResourceBudget`] port — this lane holds no budget authority of its -//! own and can only reserve, reconcile, and release. - -use std::{ - collections::HashMap, - panic::AssertUnwindSafe, - sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - }, -}; - -use async_trait::async_trait; -use futures_util::FutureExt as _; -use ironclaw_extension_contracts::hosted_mcp::McpAuthChallenge; -use ironclaw_extension_contracts::hosted_mcp::{ - HostedMcpDiscoveredTool, HostedMcpDiscoveredToolAnnotations, +//! [`RuntimeResourceBudget`](ironclaw_host_api::resource::RuntimeResourceBudget) +//! port — this lane holds no budget authority of its own and can only reserve, +//! reconcile, and release. +//! +//! # Module charter +//! +//! PROPOSAL §6.6.3 asks this crate to split its single file into chartered +//! modules. The pipeline runs outward: a caller names the **contract**, the +//! **runtime** governs resources around it, the **client** speaks the protocol, +//! **jsonrpc** frames it, **discovery** admits what comes back, **egress** is +//! the only way bytes leave, and **diagnostics** is the only vocabulary any of +//! them may report a failure in. Each concern owns one module, and the table +//! below is the rule for where new code goes. +//! +//! The submodules are **private**: every public item is re-exported here, so +//! `ironclaw_mcp::X` stays the single import path for callers outside the +//! crate and the module names are never part of the public API. +//! +//! | Module | Owns | Never contains | +//! |---|---|---| +//! | `contract` | The vocabulary a caller names: config, invocation/request/output DTOs, the [`McpClient`] and [`McpExecutor`] traits, and the [`McpError`]/[`McpClientError`] taxonomy | Protocol framing, transport, or resource accounting | +//! | `runtime` | Resource-governed execution: reserve → call → reconcile/release, descriptor admission, and the manifest credential context an auth failure reports | JSON-RPC, HTTP, or catalog parsing | +//! | `client` | The Streamable-HTTP [`McpClient`] implementation: handshake, per-invocation session lifecycle, the `tools/list` paging loop | The wire codec (that is `jsonrpc`) or catalog admission rules (that is `discovery`) | +//! | `jsonrpc` | The JSON-RPC 2.0 codec and MCP response hygiene: encode, plain-JSON and SSE framing, id matching, session-id and protocol-version validation, auth-challenge extraction, per-method credential routing | Session *state* (that is `client`) or tool-shape rules (that is `discovery`) | +//! | `discovery` | `tools/list` catalog admission: the host ceilings, per-tool classification, input-schema bounds, description bounding, annotations, tool-name grammar | Anything that sends or receives a request | +//! | `egress` | The host-mediated HTTP seam: the [`McpHostHttp`] port, its runtime-egress adapter, and the host-owned egress plan/planner | A URL, header, or body decision that is protocol content | +//! | `diagnostics` | Every stable, bounded failure token the lane surfaces, and the cause enums behind them | A failure *decision* — modules classify, `diagnostics` only names | +//! +//! Two rules keep the charter honest: +//! +//! - **No module builds a failure string of its own.** Reasons are constructed +//! only from `diagnostics`' cause enums, so every token the model can see is +//! bounded and enumerable in one file. +//! - **`discovery` owns the rules, `client` owns the loop.** The per-page caps +//! and the running-total check read the *same* constants from `discovery`, so +//! the two enforcement points cannot drift apart. + +mod client; +mod contract; +mod diagnostics; +mod discovery; +mod egress; +mod jsonrpc; +mod runtime; + +pub use client::McpHostHttpClient; +pub use contract::{ + McpClient, McpClientError, McpClientOutput, McpClientRequest, McpError, McpExecutionRequest, + McpExecutionResult, McpExecutor, McpInvocation, McpRuntimeConfig, McpToolDiscoveryOutput, }; -use ironclaw_extension_contracts::runtime::ExtensionRuntime; -use ironclaw_host_api::{ - action::{NetworkMethod, NetworkPolicy}, - capability::{ - CapabilityDescriptor, RuntimeCredentialRequirement, RuntimeCredentialRequirementSource, - }, - decision::RuntimeCredentialAuthRequirement, - http::{ - CapabilityHostHttpRequest, RuntimeCredentialInjection, RuntimeCredentialSource, - RuntimeHttpEgress, RuntimeHttpEgressError, RuntimeHttpEgressResponse, - }, - ids::{CapabilityId, ExtensionId, ResourceReservationId, SecretHandle}, - resource::{ - CapabilityHostResult, ResourceEstimate, ResourceReceipt, ResourceReservation, - ResourceScope, ResourceUsage, RuntimeResourceBudget, RuntimeResourceError, - }, - runtime::RuntimeKind, +pub use egress::{ + McpHostHttp, McpHostHttpEgressPlan, McpHostHttpEgressPlanRequest, McpHostHttpEgressPlanner, + McpHostHttpError, McpHostHttpResponse, McpRuntimeHttpAdapter, StaticMcpHostHttpEgressPlanner, }; -use serde_json::Value; -use thiserror::Error; - -const STREAMABLE_HTTP_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; -const MCP_PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version"; - -/// Maximum number of tools accepted from a hosted MCP `tools/list` discovery -/// pass, across all pages. Shared by the discovery loop's running-total check -/// and [`parse_tools_list_result`]'s per-page cap so the two enforcement -/// points cannot drift apart. -const MAX_DISCOVERED_MCP_TOOLS: usize = 1024; - -/// Maximum number of `tools/list` pagination pages followed during a single -/// discovery pass. -const MAX_MCP_TOOLS_LIST_PAGES: usize = 50; - -/// Maximum aggregate serialized bytes accepted across all `tools/list` pages -/// during a single discovery pass. -const MAX_MCP_TOOLS_CATALOG_BYTES: usize = 16 * 1024 * 1024; - -/// Host-owned MCP adapter limits. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct McpRuntimeConfig { - pub max_output_bytes: u64, -} - -impl Default for McpRuntimeConfig { - fn default() -> Self { - Self { - max_output_bytes: 1024 * 1024, - } - } -} - -impl McpRuntimeConfig { - pub fn for_testing() -> Self { - Self { - max_output_bytes: 64 * 1024, - } - } -} - -/// JSON invocation passed to a manifest-declared MCP capability. -#[derive(Debug, Clone, PartialEq)] -pub struct McpInvocation { - pub input: Value, -} - -/// Full resource-governed MCP execution request. -#[derive(Debug)] -pub struct McpExecutionRequest<'a> { - /// The extension whose manifest declares this lane. - /// - /// The lane deliberately does **not** receive the `ExtensionPackage`: it - /// read only the id, the capability descriptors, and the runtime stanza, - /// and taking the package forced a `runtimes -> loops` dependency on the - /// registry crate (the W7 `ironclaw_mcp -> ironclaw_extensions` exception). - /// The caller, which owns the package, projects those three. - /// - /// **Caller obligation (the cost of that carve-out).** `extension`, - /// `capabilities`, and `runtime` are three independent borrows, so the type - /// no longer *structurally* guarantees they came from one package the way - /// `&ExtensionPackage` did. `execute_extension_json` re-checks the - /// descriptor half (`descriptor.provider == extension`), but nothing in an - /// `&ExtensionRuntime` identifies its owning extension, so the runtime half - /// cannot be re-derived here — a caller that paired extension A's - /// descriptors with extension B's runtime stanza would authenticate as A - /// and dial B. **Always project all three from the same `ExtensionPackage` - /// in one expression.** The single production caller - /// (`ironclaw_host_runtime::services::runtime_adapters`) does exactly that. - /// Restoring the compile-time binding needs a sealed projection minted by - /// the package owner — it cannot be a check inside this lane, and it must - /// not be a re-addition of the registry edge; tracked with the WS3 lane - /// work. - pub extension: &'a ExtensionId, - pub capabilities: &'a [CapabilityDescriptor], - pub runtime: &'a ExtensionRuntime, - pub capability_id: &'a CapabilityId, - pub scope: ResourceScope, - pub estimate: ResourceEstimate, - pub resource_reservation: Option, - pub invocation: McpInvocation, -} - -/// Host-normalized request handed to the configured MCP client adapter. -#[derive(Debug, Clone, PartialEq)] -pub struct McpClientRequest { - pub provider: ExtensionId, - pub capability_id: CapabilityId, - pub scope: ResourceScope, - pub transport: String, - pub command: Option, - pub args: Vec, - pub url: Option, - pub input: Value, - pub max_output_bytes: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct McpAuthContext { - required_secrets: Vec, - credential_requirements: Vec, -} - -#[derive(Debug)] -struct PreparedMcpClientRequest { - request: McpClientRequest, - auth_context: McpAuthContext, -} - -/// Raw MCP adapter output before resource reconciliation. -#[derive(Debug, Clone, PartialEq)] -pub struct McpClientOutput { - pub output: Value, - pub usage: ResourceUsage, - pub output_bytes: Option, -} - -impl McpClientOutput { - pub fn json(value: Value) -> Self { - Self { - output: value, - usage: ResourceUsage::default(), - output_bytes: None, - } - } -} - -/// Result of a hosted MCP schema-discovery pass. -/// -/// Discovered tools use the extension-domain [`HostedMcpDiscoveredTool`] shape -/// directly: `ironclaw_mcp` parses `tools/list` into the same descriptor the -/// extension domain consumes, so there is no separate MCP-local mirror. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct McpToolDiscoveryOutput { - pub tools: Vec, - pub usage: ResourceUsage, -} - -/// Host-selected MCP client adapter. -/// -/// Implementations must enforce `McpClientRequest::max_output_bytes` while -/// reading MCP server output, before constructing the structured JSON `Value`. -/// The runtime re-checks serialized output size after the adapter returns, but -/// that check is a second line of defense rather than the primary memory bound. -#[async_trait] -pub trait McpClient: Send + Sync { - /// HTTP/SSE MCP transports must be implemented through the shared host-mediated - /// runtime egress boundary. The default is fail-closed so a generic client - /// cannot accidentally perform direct outbound HTTP. - fn uses_host_mediated_http_egress(&self) -> bool { - false - } - - async fn call_tool(&self, request: McpClientRequest) - -> Result; - - async fn discover_tools( - &self, - request: McpClientRequest, - max_tools: u32, - ) -> Result { - let _ = (request, max_tools); - Err(McpClientError::client(request_denied( - McpRequestDeniedCause::UnsupportedTransport, - ))) - } -} - -/// Stable, sanitized MCP client-side failure categories. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum McpClientError { - Client { - reason: String, - }, - /// The server completed `tools/list`, but the advertised catalog violated - /// the host's provider-neutral shape or safety contract. Repeating OAuth - /// or the same request cannot repair this generation. - InvalidToolCatalog { - reason: String, - }, - AuthRequired, - /// A hosted server returned 401/403. The challenge is header-derived and - /// deliberately redacted; it contains no remote response body or tokens. - AuthChallenge { - challenge: McpAuthChallenge, - }, -} - -impl McpClientError { - pub fn client(reason: impl Into) -> Self { - Self::Client { - reason: reason.into(), - } - } - - pub fn invalid_tool_catalog(reason: impl Into) -> Self { - Self::InvalidToolCatalog { - reason: reason.into(), - } - } - - pub fn stable_reason(&self) -> &str { - match self { - Self::Client { reason } | Self::InvalidToolCatalog { reason } => reason, - Self::AuthRequired | Self::AuthChallenge { .. } => "auth_required", - } - } -} - -impl From for McpClientError { - fn from(reason: String) -> Self { - Self::client(reason) - } -} - -/// Full resource-governed MCP execution result. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct McpExecutionResult { - pub result: CapabilityHostResult, - pub receipt: ResourceReceipt, -} - -pub type McpHostHttpResponse = RuntimeHttpEgressResponse; - -#[derive(Debug, Error)] -pub enum McpHostHttpError { - #[error("MCP host HTTP error: {reason}")] - Egress { reason: String }, -} - -#[derive(Debug, Clone)] -pub struct McpRuntimeHttpAdapter { - egress: E, -} - -impl McpRuntimeHttpAdapter -where - E: RuntimeHttpEgress, -{ - pub fn new(egress: E) -> Self { - Self { egress } - } - - pub async fn request( - &self, - request: CapabilityHostHttpRequest, - ) -> Result { - AssertUnwindSafe( - self.egress - .execute(request.into_runtime_request(RuntimeKind::Mcp)), - ) - .catch_unwind() - .await - .map_err(|_| McpHostHttpError::Egress { - reason: "runtime_http_egress_panicked".to_string(), - })? - .map_err(mcp_http_error) - } -} - -fn mcp_http_error(error: RuntimeHttpEgressError) -> McpHostHttpError { - McpHostHttpError::Egress { - reason: error.stable_runtime_reason().to_string(), - } -} - -#[async_trait] -pub trait McpHostHttp: Send + Sync { - async fn request( - &self, - request: CapabilityHostHttpRequest, - ) -> Result; -} - -#[async_trait] -impl McpHostHttp for McpRuntimeHttpAdapter -where - E: RuntimeHttpEgress + Send + Sync, -{ - async fn request( - &self, - request: CapabilityHostHttpRequest, - ) -> Result { - McpRuntimeHttpAdapter::request(self, request).await - } -} - -#[async_trait] -impl McpHostHttp for Arc -where - T: McpHostHttp + ?Sized + Send + Sync, -{ - async fn request( - &self, - request: CapabilityHostHttpRequest, - ) -> Result { - self.as_ref().request(request).await - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct McpHostHttpEgressPlan { - pub network_policy: NetworkPolicy, - pub credential_injections: Vec, - pub response_body_limit: Option, - pub timeout_ms: Option, -} - -#[derive(Debug, Clone, Copy)] -pub struct McpHostHttpEgressPlanRequest<'a> { - pub provider: &'a ExtensionId, - pub capability_id: &'a CapabilityId, - pub scope: &'a ResourceScope, - pub transport: &'a str, - pub method: NetworkMethod, - pub url: &'a str, - pub headers: &'a [(String, String)], - pub body: &'a [u8], -} - -/// Host-owned egress planner for MCP HTTP/SSE requests. -/// -/// The planner is intentionally separate from [`McpClientRequest::input`]: -/// runtime/plugin inputs can affect the JSON-RPC body, but only this host-owned -/// planner can provide network policy, credential handles, response limits, and -/// timeouts for the shared egress service. -/// -/// `plan` must be deterministic and side-effect-free. The concrete HTTP client -/// plans the real `tools/call` body once before the MCP handshake, validates -/// its credential sources, then threads that plan into the later `tools/call` -/// transport send. Planner-visible headers are stable policy headers only; the -/// dynamic MCP session header is added by the protocol client after planning. -/// Hosted MCP providers may require authentication for the entire JSON-RPC -/// session, including initialization, so staged credentials must remain scoped -/// to the invocation until the capability dispatch completes. -pub trait McpHostHttpEgressPlanner: Send + Sync { - fn plan(&self, request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan; -} - -impl McpHostHttpEgressPlanner for Arc -where - T: McpHostHttpEgressPlanner + ?Sized, -{ - fn plan(&self, request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan { - self.as_ref().plan(request) - } -} - -#[derive(Debug, Clone)] -pub struct StaticMcpHostHttpEgressPlanner { - plan: McpHostHttpEgressPlan, -} - -impl StaticMcpHostHttpEgressPlanner { - pub fn new(plan: McpHostHttpEgressPlan) -> Self { - Self { plan } - } -} - -impl McpHostHttpEgressPlanner for StaticMcpHostHttpEgressPlanner { - fn plan(&self, _request: McpHostHttpEgressPlanRequest<'_>) -> McpHostHttpEgressPlan { - self.plan.clone() - } -} - -#[derive(Debug, Clone)] -pub struct McpHostHttpClient { - http: H, - planner: P, - state: Arc, -} - -#[derive(Debug)] -struct McpHostHttpClientState { - next_id: AtomicU64, - // `std::sync::Mutex` is appropriate here: the lock is held only for O(1) - // HashMap operations (never across an `.await`), and the key includes - // `invocation_id` so concurrent dispatches from different invocations act - // on disjoint map entries with no real contention. - sessions: Mutex>, -} - -struct McpHostHttpSessionCleanup { - state: Arc, - session_key: McpHostHttpSessionKey, -} - -struct PlannedMcpJsonRpc { - id: Option, - method: McpJsonRpcMethod, - url: String, - policy_headers: Vec<(String, String)>, - body: Vec, - plan: McpHostHttpEgressPlan, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct McpHostHttpSession { - session_id: Option, - protocol_version: String, -} - -impl McpHostHttpSessionCleanup { - fn new(state: Arc, session_key: McpHostHttpSessionKey) -> Self { - Self { state, session_key } - } -} - -impl Drop for McpHostHttpSessionCleanup { - fn drop(&mut self) { - if let Ok(mut guard) = self.state.sessions.lock() { - guard.remove(&self.session_key); - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct McpHostHttpSessionKey { - tenant_id: String, - user_id: String, - agent_id: Option, - project_id: Option, - mission_id: Option, - thread_id: Option, - invocation_id: String, - provider: String, - url: String, -} - -impl McpHostHttpSessionKey { - fn new(scope: &ResourceScope, provider: &ExtensionId, url: &str) -> Self { - Self { - tenant_id: scope.tenant_id.as_str().to_string(), - user_id: scope.user_id.as_str().to_string(), - agent_id: scope.agent_id.as_ref().map(|id| id.as_str().to_string()), - project_id: scope.project_id.as_ref().map(|id| id.as_str().to_string()), - mission_id: scope.mission_id.as_ref().map(|id| id.as_str().to_string()), - thread_id: scope.thread_id.as_ref().map(|id| id.as_str().to_string()), - invocation_id: scope.invocation_id.to_string(), - provider: provider.as_str().to_string(), - url: url.to_string(), - } - } -} - -impl McpHostHttpClient -where - H: McpHostHttp, - P: McpHostHttpEgressPlanner, -{ - pub fn new(http: H, planner: P) -> Self { - Self { - http, - planner, - state: Arc::new(McpHostHttpClientState { - next_id: AtomicU64::new(1), - sessions: Mutex::new(HashMap::new()), - }), - } - } - - fn next_request_id(&self) -> u64 { - self.state.next_id.fetch_add(1, Ordering::SeqCst) - } - - /// Perform only the MCP initialization handshake. - /// - /// Registration uses this to distinguish credential-free access from an - /// authentication challenge without fetching or admitting the tool - /// catalog. The temporary session is always discarded before returning. - pub async fn probe_auth( - &self, - request: McpClientRequest, - ) -> Result { - if !requires_host_http_egress(&request.transport) { - return Err(McpClientError::client(request_denied( - McpRequestDeniedCause::UnsupportedTransport, - ))); - } - let url = request.url.as_deref().ok_or_else(|| { - McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) - })?; - let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); - let _session_cleanup = - McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); - self.initialize_session(&request, &session_key).await - } - - async fn send_json_rpc( - &self, - request: &McpClientRequest, - session_key: &McpHostHttpSessionKey, - id: Option, - method: McpJsonRpcMethod, - params: Option, - ) -> Result { - let planned = self.plan_json_rpc(request, id, method, params)?; - self.send_planned_json_rpc(request, session_key, planned) - .await - } - - fn plan_json_rpc( - &self, - request: &McpClientRequest, - id: Option, - method: McpJsonRpcMethod, - params: Option, - ) -> Result { - let url = request.url.as_deref().ok_or_else(|| { - McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) - })?; - let body = - encode_json_rpc_request(id, method.as_str(), params).map_err(McpClientError::client)?; - let policy_headers = vec![ - ("Content-Type".to_string(), "application/json".to_string()), - ( - "Accept".to_string(), - "application/json, text/event-stream".to_string(), - ), - ]; - - let plan = self.planner.plan(McpHostHttpEgressPlanRequest { - provider: &request.provider, - capability_id: &request.capability_id, - scope: &request.scope, - transport: &request.transport, - method: NetworkMethod::Post, - url, - headers: &policy_headers, - body: &body, - }); - Ok(PlannedMcpJsonRpc { - id, - method, - url: url.to_string(), - policy_headers, - body, - plan, - }) - } - - async fn send_planned_json_rpc( - &self, - request: &McpClientRequest, - session_key: &McpHostHttpSessionKey, - planned: PlannedMcpJsonRpc, - ) -> Result { - let mut headers = planned.policy_headers; - if let Some(session) = self.current_session(session_key)? { - headers.push(( - MCP_PROTOCOL_VERSION_HEADER.to_string(), - session.protocol_version, - )); - if let Some(session_id) = session.session_id { - headers.push(("Mcp-Session-Id".to_string(), session_id)); - } - } - - let response_body_limit = effective_mcp_response_body_limit( - planned.plan.response_body_limit, - request.max_output_bytes, - ); - let credential_injections = planned - .method - .credential_injections(planned.plan.credential_injections)?; - let response = self - .http - .request(CapabilityHostHttpRequest { - scope: request.scope.clone(), - capability_id: request.capability_id.clone(), - method: NetworkMethod::Post, - url: planned.url, - headers, - body: planned.body, - network_policy: planned.plan.network_policy, - credential_injections, - response_body_limit, - timeout_ms: planned.plan.timeout_ms, - }) - .await - .map_err(mcp_client_http_error)?; - - let usage = ResourceUsage::default().set_network_egress_bytes(response.request_bytes); - - if !(200..300).contains(&response.status) { - if is_mcp_auth_response_status(response.status) { - // Bare `AuthRequired` when the response gives us nothing to - // act on; `AuthChallenge` only when it actually carries - // WWW-Authenticate/resource-metadata to resolve. - let challenge = mcp_auth_challenge_from_response(&response); - return Err( - if challenge.www_authenticate_metadata.is_empty() - && challenge.protected_resource_metadata.is_empty() - { - McpClientError::AuthRequired - } else { - McpClientError::AuthChallenge { challenge } - }, - ); - } - return Err(McpClientError::client(response_error( - McpResponseErrorCause::HttpStatus(response.status), - ))); - } - let session_id = mcp_session_id_from_response(&response).map_err(McpClientError::client)?; - - if response.status == 202 && planned.id.is_none() { - return Ok(McpJsonRpcExchange { - response: McpJsonRpcResponse { - result: None, - error: None, - }, - session_id, - usage, - }); - } - - Ok(McpJsonRpcExchange { - response: parse_mcp_response(&response, planned.id).map_err(McpClientError::client)?, - session_id, - usage, - }) - } - - fn current_session( - &self, - session_key: &McpHostHttpSessionKey, - ) -> Result, McpClientError> { - self.state - .sessions - .lock() - .map(|guard| guard.get(session_key).cloned()) - .map_err(|_| { - McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) - }) - } - - fn store_session( - &self, - session_key: &McpHostHttpSessionKey, - session: McpHostHttpSession, - ) -> Result<(), McpClientError> { - let mut guard = self.state.sessions.lock().map_err(|_| { - McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) - })?; - guard.insert(session_key.clone(), session); - Ok(()) - } - - fn update_session_id( - &self, - session_key: &McpHostHttpSessionKey, - session_id: Option, - ) -> Result<(), McpClientError> { - let Some(session_id) = session_id else { - return Ok(()); - }; - let mut guard = self.state.sessions.lock().map_err(|_| { - McpClientError::client(request_denied(McpRequestDeniedCause::SessionStatePoisoned)) - })?; - if let Some(session) = guard.get_mut(session_key) { - session.session_id = Some(session_id); - } - Ok(()) - } - - async fn initialize_session( - &self, - request: &McpClientRequest, - session_key: &McpHostHttpSessionKey, - ) -> Result { - let mut usage = ResourceUsage::default(); - let initialize_id = self.next_request_id(); - let initialize = self - .send_json_rpc( - request, - session_key, - Some(initialize_id), - McpJsonRpcMethod::Initialize, - Some(json_rpc_initialize_params()), - ) - .await?; - accumulate_usage(&mut usage, initialize.usage); - if let Some(error) = initialize.response.error { - return Err(McpClientError::client(response_error( - McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }, - ))); - } - self.store_session( - session_key, - McpHostHttpSession { - session_id: initialize.session_id, - protocol_version: protocol_version_from_initialize_response(&initialize.response) - .map_err(McpClientError::client)?, - }, - )?; - - let initialized = self - .send_json_rpc( - request, - session_key, - None, - McpJsonRpcMethod::InitializedNotification, - None, - ) - .await?; - accumulate_usage(&mut usage, initialized.usage); - self.update_session_id(session_key, initialized.session_id.clone())?; - if let Some(error) = initialized.response.error { - return Err(McpClientError::client(response_error( - McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }, - ))); - } - Ok(usage) - } -} - -#[async_trait] -impl McpClient for McpHostHttpClient -where - H: McpHostHttp, - P: McpHostHttpEgressPlanner, -{ - fn uses_host_mediated_http_egress(&self) -> bool { - true - } - - async fn call_tool( - &self, - request: McpClientRequest, - ) -> Result { - if !requires_host_http_egress(&request.transport) { - return Err(McpClientError::client(request_denied( - McpRequestDeniedCause::UnsupportedTransport, - ))); - } - - let url = request.url.as_deref().ok_or_else(|| { - McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) - })?; - let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); - let _session_cleanup = - McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); - - let tool_name = mcp_tool_name(&request.provider, &request.capability_id); - let tool_call_params = serde_json::json!({ - "name": tool_name, - "arguments": request.input.clone(), - }); - let tool_call_id = self.next_request_id(); - let tool_call_plan = self.plan_json_rpc( - &request, - Some(tool_call_id), - McpJsonRpcMethod::ToolsCall, - Some(tool_call_params), - )?; - validate_tools_call_credential_injections(&tool_call_plan.plan.credential_injections) - .map_err(McpClientError::client)?; - - let mut usage = self.initialize_session(&request, &session_key).await?; - - let call = self - .send_planned_json_rpc(&request, &session_key, tool_call_plan) - .await?; - accumulate_usage(&mut usage, call.usage); - self.update_session_id(&session_key, call.session_id.clone())?; - if let Some(error) = call.response.error { - return Err(McpClientError::client(response_error( - McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }, - ))); - } - let output = call.response.result.ok_or_else(|| { - McpClientError::client(response_error(McpResponseErrorCause::MissingResult)) - })?; - let output_bytes = serde_json::to_vec(&output) - .map(|bytes| bytes.len() as u64) - .map_err(|err| { - McpClientError::client(response_error(McpResponseErrorCause::ParseFailed( - err.to_string(), - ))) - })?; - usage.output_bytes = usage.output_bytes.max(output_bytes); - - Ok(McpClientOutput { - output, - usage, - output_bytes: Some(output_bytes), - }) - } - - async fn discover_tools( - &self, - request: McpClientRequest, - max_tools: u32, - ) -> Result { - if !requires_host_http_egress(&request.transport) { - return Err(McpClientError::client(request_denied( - McpRequestDeniedCause::UnsupportedTransport, - ))); - } - - let url = request.url.as_deref().ok_or_else(|| { - McpClientError::client(request_denied(McpRequestDeniedCause::MissingUrl)) - })?; - let session_key = McpHostHttpSessionKey::new(&request.scope, &request.provider, url); - let _session_cleanup = - McpHostHttpSessionCleanup::new(Arc::clone(&self.state), session_key.clone()); - - if max_tools == 0 { - return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( - McpInvalidToolListCause::TooManyTools, - ))); - } - - // The first page's plan is built before `initialize_session` runs (not - // inside the loop below) so the planner observes `tools/list` before - // `initialize`/`notifications/initialized`, matching the original - // single-page discovery ordering that callers and tests depend on. - // Only pages after the first are planned lazily inside the loop, once - // a `nextCursor` is known. - let first_tools_list_id = self.next_request_id(); - let first_tools_list_plan = self.plan_json_rpc( - &request, - Some(first_tools_list_id), - McpJsonRpcMethod::ToolsList, - None, - )?; - validate_staged_credential_injections(&first_tools_list_plan.plan.credential_injections) - .map_err(McpClientError::client)?; - - let mut usage = self.initialize_session(&request, &session_key).await?; - let mut discovered = Vec::new(); - let mut accepted_catalog_bytes = 0usize; - let mut cursor = None; - let mut pending_plan = Some(first_tools_list_plan); - for page in 1..=MAX_MCP_TOOLS_LIST_PAGES { - let tools_list_plan = match pending_plan.take() { - Some(plan) => plan, - None => { - let tools_list_id = self.next_request_id(); - let plan = self.plan_json_rpc( - &request, - Some(tools_list_id), - McpJsonRpcMethod::ToolsList, - cursor - .as_ref() - .map(|cursor| serde_json::json!({ "cursor": cursor })), - )?; - validate_staged_credential_injections(&plan.plan.credential_injections) - .map_err(McpClientError::client)?; - plan - } - }; - - let tools = self - .send_planned_json_rpc(&request, &session_key, tools_list_plan) - .await?; - accumulate_usage(&mut usage, tools.usage); - self.update_session_id(&session_key, tools.session_id.clone())?; - if let Some(error) = tools.response.error { - return Err(McpClientError::client(response_error( - McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }, - ))); - } - let result = tools.response.result.ok_or_else(|| { - McpClientError::client(response_error(McpResponseErrorCause::MissingResult)) - })?; - let page_bytes = result - .get("tools") - .and_then(Value::as_array) - .and_then(|tools| serde_json::to_vec(tools).ok()) - .map_or(usize::MAX, |bytes| bytes.len()); - let (page_tools, next_cursor) = - parse_tools_list_page(&result).map_err(McpClientError::invalid_tool_catalog)?; - accepted_catalog_bytes = accepted_catalog_bytes.saturating_add(page_bytes); - if discovered.len().saturating_add(page_tools.len()) > MAX_DISCOVERED_MCP_TOOLS - || discovered.len().saturating_add(page_tools.len()) > max_tools as usize - { - return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( - McpInvalidToolListCause::TooManyTools, - ))); - } - if accepted_catalog_bytes > MAX_MCP_TOOLS_CATALOG_BYTES { - return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( - McpInvalidToolListCause::CatalogTooLarge, - ))); - } - discovered.extend(page_tools); - match next_cursor { - Some(_next_cursor) if page == MAX_MCP_TOOLS_LIST_PAGES => { - return Err(McpClientError::invalid_tool_catalog(invalid_tool_list( - McpInvalidToolListCause::TooManyPages, - ))); - } - Some(next_cursor) => cursor = Some(next_cursor), - None => break, - } - } - Ok(McpToolDiscoveryOutput { - tools: discovered, - usage, - }) - } -} - -#[derive(Debug, Clone, PartialEq)] -struct McpJsonRpcResponse { - result: Option, - error: Option, -} - -/// Bounded view of a JSON-RPC `error` object surfaced through the private -/// model-visible cause channel. The server-provided `message` remains untrusted: -/// it is scrubbed at the model-visible diagnostic seam before reaching the model. -#[derive(Debug, Clone, PartialEq, Eq)] -struct JsonRpcErrorInfo { - code: Option, - message: Option, -} - -#[derive(Debug, Clone, PartialEq)] -struct McpJsonRpcExchange { - response: McpJsonRpcResponse, - session_id: Option, - usage: ResourceUsage, -} - -/// Known MCP JSON-RPC methods whose credential-routing behavior is host-owned. -/// -/// Hosted MCP providers may require bearer authentication for the whole -/// JSON-RPC session, including `initialize` and notifications. The host egress -/// planner remains the source of truth for which staged credentials may be -/// sent to the provider URL, and direct secret-store leases are rejected before -/// outbound transport. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum McpJsonRpcMethod { - Initialize, - InitializedNotification, - ToolsList, - ToolsCall, -} - -impl McpJsonRpcMethod { - fn as_str(self) -> &'static str { - match self { - Self::Initialize => "initialize", - Self::InitializedNotification => "notifications/initialized", - Self::ToolsList => "tools/list", - Self::ToolsCall => "tools/call", - } - } - - fn credential_injections( - self, - credential_injections: Vec, - ) -> Result, String> { - if credential_injections - .iter() - .any(|injection| matches!(injection.source, RuntimeCredentialSource::SecretStoreLease)) - { - return Err(request_denied( - McpRequestDeniedCause::DeniedCredentialSource, - )); - } - Ok(credential_injections) - } -} - -/// Validate credential injections planned for a `tools/call` request without -/// consuming the list, so the caller can reuse it in the actual send. -/// -/// Returns `Err(denied)` if any injection uses a [`RuntimeCredentialSource::SecretStoreLease`], -/// which is not permitted over the MCP `tools/call` boundary. -fn validate_tools_call_credential_injections( - credential_injections: &[RuntimeCredentialInjection], -) -> Result<(), String> { - validate_staged_credential_injections(credential_injections) -} - -fn validate_staged_credential_injections( - credential_injections: &[RuntimeCredentialInjection], -) -> Result<(), String> { - if credential_injections - .iter() - .any(|injection| matches!(injection.source, RuntimeCredentialSource::SecretStoreLease)) - { - return Err(request_denied( - McpRequestDeniedCause::DeniedCredentialSource, - )); - } - Ok(()) -} - -fn mcp_client_http_error(error: McpHostHttpError) -> McpClientError { - match error { - McpHostHttpError::Egress { reason } => McpClientError::client(reason), - } -} - -fn is_mcp_auth_response_status(status: u16) -> bool { - matches!(status, 401 | 403) -} - -fn mcp_auth_challenge_from_response(response: &McpHostHttpResponse) -> McpAuthChallenge { - let mut www_authenticate_metadata = Vec::new(); - let mut protected_resource_metadata = Vec::new(); - for (name, value) in &response.headers { - if name.eq_ignore_ascii_case("www-authenticate") { - www_authenticate_metadata.extend( - ironclaw_extension_contracts::hosted_mcp::extract_mcp_auth_metadata_locations( - value, - ), - ); - } else if name.eq_ignore_ascii_case("protected-resource-metadata") { - protected_resource_metadata.extend( - ironclaw_extension_contracts::hosted_mcp::extract_mcp_auth_metadata_locations( - value, - ), - ); - } - } - McpAuthChallenge { - status: response.status, - www_authenticate_metadata, - protected_resource_metadata, - } -} - -fn effective_mcp_response_body_limit(host_limit: Option, client_limit: u64) -> Option { - Some(match host_limit { - Some(limit) => limit.min(client_limit), - None => client_limit, - }) -} - -fn is_safe_mcp_session_id(value: &str) -> bool { - const MAX_MCP_SESSION_ID_BYTES: usize = 1024; - !value.is_empty() - && value.len() <= MAX_MCP_SESSION_ID_BYTES - && value.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) -} - -fn mcp_session_id_from_response(response: &McpHostHttpResponse) -> Result, String> { - let Some((_, value)) = response - .headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("Mcp-Session-Id")) - else { - return Ok(None); - }; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Ok(None); - } - if !is_safe_mcp_session_id(trimmed) { - return Err(response_error(McpResponseErrorCause::InvalidSessionId)); - } - Ok(Some(trimmed.to_string())) -} - -fn is_safe_mcp_protocol_version(value: &str) -> bool { - const MAX_MCP_PROTOCOL_VERSION_BYTES: usize = 64; - !value.is_empty() - && value.len() <= MAX_MCP_PROTOCOL_VERSION_BYTES - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) -} - -fn protocol_version_from_initialize_response( - response: &McpJsonRpcResponse, -) -> Result { - let Some(protocol_version) = response - .result - .as_ref() - .and_then(|result| result.get("protocolVersion")) - .and_then(Value::as_str) - else { - return Err(response_error( - McpResponseErrorCause::InvalidProtocolVersion, - )); - }; - if !is_safe_mcp_protocol_version(protocol_version) { - return Err(response_error( - McpResponseErrorCause::InvalidProtocolVersion, - )); - } - Ok(protocol_version.to_string()) -} - -fn encode_json_rpc_request( - id: Option, - method: &str, - params: Option, -) -> Result, String> { - let mut object = serde_json::Map::new(); - object.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); - if let Some(id) = id { - object.insert( - "id".to_string(), - Value::Number(serde_json::Number::from(id)), - ); - } - object.insert("method".to_string(), Value::String(method.to_string())); - if let Some(params) = params { - object.insert("params".to_string(), params); - } - serde_json::to_vec(&Value::Object(object)) - .map_err(|err| request_denied(McpRequestDeniedCause::EncodeFailed(err.to_string()))) -} - -fn parse_mcp_response( - response: &McpHostHttpResponse, - expected_id: Option, -) -> Result { - if response_is_sse(response) { - parse_mcp_sse_response(&response.body, expected_id) - } else { - let value = serde_json::from_slice::(&response.body) - .map_err(|err| response_error(McpResponseErrorCause::ParseFailed(err.to_string())))?; - parse_mcp_json_rpc_value(&value, expected_id) - } -} - -fn response_is_sse(response: &McpHostHttpResponse) -> bool { - response.headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-type") - && value.to_ascii_lowercase().contains("text/event-stream") - }) -} - -fn parse_mcp_sse_response( - body: &[u8], - expected_id: Option, -) -> Result { - let text = std::str::from_utf8(body) - .map_err(|err| response_error(McpResponseErrorCause::ParseFailed(err.to_string())))?; - let mut event_data = String::new(); - for line in text.lines().chain(std::iter::once("")) { - if !line.is_empty() { - let Some(payload) = line.strip_prefix("data:") else { - continue; - }; - let payload = payload.strip_prefix(' ').unwrap_or(payload); - if !event_data.is_empty() { - event_data.push('\n'); - } - event_data.push_str(payload); - continue; - } - if event_data.trim().is_empty() { - event_data.clear(); - continue; - } - let value = serde_json::from_str::(&event_data); - event_data.clear(); - let Ok(value) = value else { - continue; - }; - let parsed_id = json_rpc_id(&value); - if expected_id.is_none() || parsed_id == expected_id { - return parse_mcp_json_rpc_value(&value, expected_id); - } - } - Err(response_error(McpResponseErrorCause::NoPayload)) -} - -fn parse_mcp_json_rpc_value( - value: &Value, - expected_id: Option, -) -> Result { - let parsed_id = json_rpc_id(value); - if let Some(expected) = expected_id - && parsed_id != Some(expected) - { - return Err(response_error(McpResponseErrorCause::IdMismatch)); - } - Ok(McpJsonRpcResponse { - result: value.get("result").cloned(), - error: parse_json_rpc_error_info(value.get("error")), - }) -} - -/// Extract a bounded view of a JSON-RPC `error` object. Returns -/// `None` when no `error` member is present. A non-object `error` member still -/// counts as an error, but carries no structured code/message. -fn parse_json_rpc_error_info(error: Option<&Value>) -> Option { - let error = error?; - let code = error.get("code").and_then(Value::as_i64); - let message = error - .get("message") - .and_then(Value::as_str) - .map(bound_mcp_reason_detail); - Some(JsonRpcErrorInfo { code, message }) -} - -fn parse_tools_list_result( - value: &Value, - manifest_max_tools: u32, -) -> Result, String> { - const MAX_TOOL_NAME_BYTES: usize = 128; - const MAX_TOOL_DESCRIPTION_BYTES: usize = 2048; - const MAX_SCHEMA_DEPTH: u8 = 32; - const MAX_SCHEMA_NODES: usize = 8192; - const MAX_SCHEMA_STRING_BYTES: usize = 16 * 1024; - - let tools = value - .get("tools") - .and_then(Value::as_array) - .ok_or_else(|| invalid_tool_list(McpInvalidToolListCause::MissingToolsArray))?; - let manifest_max_tools = usize::try_from(manifest_max_tools) - .unwrap_or(MAX_DISCOVERED_MCP_TOOLS) - .min(MAX_DISCOVERED_MCP_TOOLS); - if manifest_max_tools == 0 || tools.len() > manifest_max_tools { - return Err(invalid_tool_list(McpInvalidToolListCause::TooManyTools)); - } - - // Catalog acceptance distinguishes shape-only defects from security/bounds - // violations. A single tool with a shape-only defect (an unsupported name, - // an invalid description, or malformed annotations) is dropped from this - // generation and recorded, so one malformed entry cannot brick an otherwise - // valid integration that has no prior generation to fall back to. A - // security/bounds violation (missing or unsafe input schema — checked first - // per tool so a co-occurring cosmetic defect cannot downgrade it — or a - // catalog that overflows the host cap) still rejects the whole generation - // with a stable safe subcause; the previous published generation, if any, - // remains authoritative until a complete bounded catalog is discovered. - let mut published = Vec::with_capacity(tools.len()); - let mut first_skipped_cause: Option = None; - for (index, tool) in tools.iter().enumerate() { - match classify_discovered_tool( - tool, - MAX_TOOL_NAME_BYTES, - MAX_TOOL_DESCRIPTION_BYTES, - MAX_SCHEMA_DEPTH, - MAX_SCHEMA_NODES, - MAX_SCHEMA_STRING_BYTES, - ) - .map_err(invalid_tool_list)? - { - DiscoveredToolClassification::Published(discovered) => published.push(discovered), - DiscoveredToolClassification::SkippedShapeViolation(cause) => { - first_skipped_cause.get_or_insert(cause); - // Bounded, provider-neutral record: the tool index and stable - // cause token only — never the raw provider-supplied content. - tracing::debug!( - tool_index = index, - skip_cause = cause.stable_token(), - "skipping shape-nonconforming hosted MCP tool from discovery catalog" - ); - } - } - } - if published.is_empty() - && let Some(cause) = first_skipped_cause - { - // Every advertised tool was shape-nonconforming: there is nothing to - // publish, so fail this generation non-retryably with a stable subcause - // rather than activating on an empty catalog. An empty provider list - // (no tools advertised, nothing skipped) is left as an empty result the - // caller treats as "no tools discovered yet". - return Err(invalid_tool_list(cause)); - } - Ok(published) -} - -fn parse_tools_list_page( - value: &Value, -) -> Result<(Vec, Option), String> { - let tools = parse_tools_list_result(value, MAX_DISCOVERED_MCP_TOOLS as u32)?; - let next_cursor = match value.get("nextCursor") { - None | Some(Value::Null) => None, - Some(Value::String(cursor)) - if !cursor.is_empty() - && cursor.len() <= 4_096 - && !cursor.chars().any(|character| character.is_control()) => - { - Some(cursor.clone()) - } - Some(_) => return Err(invalid_tool_list(McpInvalidToolListCause::InvalidCursor)), - }; - Ok((tools, next_cursor)) -} - -/// Result of classifying one advertised MCP tool during discovery. -enum DiscoveredToolClassification { - /// The tool conforms to the host contract and is published. - Published(HostedMcpDiscoveredTool), - /// The tool violates a shape-only, non-security rule and is dropped from - /// this generation while the rest of a bounded catalog still publishes. - SkippedShapeViolation(McpInvalidToolListCause), -} - -/// Classify a single advertised tool. Security/bounds violations (missing or -/// unsafe input schema) return `Err(cause)` and reject the whole generation; -/// they are evaluated first so a co-occurring cosmetic defect cannot downgrade -/// them to a per-tool skip. Shape-only defects return -/// `Ok(SkippedShapeViolation(cause))`. -fn classify_discovered_tool( - tool: &Value, - max_name_bytes: usize, - max_description_bytes: usize, - max_schema_depth: u8, - max_schema_nodes: usize, - max_schema_string_bytes: usize, -) -> Result { - let input_schema = tool - .get("inputSchema") - .filter(|schema| schema.is_object()) - .cloned() - .ok_or(McpInvalidToolListCause::MissingInputSchema)?; - if !is_supported_mcp_input_schema( - &input_schema, - max_schema_depth, - max_schema_nodes, - max_schema_string_bytes, - ) { - return Err(McpInvalidToolListCause::UnsafeInputSchema); - } - // Discovered tool names become Reborn capability suffixes, so discovery - // skips unsupported names instead of normalizing them into potentially - // colliding capability IDs. - let Some(name) = tool - .get("name") - .and_then(Value::as_str) - .filter(|name| is_supported_mcp_tool_name(name, max_name_bytes)) - else { - return Ok(DiscoveredToolClassification::SkippedShapeViolation( - McpInvalidToolListCause::InvalidToolName, - )); - }; - let description = tool - .get("description") - .and_then(Value::as_str) - .unwrap_or(""); - let Some(description) = bound_mcp_tool_description(description, max_description_bytes) else { - return Ok(DiscoveredToolClassification::SkippedShapeViolation( - McpInvalidToolListCause::InvalidDescription, - )); - }; - let annotations = match parse_tool_annotations(tool.get("annotations")) { - Ok(annotations) => annotations, - Err(cause) => return Ok(DiscoveredToolClassification::SkippedShapeViolation(cause)), - }; - Ok(DiscoveredToolClassification::Published( - HostedMcpDiscoveredTool { - name: name.to_string(), - description: description.to_string(), - input_schema, - annotations, - }, - )) -} - -fn is_supported_mcp_input_schema( - schema: &Value, - max_depth: u8, - max_nodes: usize, - max_string_bytes: usize, -) -> bool { - let mut nodes = 0usize; - validate_mcp_schema_value( - schema, - 0, - max_depth, - max_nodes, - max_string_bytes, - &mut nodes, - ) -} - -fn validate_mcp_schema_value( - value: &Value, - depth: u8, - max_depth: u8, - max_nodes: usize, - max_string_bytes: usize, - nodes: &mut usize, -) -> bool { - if depth > max_depth { - return false; - } - *nodes = nodes.saturating_add(1); - if *nodes > max_nodes { - return false; - } - match value { - Value::String(value) => { - value.len() <= max_string_bytes && !value.chars().any(is_unsupported_description_char) - } - Value::Array(values) => values.iter().all(|value| { - validate_mcp_schema_value( - value, - depth + 1, - max_depth, - max_nodes, - max_string_bytes, - nodes, - ) - }), - Value::Object(values) => values.iter().all(|(key, value)| { - key.len() <= max_string_bytes - && !key.chars().any(is_unsupported_description_char) - && validate_mcp_schema_value( - value, - depth + 1, - max_depth, - max_nodes, - max_string_bytes, - nodes, - ) - }), - _ => true, - } -} - -fn is_unsupported_description_char(value: char) -> bool { - value.is_control() && !matches!(value, '\n' | '\r' | '\t') -} - -/// Preserve a provider's otherwise-valid tool catalog when only descriptive -/// prose exceeds the host display/prompt budget. Names and schemas remain -/// fail-closed because truncating either could change capability semantics; -/// descriptions are presentation metadata and can be safely bounded. -fn bound_mcp_tool_description(value: &str, max_bytes: usize) -> Option { - if value.chars().any(is_unsupported_description_char) { - return None; - } - if value.len() <= max_bytes { - return Some(value.to_string()); - } - - const TRUNCATION_MARKER: &str = "..."; - if max_bytes <= TRUNCATION_MARKER.len() { - return Some(".".repeat(max_bytes)); - } - - let mut end = max_bytes - TRUNCATION_MARKER.len(); - while !value.is_char_boundary(end) { - end -= 1; - } - let prefix = value.get(..end)?; - let mut bounded = String::with_capacity(max_bytes); - bounded.push_str(prefix); - bounded.push_str(TRUNCATION_MARKER); - Some(bounded) -} - -fn parse_tool_annotations( - value: Option<&Value>, -) -> Result { - let Some(value) = value else { - return Ok(HostedMcpDiscoveredToolAnnotations::default()); - }; - let object = value - .as_object() - .ok_or(McpInvalidToolListCause::InvalidAnnotations)?; - let title = object - .get("title") - .map(|value| { - value - .as_str() - .and_then(|title| bound_mcp_tool_description(title, 2_048)) - .ok_or(McpInvalidToolListCause::InvalidAnnotations) - }) - .transpose()?; - Ok(HostedMcpDiscoveredToolAnnotations { - title, - destructive_hint: object - .get("destructiveHint") - .and_then(Value::as_bool) - .unwrap_or(false), - side_effects_hint: object - .get("sideEffectsHint") - .and_then(Value::as_bool) - .unwrap_or(false), - read_only_hint: object - .get("readOnlyHint") - .and_then(Value::as_bool) - .unwrap_or(false), - idempotent_hint: object.get("idempotentHint").and_then(Value::as_bool), - open_world_hint: object.get("openWorldHint").and_then(Value::as_bool), - }) -} - -fn is_supported_mcp_tool_name(value: &str, max_bytes: usize) -> bool { - if value.is_empty() || value.len() > max_bytes || value.contains("..") { - return false; - } - value.split('.').all(is_supported_mcp_tool_name_segment) -} - -fn is_supported_mcp_tool_name_segment(segment: &str) -> bool { - let Some(first) = segment.as_bytes().first().copied() else { - return false; - }; - if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { - return false; - } - segment.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') - }) -} - -fn json_rpc_id(value: &Value) -> Option { - match value.get("id") { - Some(Value::Number(number)) => number.as_u64(), - Some(Value::String(value)) => value.parse::().ok(), - _ => None, - } -} - -fn json_rpc_initialize_params() -> Value { - serde_json::json!({ - "protocolVersion": STREAMABLE_HTTP_MCP_PROTOCOL_VERSION, - "capabilities": { - "roots": { "listChanged": false }, - "sampling": {} - }, - "clientInfo": { - "name": "ironclaw", - "version": env!("CARGO_PKG_VERSION") - } - }) -} - -fn mcp_tool_name(provider: &ExtensionId, capability_id: &CapabilityId) -> String { - let prefix = format!("{}.", provider.as_str()); - capability_id - .as_str() - .strip_prefix(&prefix) - .unwrap_or_else(|| capability_id.as_str()) - .to_string() -} - -fn accumulate_usage(total: &mut ResourceUsage, usage: ResourceUsage) { - total.network_egress_bytes = total - .network_egress_bytes - .saturating_add(usage.network_egress_bytes); - total.output_bytes = total.output_bytes.saturating_add(usage.output_bytes); -} - -/// Maximum byte length for a diagnostic reason string surfaced to the -/// runtime/model. These tokens carry protocol codes, HTTP statuses, and -/// bounded JSON-RPC messages through a private cause channel. They are still -/// untrusted and may contain secrets until the downstream model-visible scrub -/// seam processes them, so every reason is capped here as defense in depth. -const MAX_MCP_REASON_BYTES: usize = 512; - -/// Bound an untrusted diagnostic fragment to [`MAX_MCP_REASON_BYTES`], -/// truncating on a char boundary and appending an ellipsis marker so the -/// reader knows the value was clipped. -fn bound_mcp_reason_detail(detail: &str) -> String { - const ELLIPSIS: &str = "..."; - let normalized: String = detail - .chars() - .map(|c| if c.is_control() { ' ' } else { c }) - .collect(); - if normalized.len() <= MAX_MCP_REASON_BYTES { - return normalized; - } - let budget = MAX_MCP_REASON_BYTES.saturating_sub(ELLIPSIS.len()); - let mut end = budget; - while end > 0 && !normalized.is_char_boundary(end) { - end -= 1; - } - format!("{}{ELLIPSIS}", &normalized[..end]) -} - -/// Per-cause request-side (pre-send / planning) failure tokens. Each carries -/// a stable prefix so callers and the model can classify the failure, plus -/// bounded diagnostic detail where available. -#[derive(Debug, Clone, PartialEq, Eq)] -enum McpRequestDeniedCause { - /// JSON-RPC request body could not be encoded. - EncodeFailed(String), - /// The planned request has no target URL. - MissingUrl, - /// The requested transport is not host-mediated HTTP/SSE. - UnsupportedTransport, - /// A credential injection used a denied source over this boundary. - DeniedCredentialSource, - /// The in-memory session map lock was poisoned. - SessionStatePoisoned, -} - -impl McpRequestDeniedCause { - fn into_reason(self) -> String { - match self { - Self::EncodeFailed(detail) => { - format!( - "mcp_request_encode_failed: {}", - bound_mcp_reason_detail(&detail) - ) - } - Self::MissingUrl => "mcp_missing_url".to_string(), - Self::UnsupportedTransport => "mcp_unsupported_transport".to_string(), - Self::DeniedCredentialSource => "mcp_denied_credential_source".to_string(), - Self::SessionStatePoisoned => "mcp_session_state_poisoned".to_string(), - } - } -} - -/// Per-cause response-side failure tokens. Each carries a stable prefix plus -/// bounded diagnostic detail (HTTP status, JSON-RPC code/message, -/// parse-failure cause) for the private model-visible cause channel. -#[derive(Debug, Clone, PartialEq, Eq)] -enum McpResponseErrorCause { - /// Non-2xx HTTP status from the MCP endpoint. - HttpStatus(u16), - /// JSON-RPC `error` object with code and bounded message. - JsonRpcError { - code: Option, - message: Option, - }, - /// Response body failed JSON parsing. - ParseFailed(String), - /// A successful response carried no `result` field. - MissingResult, - /// The endpoint returned an unsafe/oversized `Mcp-Session-Id`. - InvalidSessionId, - /// The `initialize` response carried an unsafe/missing protocol version. - InvalidProtocolVersion, - /// JSON-RPC response `id` did not match the request id. - IdMismatch, - /// Response did not contain a usable JSON-RPC payload (e.g. SSE with no - /// matching data frame). - NoPayload, - /// Discovered `tools/list` result was malformed (shape/limits violation). - InvalidToolList(McpInvalidToolListCause), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum McpInvalidToolListCause { - MissingToolsArray, - TooManyTools, - InvalidToolName, - InvalidDescription, - MissingInputSchema, - UnsafeInputSchema, - InvalidAnnotations, - InvalidCursor, - TooManyPages, - CatalogTooLarge, -} - -impl McpInvalidToolListCause { - const fn stable_token(self) -> &'static str { - match self { - Self::MissingToolsArray => "missing_tools_array", - Self::TooManyTools => "too_many_tools", - Self::InvalidToolName => "invalid_tool_name", - Self::InvalidDescription => "invalid_description", - Self::MissingInputSchema => "missing_input_schema", - Self::UnsafeInputSchema => "unsafe_input_schema", - Self::InvalidAnnotations => "invalid_annotations", - Self::InvalidCursor => "invalid_cursor", - Self::TooManyPages => "too_many_pages", - Self::CatalogTooLarge => "catalog_too_large", - } - } -} - -impl McpResponseErrorCause { - fn into_reason(self) -> String { - match self { - Self::HttpStatus(status) => format!("mcp_http_status_{status}"), - Self::JsonRpcError { code, message } => { - let mut reason = String::from("mcp_jsonrpc_error"); - if let Some(code) = code { - reason.push_str(&format!(" code={code}")); - } - if let Some(message) = message { - reason.push_str(": "); - reason.push_str(&message); - } - reason - } - Self::ParseFailed(detail) => { - format!("mcp_parse_failed: {}", bound_mcp_reason_detail(&detail)) - } - Self::MissingResult => "mcp_missing_result".to_string(), - Self::InvalidSessionId => "mcp_invalid_session_id".to_string(), - Self::InvalidProtocolVersion => "mcp_invalid_protocol_version".to_string(), - Self::IdMismatch => "mcp_jsonrpc_id_mismatch".to_string(), - Self::NoPayload => "mcp_no_payload".to_string(), - Self::InvalidToolList(cause) => { - format!("mcp_invalid_tool_list: {}", cause.stable_token()) - } - } - } -} - -fn request_denied(cause: McpRequestDeniedCause) -> String { - cause.into_reason() -} - -fn response_error(cause: McpResponseErrorCause) -> String { - cause.into_reason() -} - -fn invalid_tool_list(cause: McpInvalidToolListCause) -> String { - response_error(McpResponseErrorCause::InvalidToolList(cause)) -} - -/// MCP runtime failures. -#[derive(Debug, Error)] -pub enum McpError { - #[error("resource governor error: {0}")] - Resource(RuntimeResourceError), - #[error("MCP client error: {reason}")] - Client { reason: String }, - #[error("MCP server advertised an invalid tool catalog: {reason}")] - InvalidToolCatalog { reason: String }, - #[error("MCP capability requires authentication")] - AuthRequired { - required_secrets: Vec, - credential_requirements: Vec, - }, - #[error("unsupported MCP transport {transport}")] - UnsupportedTransport { transport: String }, - #[error("MCP transport {transport} requires host-mediated HTTP egress")] - HostHttpEgressRequired { transport: String }, - #[error("stdio MCP transport is unsupported until process-level egress controls land")] - ExternalStdioTransportUnsupported, - #[error("extension {extension} uses runtime {actual:?}, not RuntimeKind::Mcp")] - ExtensionRuntimeMismatch { - extension: ExtensionId, - actual: RuntimeKind, - }, - #[error("capability {capability} is not declared by this extension package")] - CapabilityNotDeclared { capability: CapabilityId }, - #[error("MCP descriptor mismatch: {reason}")] - DescriptorMismatch { reason: String }, - #[error("invalid MCP invocation: {reason}")] - InvalidInvocation { reason: String }, - #[error("MCP output limit exceeded: limit {limit}, actual {actual}")] - OutputLimitExceeded { limit: u64, actual: u64 }, -} - -impl From for McpError { - fn from(error: RuntimeResourceError) -> Self { - Self::Resource(error) - } -} - -/// Runtime for executing manifest-declared MCP capabilities through a host adapter. -#[derive(Debug, Clone)] -pub struct McpRuntime { - config: McpRuntimeConfig, - client: C, -} - -impl McpRuntime -where - C: McpClient, -{ - pub fn new(config: McpRuntimeConfig, client: C) -> Self { - Self { config, client } - } - - pub fn config(&self) -> &McpRuntimeConfig { - &self.config - } - - pub async fn execute_extension_json( - &self, - budget: &Budget, - request: McpExecutionRequest<'_>, - ) -> Result - where - Budget: RuntimeResourceBudget + ?Sized, - { - let client_request = self.prepare_client_request(&request)?; - let auth_context = client_request.auth_context; - let client_request = client_request.request; - let transport = client_request.transport.clone(); - if requires_host_http_egress(&transport) && !self.client.uses_host_mediated_http_egress() { - return Err(McpError::HostHttpEgressRequired { transport }); - } - let reservation = reserve_or_use_existing( - budget, - request.scope.clone(), - request.estimate.clone(), - request.resource_reservation.clone(), - )?; - - let output = match self.client.call_tool(client_request).await { - Ok(output) => output, - Err(error) => { - return Err(release_after_failure( - budget, - reservation.id, - mcp_error_from_client_error(error, auth_context), - )); - } - }; - - let serialized_len = serde_json::to_vec(&output.output) - .map_err(|error| { - release_after_failure( - budget, - reservation.id, - McpError::InvalidInvocation { - reason: error.to_string(), - }, - ) - })? - .len() as u64; - let output_bytes = output - .output_bytes - .unwrap_or(serialized_len) - .max(serialized_len); - if output_bytes > self.config.max_output_bytes { - return Err(release_after_failure( - budget, - reservation.id, - McpError::OutputLimitExceeded { - limit: self.config.max_output_bytes, - actual: output_bytes, - }, - )); - } - - let mut usage = output.usage; - usage.output_bytes = usage.output_bytes.max(output_bytes); - if transport == "stdio" { - usage.process_count = usage.process_count.max(1); - } - let receipt = budget.reconcile(reservation.id, usage.clone())?; - Ok(McpExecutionResult { - result: CapabilityHostResult { - output: output.output, - reservation_id: reservation.id, - usage, - output_bytes, - }, - receipt, - }) - } - - fn prepare_client_request( - &self, - request: &McpExecutionRequest<'_>, - ) -> Result { - let descriptor = request - .capabilities - .iter() - .find(|descriptor| &descriptor.id == request.capability_id) - .cloned() - .ok_or_else(|| McpError::CapabilityNotDeclared { - capability: request.capability_id.clone(), - })?; - - if descriptor.runtime != RuntimeKind::Mcp { - return Err(McpError::ExtensionRuntimeMismatch { - extension: request.extension.clone(), - actual: descriptor.runtime, - }); - } - if descriptor.provider != *request.extension { - return Err(McpError::DescriptorMismatch { - reason: format!( - "descriptor {} provider {} does not match package {}", - descriptor.id, descriptor.provider, *request.extension - ), - }); - } - - let (transport, command, args, url) = match request.runtime { - ExtensionRuntime::Mcp { - transport, - command, - args, - url, - } => (transport, command, args, url), - other => { - return Err(McpError::ExtensionRuntimeMismatch { - extension: request.extension.clone(), - actual: other.kind(), - }); - } - }; - - if transport == "stdio" { - return Err(McpError::ExternalStdioTransportUnsupported); - } - if !matches!(transport.as_str(), "http" | "sse") { - return Err(McpError::UnsupportedTransport { - transport: transport.clone(), - }); - } - if matches!(transport.as_str(), "http" | "sse") && url.is_none() { - return Err(McpError::InvalidInvocation { - reason: format!("{transport} MCP transport requires a manifest url"), - }); - } - - let auth_context = mcp_auth_context(&descriptor.provider, &descriptor.runtime_credentials); - - Ok(PreparedMcpClientRequest { - request: McpClientRequest { - provider: request.extension.clone(), - capability_id: request.capability_id.clone(), - scope: request.scope.clone(), - transport: transport.clone(), - command: command.clone(), - args: args.clone(), - url: url.clone(), - input: request.invocation.input.clone(), - max_output_bytes: self.config.max_output_bytes, - }, - auth_context, - }) - } -} - -fn mcp_error_from_client_error(error: McpClientError, auth_context: McpAuthContext) -> McpError { - match error { - McpClientError::Client { reason } => McpError::Client { reason }, - McpClientError::InvalidToolCatalog { reason } => McpError::InvalidToolCatalog { reason }, - McpClientError::AuthRequired | McpClientError::AuthChallenge { .. } => { - McpError::AuthRequired { - required_secrets: auth_context.required_secrets, - credential_requirements: auth_context.credential_requirements, - } - } - } -} - -fn mcp_auth_context( - requester_extension: &ExtensionId, - credentials: &[RuntimeCredentialRequirement], -) -> McpAuthContext { - let mut required_secrets = Vec::new(); - let mut credential_requirements = Vec::new(); - for credential in credentials.iter().filter(|credential| credential.required) { - match &credential.source { - RuntimeCredentialRequirementSource::SecretHandle => { - required_secrets.push(credential.handle.clone()); - } - RuntimeCredentialRequirementSource::ProductAuthAccount { .. } => { - if let Some(requirement) = - credential.product_auth_requirement_for(requester_extension.clone()) - { - credential_requirements.push(requirement); - } - } - } - } - McpAuthContext { - required_secrets, - credential_requirements, - } -} - -/// Object-safe MCP executor interface used by the kernel composition layer. -#[async_trait] -pub trait McpExecutor: Send + Sync { - async fn execute_extension_json( - &self, - budget: &dyn RuntimeResourceBudget, - request: McpExecutionRequest<'_>, - ) -> Result; -} - -#[async_trait] -impl McpExecutor for McpRuntime -where - C: McpClient, -{ - async fn execute_extension_json( - &self, - budget: &dyn RuntimeResourceBudget, - request: McpExecutionRequest<'_>, - ) -> Result { - McpRuntime::execute_extension_json(self, budget, request).await - } -} - -fn requires_host_http_egress(transport: &str) -> bool { - matches!(transport, "http" | "sse") -} - -fn reserve_or_use_existing( - budget: &Budget, - scope: ResourceScope, - estimate: ResourceEstimate, - reservation: Option, -) -> Result -where - Budget: RuntimeResourceBudget + ?Sized, -{ - if let Some(reservation) = reservation { - if reservation.scope != scope || reservation.estimate != estimate { - return Err(McpError::Resource( - RuntimeResourceError::reservation_mismatch(reservation.id), - )); - } - return Ok(reservation); - } - budget.reserve(scope, estimate).map_err(McpError::from) -} - -fn release_after_failure( - budget: &Budget, - reservation_id: ResourceReservationId, - original: McpError, -) -> McpError -where - Budget: RuntimeResourceBudget + ?Sized, -{ - let _ = budget.release(reservation_id); - original -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn mcp_auth_context_preserves_product_auth_oauth_setup() { - let scopes = vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]; - let credential = RuntimeCredentialRequirement { - handle: SecretHandle::new("google-drive-access").unwrap(), - source: RuntimeCredentialRequirementSource::ProductAuthAccount { - provider: ironclaw_host_api::ids::VendorId::new("google").unwrap(), - setup: ironclaw_host_api::capability::RuntimeCredentialAccountSetup::OAuth { - scopes: scopes.clone(), - }, - }, - provider_scopes: scopes.clone(), - audience: ironclaw_host_api::action::NetworkTargetPattern { - scheme: None, - host_pattern: "*".to_string(), - port: None, - }, - target: ironclaw_host_api::http::RuntimeCredentialTarget::Header { - name: "authorization".to_string(), - prefix: Some("Bearer ".to_string()), - }, - required: true, - }; - - let context = mcp_auth_context(&ExtensionId::new("google-drive").unwrap(), &[credential]); - - assert!(context.required_secrets.is_empty()); - assert_eq!( - context.credential_requirements, - vec![RuntimeCredentialAuthRequirement { - provider: ironclaw_host_api::ids::VendorId::new("google").unwrap(), - setup: ironclaw_host_api::capability::RuntimeCredentialAccountSetup::OAuth { - scopes - }, - requester_extension: ExtensionId::new("google-drive").unwrap(), - provider_scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()], - }] - ); - } - - #[test] - fn parse_tools_list_result_rejects_oversized_tool_list() { - let tools = (0..129) - .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) - .collect::>(); - - let error = parse_tools_list_result(&json!({ "tools": tools }), 128) - .expect_err("tool discovery must cap returned tools"); - - assert_eq!(error, "mcp_invalid_tool_list: too_many_tools"); - } - - #[test] - fn parse_tools_list_result_honors_manifest_budget_under_host_cap() { - let tools = (0..129) - .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) - .collect::>(); - - let discovered = parse_tools_list_result(&json!({ "tools": tools }), 256) - .expect("the manifest may declare a catalog larger than the old hidden limit"); - - assert_eq!(discovered.len(), 129); - } - - #[test] - fn parse_tools_list_result_caps_manifest_budget_at_host_maximum() { - let tools = (0..1025) - .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) - .collect::>(); - - let error = parse_tools_list_result(&json!({ "tools": tools }), u32::MAX) - .expect_err("provider-declared budgets cannot exceed the host ceiling"); - - assert_eq!(error, "mcp_invalid_tool_list: too_many_tools"); - } - - #[test] - fn parse_tools_list_result_rejects_unsupported_description_control_char() { - let mut tool = valid_tool("search", json!({"type": "object"})); - tool["description"] = json!("bad\u{0000}description"); - - let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) - .expect_err("unsupported description control characters must fail"); - - assert_eq!(error, "mcp_invalid_tool_list: invalid_description"); - } - - #[test] - fn parse_tools_list_result_bounds_utf8_description_at_character_boundary() { - let mut tool = valid_tool("search", json!({"type": "object"})); - tool["description"] = json!("🔧".repeat(600)); - - let tools = parse_tools_list_result(&json!({ "tools": [tool] }), 128) - .expect("descriptive prose must not invalidate the catalog"); - let description = &tools[0].description; - - assert!(description.len() <= 2_048); - assert!(description.ends_with("...")); - assert!(description.is_char_boundary(description.len())); - } - - #[test] - fn parse_tools_list_result_accepts_bounded_real_world_openapi_schema_shape() { - // OpenAPI-derived MCP catalogs legitimately exceed the old depth-8 / - // 512-node parser constants. The response body remains independently - // bounded by the host egress plan, so a safe, finite schema within the - // catalog budget must not make the whole extension unactivatable. - let tool = valid_tool( - "update-resource", - json!({ - "type": "object", - "properties": { - "nested": nested_schema(5), - "wide": wide_schema(600) - } - }), - ); - - let tools = parse_tools_list_result(&json!({ "tools": [tool] }), 128) - .expect("bounded OpenAPI-derived schemas must remain discoverable"); - - assert_eq!(tools.len(), 1); - } - - #[test] - fn parse_tools_list_result_rejects_missing_or_non_object_schema() { - let mut missing_schema = valid_tool("missing-schema", json!({"type": "object"})); - missing_schema - .as_object_mut() - .expect("test tool object") - .remove("inputSchema"); - let non_object_schema = valid_tool("bad-schema", json!("object please")); - - for tool in [missing_schema, non_object_schema] { - let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) - .expect_err("schema must be present and object-shaped"); - - assert_eq!(error, "mcp_invalid_tool_list: missing_input_schema"); - } - } - - #[test] - fn parse_tools_list_result_rejects_unsafe_schema_strings_and_shape() { - let cases = [ - valid_tool( - "control", - json!({"type": "object", "description": "bad\u{0008}schema"}), - ), - valid_tool( - "long-string", - json!({"type": "object", "description": "a".repeat(16 * 1024 + 1)}), - ), - valid_tool("too-deep", nested_schema(17)), - valid_tool("too-many-nodes", wide_schema(8193)), - ]; - - for tool in cases { - let error = parse_tools_list_result(&json!({ "tools": [tool] }), 128) - .expect_err("unsafe schema strings and shape must fail"); - - assert_eq!(error, "mcp_invalid_tool_list: unsafe_input_schema"); - } - } - - #[test] - #[tracing_test::traced_test] - fn parse_tools_list_result_skips_shape_invalid_tools_and_publishes_bounded_remainder() { - // A real MCP server can advertise a mostly-valid catalog alongside a - // few shape-nonconforming entries (an uppercase tool name, a - // control-char description). Those individual tools are dropped and - // recorded, but the remaining valid tools must still publish so one - // malformed entry cannot brick the whole integration on first install. - let mut tools = (0..24) - .map(|index| valid_tool(&format!("tool-{index}"), json!({"type": "object"}))) - .collect::>(); - tools[5]["name"] = json!("UppercaseName"); - tools[10]["description"] = json!("bad\u{0000}description"); - - let published = parse_tools_list_result(&json!({ "tools": tools }), 128) - .expect("a bounded catalog must survive a few shape-nonconforming tools"); - - assert_eq!(published.len(), 22); - assert!( - published.iter().all(|tool| tool.name != "UppercaseName"), - "the uppercase-named tool must not be published" - ); - assert!( - published.iter().any(|tool| tool.name == "tool-0"), - "valid tools before the skipped entries must still publish" - ); - assert!( - published.iter().any(|tool| tool.name == "tool-23"), - "valid tools after the skipped entries must still publish" - ); - assert!(logs_contain("skipping shape-nonconforming hosted MCP tool")); - assert!(logs_contain("invalid_tool_name")); - assert!(logs_contain("invalid_description")); - } - - #[test] - fn parse_tools_list_result_fails_whole_catalog_when_unsafe_schema_amid_valid_tools() { - // Security/bounds violations are never downgraded to a per-tool skip: - // a single over-deep (DoS-shaped) input schema fails the entire - // generation even when it is surrounded by otherwise-valid tools, so a - // hostile entry cannot smuggle itself in by riding a valid catalog. - let mut tools = vec![ - valid_tool("alpha", json!({"type": "object"})), - valid_tool("beta", json!({"type": "object"})), - ]; - tools.insert(1, valid_tool("too-deep", nested_schema(64))); - - let error = parse_tools_list_result(&json!({ "tools": tools }), 128) - .expect_err("an unsafe schema must fail the whole catalog even with valid neighbors"); - - assert_eq!(error, "mcp_invalid_tool_list: unsafe_input_schema"); - } - - #[test] - fn parse_tools_list_result_fails_when_every_tool_is_shape_invalid() { - // When nothing survives the shape filter there is nothing to publish, - // so discovery still fails non-retryably with a stable subcause rather - // than activating on an empty catalog. - let tools = vec![ - valid_tool("Uppercase-A", json!({"type": "object"})), - valid_tool("Uppercase-B", json!({"type": "object"})), - ] - .into_iter() - .map(|mut tool| { - let bad = tool["name"].as_str().unwrap().to_string(); - tool["name"] = json!(bad); - tool - }) - .collect::>(); - - let error = parse_tools_list_result(&json!({ "tools": tools }), 128) - .expect_err("a catalog with no shape-valid tools must not activate"); - - assert_eq!(error, "mcp_invalid_tool_list: invalid_tool_name"); - } - - #[test] - fn parse_tools_list_result_preserves_empty_provider_catalog_as_empty() { - // An empty provider list (no advertised tools, nothing skipped) is not - // a shape failure: it stays an empty result the caller treats as "no - // tools discovered yet", distinct from the all-skipped failure above. - let published = parse_tools_list_result(&json!({ "tools": [] }), 128) - .expect("an empty provider catalog is not a shape failure"); - - assert!(published.is_empty()); - } - - #[test] - fn is_supported_mcp_tool_name_boundary_cases() { - let exactly_128 = "a".repeat(128); - let too_long = "a".repeat(129); - - assert!(!is_supported_mcp_tool_name("", 128)); - assert!(is_supported_mcp_tool_name(&exactly_128, 128)); - assert!(!is_supported_mcp_tool_name(&too_long, 128)); - assert!(!is_supported_mcp_tool_name("search..issues", 128)); - assert!(!is_supported_mcp_tool_name("Search", 128)); - assert!(!is_supported_mcp_tool_name("search._private", 128)); - } - - #[test] - fn mcp_tool_name_strips_provider_prefix_for_canonical_tool_name() { - let provider = ExtensionId::new("nearai").unwrap(); - let capability_id = CapabilityId::new("nearai.web_search").unwrap(); - - assert_eq!(mcp_tool_name(&provider, &capability_id), "web_search"); - } - - #[test] - fn parse_mcp_sse_response_skips_empty_data_keepalives() { - let body = b"event: ping\ndata:\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n"; - - let response = parse_mcp_sse_response(body, Some(7)) - .expect("empty SSE data lines should not abort parsing"); - - assert_eq!(response.result, Some(json!({"ok": true}))); - assert!(response.error.is_none()); - } - - #[test] - fn parse_mcp_sse_response_joins_one_events_data_lines() { - let body = br##"event: message -data: { -data: "jsonrpc": "2.0", -data: "id": 7, -data: "result": { -data: "content": [ -data: {"type": "text", "text": "# NEAR AI\nURL: https://cloud-api.near.ai"} -data: ] -data: } -data: } - -"##; - - let response = parse_mcp_sse_response(body, Some(7)) - .expect("one SSE event may split its JSON over repeated data lines"); - - assert_eq!( - response.result, - Some(json!({ - "content": [{ - "type": "text", - "text": "# NEAR AI\nURL: https://cloud-api.near.ai" - }] - })) - ); - assert!(response.error.is_none()); - } - - /// Build an `McpHostHttpResponse` with a caller-chosen `content-type` and - /// raw body bytes — the two inputs `parse_mcp_response` sniffs to pick the - /// SSE vs plain-JSON branch. Fixtures below are hand-authored (there are no - /// live-captured MCP response bodies under `tests/fixtures/`), but their - /// framings mirror what a spec-compliant Streamable-HTTP MCP server emits. - fn mcp_response(content_type: &str, body: &[u8]) -> McpHostHttpResponse { - McpHostHttpResponse { - status: 200, - headers: vec![("content-type".to_string(), content_type.to_string())], - body: body.to_vec(), - saved_body: None, - request_bytes: 0, - response_bytes: body.len() as u64, - redaction_applied: false, - } - } - - /// Format matrix for the single `parse_mcp_response` dispatch that every - /// JSON-RPC leg (`initialize`/`tools/list`/`tools/call`) funnels through. - /// The client advertises `Accept: application/json, text/event-stream` - /// (two content types), so the parser must accept BOTH framings for the - /// same logical response — this pins that parity at the dispatch entry - /// point, not just at `parse_mcp_sse_response` (already covered above). - #[test] - fn parse_mcp_response_accepts_both_advertised_framings() { - let id = Some(7u64); - let ok_body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; - - // Plain JSON framing (content-type application/json). - let json = parse_mcp_response(&mcp_response("application/json", ok_body), id) - .expect("plain JSON framing parses"); - assert_eq!(json.result, Some(json!({"ok": true}))); - assert!(json.error.is_none()); - - // SSE single-event framing (content-type text/event-stream). - let sse_single = parse_mcp_response( - &mcp_response( - "text/event-stream", - b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n", - ), - id, - ) - .expect("SSE single-event framing parses"); - assert_eq!(sse_single.result, Some(json!({"ok": true}))); - - // SSE multi-event framing with a leading keepalive ping — the real - // frame ordering a streaming server emits. - let sse_multi = parse_mcp_response( - &mcp_response( - "text/event-stream; charset=utf-8", - b"event: ping\ndata:\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n", - ), - id, - ) - .expect("SSE multi-event framing parses past the keepalive"); - assert_eq!(sse_multi.result, Some(json!({"ok": true}))); - } - - /// Error-object framing (a JSON-RPC `error` member) is surfaced as - /// `error == true` — in BOTH framings — rather than mis-parsed as success - /// or dropped. This is the recoverable, model-visible tool-error leg. - #[test] - fn parse_mcp_response_flags_error_object_in_both_framings() { - let id = Some(3u64); - let json_err = parse_mcp_response( - &mcp_response( - "application/json", - br#"{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"bad"}}"#, - ), - id, - ) - .expect("JSON error-object is a valid response, not a parse failure"); - assert!( - json_err.error.is_some(), - "plain-JSON error object flags error" - ); - assert_eq!(json_err.result, None, "error object carries no result"); - - let sse_err = parse_mcp_response( - &mcp_response( - "text/event-stream", - b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":3,\"error\":{\"code\":-32602,\"message\":\"bad\"}}\n\n", - ), - id, - ) - .expect("SSE error-object is a valid response, not a parse failure"); - assert!( - sse_err.error.is_some(), - "SSE-framed error object flags error" - ); - assert_eq!(sse_err.result, None, "error object carries no result"); - } - - /// Empty / malformed bodies are rejected in both framings (mutation guard: - /// a parser that returned an empty-`result` success here would flip these - /// `Err`s to `Ok`). An empty plain-JSON body has no JSON value; an SSE body - /// with only keepalives has no `data:` payload carrying the expected id. - #[test] - fn parse_mcp_response_rejects_empty_bodies_in_both_framings() { - let id = Some(9u64); - // Per-cause diagnostic tokens replaced the flat "response_error": an - // unparseable JSON body reports `mcp_parse_failed` (with a bounded - // serde detail), and an SSE stream with no id-matching data reports - // `mcp_no_payload`. Both remain hard errors, not silent successes. - let empty_json_err = parse_mcp_response(&mcp_response("application/json", b""), id) - .expect_err("empty plain-JSON body must not parse as a success"); - assert!( - empty_json_err.starts_with("mcp_parse_failed"), - "empty plain-JSON body must report a parse failure, got {empty_json_err:?}" - ); - assert_eq!( - parse_mcp_response( - &mcp_response("text/event-stream", b"event: ping\ndata:\n\n"), - id, - ) - .unwrap_err(), - "mcp_no_payload", - "SSE body with only keepalives (no id-matching data) must not parse" - ); - } - - fn valid_tool(name: &str, input_schema: Value) -> Value { - json!({ - "name": name, - "description": "Search hosted data", - "inputSchema": input_schema - }) - } - - fn nested_schema(depth: usize) -> Value { - let mut value = json!({"type": "string"}); - for _ in 0..depth { - value = json!({"type": "object", "properties": {"next": value}}); - } - value - } - - fn wide_schema(nodes: usize) -> Value { - let properties = (0..nodes) - .map(|index| (format!("field_{index}"), json!({"type": "string"}))) - .collect::>(); - json!({"type": "object", "properties": properties}) - } - - fn json_response(status: u16, body: Value) -> McpHostHttpResponse { - McpHostHttpResponse { - status, - headers: vec![("content-type".to_string(), "application/json".to_string())], - body: serde_json::to_vec(&body).expect("serialize test body"), - saved_body: None, - request_bytes: 0, - response_bytes: 0, - redaction_applied: false, - } - } - - #[test] - fn non_2xx_http_status_reason_carries_status_code() { - // The 404 path is a direct `response_error(HttpStatus(..))` at the - // send call site; the cause-to-token mapping is the load-bearing part. - let reason = response_error(McpResponseErrorCause::HttpStatus(404)); - assert_eq!(reason, "mcp_http_status_404"); - assert!(reason.contains("404")); - - let reason = response_error(McpResponseErrorCause::HttpStatus(503)); - assert_eq!(reason, "mcp_http_status_503"); - } - - #[test] - fn json_rpc_error_response_reason_carries_code_and_message() { - let response = json_response( - 200, - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { "code": -32601, "message": "Method not found" } - }), - ); - - let parsed = parse_mcp_response(&response, Some(1)).expect("parse json-rpc error response"); - let error = parsed.error.expect("error object captured"); - - // Drive the same reason construction the call sites use. - let reason = response_error(McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }); - assert!( - reason.contains("-32601"), - "reason should carry the standardized protocol code: {reason}" - ); - assert!( - reason.contains("Method not found"), - "backend diagnostic should reach the private cause channel: {reason}" - ); - assert!(reason.starts_with("mcp_jsonrpc_error")); - } - - #[test] - fn json_rpc_error_without_structured_fields_still_classifies() { - let response = json_response(200, json!({ "jsonrpc": "2.0", "id": 4, "error": "boom" })); - - let parsed = parse_mcp_response(&response, Some(4)).expect("parse non-object error"); - let error = parsed.error.expect("error present even when non-object"); - assert_eq!(error.code, None); - assert_eq!(error.message, None); - let reason = response_error(McpResponseErrorCause::JsonRpcError { - code: error.code, - message: error.message, - }); - assert_eq!(reason, "mcp_jsonrpc_error"); - } - - #[test] - fn auth_challenge_redacts_response_body_and_preserves_only_metadata_locations() { - let response = McpHostHttpResponse { - status: 401, - headers: vec![ - ( - "WWW-Authenticate".to_string(), - "Bearer resource_metadata=\"https://issuer.example.test/.well-known/oauth-protected-resource?access_token=secret\"".to_string(), - ), - ( - "protected-resource-metadata".to_string(), - "https://resource.example.test/.well-known/oauth-protected-resource#secret" - .to_string(), - ), - ], - body: b"token=super-secret remote diagnostic".to_vec(), - saved_body: None, - request_bytes: 0, - response_bytes: 42, - redaction_applied: false, - }; - - let challenge = mcp_auth_challenge_from_response(&response); - assert_eq!(challenge.status, 401); - assert_eq!( - challenge.www_authenticate_metadata[0].as_str(), - "https://issuer.example.test/.well-known/oauth-protected-resource" - ); - assert_eq!( - challenge.protected_resource_metadata[0].as_str(), - "https://resource.example.test/.well-known/oauth-protected-resource" - ); - let rendered = format!("{challenge:?}"); - assert!(!rendered.contains("super-secret")); - assert!(!rendered.contains("access_token")); - } - - #[test] - fn tools_list_page_preserves_accepted_catalog_fields_exactly() { - let schema = json!({"type": "object", "properties": {"q": {"type": "string"}}}); - let value = json!({ - "tools": [{ - "name": "search.docs", - "description": "Find docs\nwithout rewriting provider text.", - "inputSchema": schema, - "annotations": {"readOnlyHint": true} - }], - "nextCursor": "second-page" - }); - - let (tools, cursor) = parse_tools_list_page(&value).expect("valid page"); - assert_eq!(cursor.as_deref(), Some("second-page")); - assert_eq!(tools[0].name, "search.docs"); - assert_eq!( - tools[0].description, - "Find docs\nwithout rewriting provider text." - ); - assert_eq!(tools[0].input_schema, schema); - assert!(tools[0].annotations.read_only_hint); - } - - #[test] - fn tools_list_page_rejects_non_string_cursor() { - let error = parse_tools_list_page(&json!({ - "tools": [valid_tool("search", json!({"type": "object"}))], - "nextCursor": 12 - })) - .expect_err("cursor is protocol data, not a value to normalize"); - assert_eq!(error, "mcp_invalid_tool_list: invalid_cursor"); - } - - #[test] - fn malformed_json_body_reason_names_parse_failure() { - let response = McpHostHttpResponse { - status: 200, - headers: vec![("content-type".to_string(), "application/json".to_string())], - body: b"{ this is not json".to_vec(), - saved_body: None, - request_bytes: 0, - response_bytes: 0, - redaction_applied: false, - }; - - let reason = parse_mcp_response(&response, Some(1)).expect_err("malformed body must fail"); - assert!( - reason.starts_with("mcp_parse_failed:"), - "reason should name parse failure: {reason}" - ); - } - - #[test] - fn successful_result_response_has_no_error() { - let response = json_response( - 200, - json!({ "jsonrpc": "2.0", "id": 9, "result": { "ok": true } }), - ); - - let parsed = parse_mcp_response(&response, Some(9)).expect("success path unchanged"); - assert_eq!(parsed.result, Some(json!({ "ok": true }))); - assert!(parsed.error.is_none()); - } - - #[test] - fn id_mismatch_reason_is_stable_token() { - let response = json_response( - 200, - json!({ "jsonrpc": "2.0", "id": 2, "result": { "ok": true } }), - ); - - let reason = parse_mcp_response(&response, Some(1)).expect_err("id mismatch must fail"); - assert_eq!(reason, "mcp_jsonrpc_id_mismatch"); - } - - #[test] - fn request_denied_causes_map_to_stable_tokens() { - assert_eq!( - request_denied(McpRequestDeniedCause::MissingUrl), - "mcp_missing_url" - ); - assert_eq!( - request_denied(McpRequestDeniedCause::UnsupportedTransport), - "mcp_unsupported_transport" - ); - assert_eq!( - request_denied(McpRequestDeniedCause::DeniedCredentialSource), - "mcp_denied_credential_source" - ); - assert_eq!( - request_denied(McpRequestDeniedCause::SessionStatePoisoned), - "mcp_session_state_poisoned" - ); - let encode = request_denied(McpRequestDeniedCause::EncodeFailed("eof".to_string())); - assert!(encode.starts_with("mcp_request_encode_failed: ")); - assert!(encode.contains("eof")); - } - - #[test] - fn invalid_session_and_protocol_reasons_are_distinct_tokens() { - assert_eq!( - response_error(McpResponseErrorCause::InvalidSessionId), - "mcp_invalid_session_id" - ); - assert_eq!( - response_error(McpResponseErrorCause::InvalidProtocolVersion), - "mcp_invalid_protocol_version" - ); - assert_eq!( - response_error(McpResponseErrorCause::MissingResult), - "mcp_missing_result" - ); - } - - #[test] - fn reason_detail_is_bounded_and_strips_control_chars() { - let long = "a".repeat(10_000); - let bounded = bound_mcp_reason_detail(&long); - assert!(bounded.len() <= MAX_MCP_REASON_BYTES); - assert!(bounded.ends_with("...")); - - let with_control = bound_mcp_reason_detail("line\nbreak\u{0000}null"); - assert!(!with_control.contains('\n')); - assert!(!with_control.contains('\u{0000}')); - } -} +pub use runtime::McpRuntime; diff --git a/crates/ironclaw_mcp/src/runtime.rs b/crates/ironclaw_mcp/src/runtime.rs new file mode 100644 index 00000000000..12d4bda8335 --- /dev/null +++ b/crates/ironclaw_mcp/src/runtime.rs @@ -0,0 +1,340 @@ +//! Resource-governed execution of a manifest-declared MCP capability. +//! +//! This module is the lane's authority boundary: it admits the descriptor +//! against the package the caller projected, reserves against the host budget, +//! calls the configured [`McpClient`], and reconciles or releases — never both. +//! It also assembles the manifest credential context an authentication failure +//! reports back, which is the only place the lane reads +//! `RuntimeCredentialRequirement`. It speaks no protocol and sends no bytes. + +use async_trait::async_trait; +use ironclaw_extension_contracts::runtime::ExtensionRuntime; +use ironclaw_host_api::{ + capability::{RuntimeCredentialRequirement, RuntimeCredentialRequirementSource}, + decision::RuntimeCredentialAuthRequirement, + ids::{ExtensionId, ResourceReservationId, SecretHandle}, + resource::{ + CapabilityHostResult, ResourceEstimate, ResourceReservation, ResourceScope, + RuntimeResourceBudget, RuntimeResourceError, + }, + runtime::RuntimeKind, +}; + +use crate::contract::{ + McpClient, McpClientError, McpClientRequest, McpError, McpExecutionRequest, McpExecutionResult, + McpExecutor, McpRuntimeConfig, +}; +use crate::egress::requires_host_http_egress; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct McpAuthContext { + required_secrets: Vec, + credential_requirements: Vec, +} + +#[derive(Debug)] +struct PreparedMcpClientRequest { + request: McpClientRequest, + auth_context: McpAuthContext, +} + +/// Runtime for executing manifest-declared MCP capabilities through a host adapter. +#[derive(Debug, Clone)] +pub struct McpRuntime { + config: McpRuntimeConfig, + client: C, +} + +impl McpRuntime +where + C: McpClient, +{ + pub fn new(config: McpRuntimeConfig, client: C) -> Self { + Self { config, client } + } + + pub fn config(&self) -> &McpRuntimeConfig { + &self.config + } + + pub async fn execute_extension_json( + &self, + budget: &Budget, + request: McpExecutionRequest<'_>, + ) -> Result + where + Budget: RuntimeResourceBudget + ?Sized, + { + let client_request = self.prepare_client_request(&request)?; + let auth_context = client_request.auth_context; + let client_request = client_request.request; + let transport = client_request.transport.clone(); + if requires_host_http_egress(&transport) && !self.client.uses_host_mediated_http_egress() { + return Err(McpError::HostHttpEgressRequired { transport }); + } + let reservation = reserve_or_use_existing( + budget, + request.scope.clone(), + request.estimate.clone(), + request.resource_reservation.clone(), + )?; + + let output = match self.client.call_tool(client_request).await { + Ok(output) => output, + Err(error) => { + return Err(release_after_failure( + budget, + reservation.id, + mcp_error_from_client_error(error, auth_context), + )); + } + }; + + let serialized_len = serde_json::to_vec(&output.output) + .map_err(|error| { + release_after_failure( + budget, + reservation.id, + McpError::InvalidInvocation { + reason: error.to_string(), + }, + ) + })? + .len() as u64; + let output_bytes = output + .output_bytes + .unwrap_or(serialized_len) + .max(serialized_len); + if output_bytes > self.config.max_output_bytes { + return Err(release_after_failure( + budget, + reservation.id, + McpError::OutputLimitExceeded { + limit: self.config.max_output_bytes, + actual: output_bytes, + }, + )); + } + + let mut usage = output.usage; + usage.output_bytes = usage.output_bytes.max(output_bytes); + if transport == "stdio" { + usage.process_count = usage.process_count.max(1); + } + let receipt = budget.reconcile(reservation.id, usage.clone())?; + Ok(McpExecutionResult { + result: CapabilityHostResult { + output: output.output, + reservation_id: reservation.id, + usage, + output_bytes, + }, + receipt, + }) + } + + fn prepare_client_request( + &self, + request: &McpExecutionRequest<'_>, + ) -> Result { + let descriptor = request + .capabilities + .iter() + .find(|descriptor| &descriptor.id == request.capability_id) + .cloned() + .ok_or_else(|| McpError::CapabilityNotDeclared { + capability: request.capability_id.clone(), + })?; + + if descriptor.runtime != RuntimeKind::Mcp { + return Err(McpError::ExtensionRuntimeMismatch { + extension: request.extension.clone(), + actual: descriptor.runtime, + }); + } + if descriptor.provider != *request.extension { + return Err(McpError::DescriptorMismatch { + reason: format!( + "descriptor {} provider {} does not match package {}", + descriptor.id, descriptor.provider, *request.extension + ), + }); + } + + let (transport, command, args, url) = match request.runtime { + ExtensionRuntime::Mcp { + transport, + command, + args, + url, + } => (transport, command, args, url), + other => { + return Err(McpError::ExtensionRuntimeMismatch { + extension: request.extension.clone(), + actual: other.kind(), + }); + } + }; + + if transport == "stdio" { + return Err(McpError::ExternalStdioTransportUnsupported); + } + if !matches!(transport.as_str(), "http" | "sse") { + return Err(McpError::UnsupportedTransport { + transport: transport.clone(), + }); + } + if matches!(transport.as_str(), "http" | "sse") && url.is_none() { + return Err(McpError::InvalidInvocation { + reason: format!("{transport} MCP transport requires a manifest url"), + }); + } + + let auth_context = mcp_auth_context(&descriptor.provider, &descriptor.runtime_credentials); + + Ok(PreparedMcpClientRequest { + request: McpClientRequest { + provider: request.extension.clone(), + capability_id: request.capability_id.clone(), + scope: request.scope.clone(), + transport: transport.clone(), + command: command.clone(), + args: args.clone(), + url: url.clone(), + input: request.invocation.input.clone(), + max_output_bytes: self.config.max_output_bytes, + }, + auth_context, + }) + } +} + +fn mcp_error_from_client_error(error: McpClientError, auth_context: McpAuthContext) -> McpError { + match error { + McpClientError::Client { reason } => McpError::Client { reason }, + McpClientError::InvalidToolCatalog { reason } => McpError::InvalidToolCatalog { reason }, + McpClientError::AuthRequired | McpClientError::AuthChallenge { .. } => { + McpError::AuthRequired { + required_secrets: auth_context.required_secrets, + credential_requirements: auth_context.credential_requirements, + } + } + } +} + +fn mcp_auth_context( + requester_extension: &ExtensionId, + credentials: &[RuntimeCredentialRequirement], +) -> McpAuthContext { + let mut required_secrets = Vec::new(); + let mut credential_requirements = Vec::new(); + for credential in credentials.iter().filter(|credential| credential.required) { + match &credential.source { + RuntimeCredentialRequirementSource::SecretHandle => { + required_secrets.push(credential.handle.clone()); + } + RuntimeCredentialRequirementSource::ProductAuthAccount { .. } => { + if let Some(requirement) = + credential.product_auth_requirement_for(requester_extension.clone()) + { + credential_requirements.push(requirement); + } + } + } + } + McpAuthContext { + required_secrets, + credential_requirements, + } +} + +#[async_trait] +impl McpExecutor for McpRuntime +where + C: McpClient, +{ + async fn execute_extension_json( + &self, + budget: &dyn RuntimeResourceBudget, + request: McpExecutionRequest<'_>, + ) -> Result { + McpRuntime::execute_extension_json(self, budget, request).await + } +} + +fn reserve_or_use_existing( + budget: &Budget, + scope: ResourceScope, + estimate: ResourceEstimate, + reservation: Option, +) -> Result +where + Budget: RuntimeResourceBudget + ?Sized, +{ + if let Some(reservation) = reservation { + if reservation.scope != scope || reservation.estimate != estimate { + return Err(McpError::Resource( + RuntimeResourceError::reservation_mismatch(reservation.id), + )); + } + return Ok(reservation); + } + budget.reserve(scope, estimate).map_err(McpError::from) +} + +fn release_after_failure( + budget: &Budget, + reservation_id: ResourceReservationId, + original: McpError, +) -> McpError +where + Budget: RuntimeResourceBudget + ?Sized, +{ + let _ = budget.release(reservation_id); + original +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_auth_context_preserves_product_auth_oauth_setup() { + let scopes = vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]; + let credential = RuntimeCredentialRequirement { + handle: SecretHandle::new("google-drive-access").unwrap(), + source: RuntimeCredentialRequirementSource::ProductAuthAccount { + provider: ironclaw_host_api::ids::VendorId::new("google").unwrap(), + setup: ironclaw_host_api::capability::RuntimeCredentialAccountSetup::OAuth { + scopes: scopes.clone(), + }, + }, + provider_scopes: scopes.clone(), + audience: ironclaw_host_api::action::NetworkTargetPattern { + scheme: None, + host_pattern: "*".to_string(), + port: None, + }, + target: ironclaw_host_api::http::RuntimeCredentialTarget::Header { + name: "authorization".to_string(), + prefix: Some("Bearer ".to_string()), + }, + required: true, + }; + + let context = mcp_auth_context(&ExtensionId::new("google-drive").unwrap(), &[credential]); + + assert!(context.required_secrets.is_empty()); + assert_eq!( + context.credential_requirements, + vec![RuntimeCredentialAuthRequirement { + provider: ironclaw_host_api::ids::VendorId::new("google").unwrap(), + setup: ironclaw_host_api::capability::RuntimeCredentialAccountSetup::OAuth { + scopes + }, + requester_extension: ExtensionId::new("google-drive").unwrap(), + provider_scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()], + }] + ); + } +} diff --git a/crates/ironclaw_product/CLAUDE.md b/crates/ironclaw_product/CLAUDE.md index b7cddae5eed..b9c16fa68e0 100644 --- a/crates/ironclaw_product/CLAUDE.md +++ b/crates/ironclaw_product/CLAUDE.md @@ -130,6 +130,17 @@ Must NOT depend on: `ironclaw_extensions`, `ironclaw_host_runtime`, `ironclaw_mcp`, `ironclaw_wasm`, `ironclaw_sandbox`, `ironclaw_network`. +All six are now *enforced* — the `ironclaw_product` `BoundaryRule` in +`crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs` is the +arbiter, and this list is a copy of it. Until WS5, `ironclaw_extensions` was on +this list and **not** on the enforced one, and the crate held the dependency: +`adapter_registry` was its only consumer. Moving that module to its chartered +owners (schema → `ironclaw_extension_contracts::product_adapter_section`, +manifest contract + resolved projection → +`ironclaw_extensions::host_api::product_adapter`) dropped the manifest entry and +closed the contradiction PROPOSAL §6.9.1 recorded. A product-tier crate that +needs manifest vocabulary imports the contracts crate, never the registry. + Agent-loop note: product-facing turns enter through workflow services and canonical turn submission. Do not shortcut directly to `AgentLoopDriver`, `PlannedDriver`, host runtime services, or loop host factories from adapters or diff --git a/crates/ironclaw_product/Cargo.toml b/crates/ironclaw_product/Cargo.toml index 312c1360866..e6c16d0826a 100644 --- a/crates/ironclaw_product/Cargo.toml +++ b/crates/ironclaw_product/Cargo.toml @@ -38,7 +38,6 @@ ironclaw_auth = { path = "../ironclaw_auth", version = "0.1.0" } ironclaw_event_projections = { path = "../ironclaw_event_projections", version = "0.1.0" } ironclaw_event_streams = { path = "../ironclaw_event_streams", version = "0.1.0" } ironclaw_events = { path = "../ironclaw_events", version = "0.1.0" } -ironclaw_extensions = { path = "../ironclaw_extensions", version = "0.1.0" } ironclaw_first_party_extension_ports = { path = "../ironclaw_first_party_extension_ports", version = "0.1.0" } ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } ironclaw_product_contracts = { path = "../ironclaw_product_contracts", version = "0.1.0" } @@ -46,8 +45,15 @@ ironclaw_extension_contracts = { path = "../ironclaw_extension_contracts", versi ironclaw_loop_host = { path = "../ironclaw_loop_host", version = "0.1.0" } ironclaw_conversations = { path = "../ironclaw_conversations", version = "0.1.0" } ironclaw_outbound = { path = "../ironclaw_outbound", version = "0.1.0" } +# The blocked-auth resume fan-out (WS6 eviction from the composition root) +# reads durable process-gate records to find the caller's other parked runs. +ironclaw_processes = { path = "../ironclaw_processes", version = "0.1.0" } ironclaw_projects = { path = "../ironclaw_projects", version = "0.1.0" } +# The admin user-directory adapter (WS6 eviction from the composition root) +# reads the identity directory and provisions per-user secrets through it. +ironclaw_reborn_identity = { path = "../ironclaw_reborn_identity", version = "0.1.0" } ironclaw_reborn_traces = { path = "../ironclaw_reborn_traces", version = "0.1.0" } +ironclaw_secrets = { path = "../ironclaw_secrets", version = "0.1.0" } ironclaw_safety = { path = "../ironclaw_safety", version = "0.2.2" } ironclaw_threads = { path = "../ironclaw_threads", version = "0.1.0" } ironclaw_triggers = { path = "../ironclaw_triggers", version = "0.1.0" } @@ -65,7 +71,6 @@ url = "2" uuid = { version = "1", features = ["v4", "v5", "serde"] } [dev-dependencies] -ironclaw_processes = { path = "../ironclaw_processes" } # Enables the shared in-memory turn-state store double for tests only. ironclaw_turns = { path = "../ironclaw_turns", features = ["test-support"] } # Enables the in-memory-backed outbound-state store constructor for tests only. diff --git a/crates/ironclaw_product/src/adapter_registry.rs b/crates/ironclaw_product/src/adapter_registry.rs deleted file mode 100644 index 5d00c2a3339..00000000000 --- a/crates/ironclaw_product/src/adapter_registry.rs +++ /dev/null @@ -1,954 +0,0 @@ -//! ProductAdapter host-api section contract and projection types. -//! -//! Validates and projects `ironclaw.product_adapter/v1` manifest sections. -//! The old registry runtime projection (`ProductAdapterRuntimeEntry` and its -//! store scan) was never the production path and was deleted by the -//! extension-runtime P2 dispatch cutover; the active snapshot is the -//! dispatch-time source of truth. - -#![forbid(unsafe_code)] - -use std::collections::BTreeSet; -use std::sync::Arc; - -use ironclaw_extension_contracts::egress::{DeclaredEgressTarget, EgressCredentialHandle}; -use ironclaw_extension_contracts::surface::CapabilitySurfaceKind; -use ironclaw_extensions::{ - ExtensionInstallationError, ExtensionManifestRecord, ExtensionManifestV2, - HostApiContractRegistry, HostApiId, HostApiManifestContext, HostApiManifestContract, - HostApiManifestProjection, HostApiMultiplicity, HostApiRefV2, HostApiSectionError, - ManifestSectionPath, ManifestSource, ManifestV2Error, -}; -use ironclaw_host_api::product_adapter::{ - AuthRequirement, ProductAdapterCapabilities, ProductAdapterId, ProductCapabilityFlag, - ProductSurfaceKind, -}; -use ironclaw_host_api::{ - host_port::HostPortCatalog, - ids::ExtensionId, - ingress::{IngressAuthPolicy, IngressRouteDescriptor, IngressRouteId}, -}; -use serde::Deserialize; -use thiserror::Error; - -pub use ironclaw_extensions::ManifestHash; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -pub const PRODUCT_ADAPTER_HOST_API_ID: &str = "ironclaw.product_adapter/v1"; -pub const PRODUCT_ADAPTER_SECTION_PREFIX: &str = "product_adapter"; - -pub fn parse_product_adapter_manifest_record( - raw_toml: impl Into, - source: ManifestSource, - host_port_catalog: &HostPortCatalog, - manifest_hash: Option, -) -> Result { - let mut contracts = HostApiContractRegistry::new(); - register_product_adapter_host_api_contract(&mut contracts)?; - let record = ExtensionManifestRecord::from_toml_with_root_binding( - raw_toml, - source, - host_port_catalog, - manifest_hash, - &contracts, - // Contract-projection helper: no package root is materialized here. - ironclaw_extensions::PackageRootBinding::FabricateOnLoad, - ) - .map_err(|error| match error { - ExtensionInstallationError::Manifest(error) => RegistryError::Manifest(error), - other => RegistryError::Installation(other), - })?; - product_adapter_sections(&record)?; - Ok(record) -} - -pub fn product_adapter_sections( - record: &ExtensionManifestRecord, -) -> Result, RegistryError> { - project_product_adapter_sections(record.raw_toml(), record.manifest()) -} - -/// A host-ingress route declared by a ProductAdapter manifest section, paired -/// with the credential handles that verify it. -/// -/// The route itself is the host-owned [`IngressRouteDescriptor`] vocabulary -/// (`ironclaw_host_api` owns route/policy validation, including the fail-closed -/// floor that a `PublicWebhook` listener must require `WebhookSignature`). That -/// descriptor deliberately carries **no** credential binding — host_api is -/// route/policy vocabulary only. The manifest layer is therefore where "which -/// credential handle verifies this route" is declared, and this crate makes it -/// credential-coherent against the section's `required_credentials` -/// (see [`ProductAdapterHostApiSection::validate`]). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HostIngressRoute { - descriptor: IngressRouteDescriptor, - credential_handles: Vec, -} - -impl HostIngressRoute { - /// The host-owned, already-validated ingress route/policy descriptor. - pub fn descriptor(&self) -> &IngressRouteDescriptor { - &self.descriptor - } - - /// Credential handles that verify this route. Every handle is guaranteed to - /// be declared in the owning section's `required_credentials`; an - /// auth-required route names at least one, and a public (no-auth) route - /// names none. - /// - /// The handle type is [`EgressCredentialHandle`] — the single credential- - /// handle newtype `ironclaw_product` owns. It is reused here rather - /// than mirrored into an ingress-specific type (per the type-placement - /// rule); its `Display` renders only the handle string, so no "egress" - /// wording leaks into ingress error messages. - pub fn credential_handles(&self) -> &[EgressCredentialHandle] { - &self.credential_handles - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProductAdapterHostApiSection { - adapter_id: ProductAdapterId, - section: ManifestSectionPath, - surface_kind: ProductSurfaceKind, - capabilities: ProductAdapterCapabilities, - auth_requirement: AuthRequirement, - declared_egress: Vec, - required_credentials: Vec, - host_ingress: Vec, -} - -impl ProductAdapterHostApiSection { - fn from_value( - extension_id: &ExtensionId, - section: ManifestSectionPath, - value: toml::Value, - ) -> Result { - reject_inline_secret_material_value(section.as_str(), &value)?; - let raw: RawProductAdapterSection = - value.try_into().map_err(|error: toml::de::Error| { - RegistryError::ManifestSectionParse { - section: section.clone(), - reason: error.to_string(), - } - })?; - // Derive adapter_id from the extension id and section subsection name - // so that multiple product-adapter sections within the same extension - // are distinguishable downstream. - let subsection = section - .as_str() - .strip_prefix(PRODUCT_ADAPTER_SECTION_PREFIX) - .and_then(|rest| rest.strip_prefix('.')) - .unwrap_or("default"); - let adapter_id_str = format!("{}/{}", extension_id.as_str(), subsection); - let adapter_id = ProductAdapterId::new(&adapter_id_str).map_err(|error| { - RegistryError::InvalidValue { - field: "adapter_id", - reason: error.to_string(), - } - })?; - let auth_requirement = raw.auth.into_auth_requirement()?; - let required_credentials = raw - .required_credentials - .into_iter() - .map(|c| c.handle) - .collect(); - let host_ingress = raw - .host_ingress - .into_iter() - .map(|route| HostIngressRoute { - descriptor: route.descriptor, - credential_handles: route.credential_handles, - }) - .collect(); - let projected = Self { - adapter_id, - section, - surface_kind: raw.surface_kind, - capabilities: ProductAdapterCapabilities::new(raw.capabilities.flags), - auth_requirement, - declared_egress: raw.egress, - required_credentials, - host_ingress, - }; - projected.validate()?; - Ok(projected) - } - - pub fn adapter_id(&self) -> &ProductAdapterId { - &self.adapter_id - } - pub fn section(&self) -> &ManifestSectionPath { - &self.section - } - pub fn surface_kind(&self) -> ProductSurfaceKind { - self.surface_kind - } - pub fn capabilities(&self) -> &ProductAdapterCapabilities { - &self.capabilities - } - pub fn auth_requirement(&self) -> &AuthRequirement { - &self.auth_requirement - } - pub fn declared_egress(&self) -> &[DeclaredEgressTarget] { - &self.declared_egress - } - pub fn required_credentials(&self) -> &[EgressCredentialHandle] { - &self.required_credentials - } - - /// Host-ingress routes this ProductAdapter section declares. Each carries a - /// host-owned [`IngressRouteDescriptor`] and its verifying credential - /// handles; the serve layer projects these into mounted routes. Empty for - /// sections that declare no ingress (the common case today). - pub fn host_ingress(&self) -> &[HostIngressRoute] { - &self.host_ingress - } - - fn validate(&self) -> Result<(), RegistryError> { - validate_auth_requirement(&self.auth_requirement)?; - let mut required = BTreeSet::new(); - for handle in &self.required_credentials { - if !required.insert(handle.clone()) { - return Err(RegistryError::DuplicateCredentialHandle { - handle: handle.clone(), - }); - } - } - let mut pairs = BTreeSet::new(); - for target in &self.declared_egress { - if let Some(handle) = target.credential_handle.as_ref() - && !required.contains(handle) - { - return Err(RegistryError::UndeclaredEgressCredentialHandle { - handle: handle.clone(), - }); - } - if !pairs.insert((target.host.clone(), target.credential_handle.clone())) { - return Err(RegistryError::DuplicateEgressTarget); - } - } - // Host-ingress credential coherence, fail closed. A route's declared - // verifying credentials must line up with whether it is actually - // authenticated, and every named handle must be declared in - // `required_credentials` (mirroring the egress rule above, so ingress - // handles flow into the same declared set installation bindings are - // validated against). Route ids stay distinct within a section so a - // mounted route can be addressed unambiguously. - let mut route_ids: BTreeSet<&IngressRouteId> = BTreeSet::new(); - for route in &self.host_ingress { - let route_id = route.descriptor.route_id(); - if !route_ids.insert(route_id) { - return Err(RegistryError::DuplicateIngressRoute { - route_id: route_id.clone(), - }); - } - match route.descriptor.policy().auth() { - // An auth-required route with no verifying credential is a route - // nothing could authenticate — reject it. - IngressAuthPolicy::Required { .. } => { - if route.credential_handles.is_empty() { - return Err(RegistryError::IngressRouteMissingCredential { - route_id: route_id.clone(), - }); - } - } - // A public (no-auth) route is verified by nothing, so declaring a - // credential handle on it is incoherent and misleading — a reader - // would assume the route is authenticated by that credential. - IngressAuthPolicy::Public { .. } => { - if !route.credential_handles.is_empty() { - return Err(RegistryError::PublicIngressRouteHasCredential { - route_id: route_id.clone(), - }); - } - } - } - for handle in &route.credential_handles { - if !required.contains(handle) { - return Err(RegistryError::UndeclaredIngressCredentialHandle { - handle: handle.clone(), - }); - } - } - } - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// ProductAdapter host-api contract validator -// --------------------------------------------------------------------------- - -#[derive(Debug)] -pub struct ProductAdapterHostApiContract { - id: HostApiId, -} - -impl ProductAdapterHostApiContract { - pub fn new() -> Result { - Ok(Self { - id: HostApiId::new(PRODUCT_ADAPTER_HOST_API_ID)?, - }) - } -} - -pub fn register_product_adapter_host_api_contract( - registry: &mut HostApiContractRegistry, -) -> Result<(), RegistryError> { - registry.register(Arc::new(ProductAdapterHostApiContract::new()?))?; - Ok(()) -} - -impl HostApiManifestContract for ProductAdapterHostApiContract { - fn id(&self) -> &HostApiId { - &self.id - } - - fn multiplicity(&self) -> HostApiMultiplicity { - HostApiMultiplicity::Multiple - } - - fn accepts_section_path(&self, section: &ManifestSectionPath) -> bool { - section.as_str() == PRODUCT_ADAPTER_SECTION_PREFIX - || section - .as_str() - .strip_prefix(PRODUCT_ADAPTER_SECTION_PREFIX) - .is_some_and(|rest| rest.starts_with('.')) - } - - fn validate_section( - &self, - host_api: &HostApiRefV2, - section: &toml::Value, - ) -> Result<(), HostApiSectionError> { - // The contract hook runs while the generic manifest parser is still - // validating the host-api section envelope, before it exposes the real - // extension id to contract implementations. `from_value` needs an id - // only to derive the adapter_id that this shape-only path discards; - // cross-field checks involving the real extension id belong in - // `project_product_adapter_sections` below. - let placeholder = - ExtensionId::new("x").map_err(|e| HostApiSectionError::from(e.to_string()))?; - ProductAdapterHostApiSection::from_value( - &placeholder, - host_api.section.clone(), - section.clone(), - ) - .map(|_| ()) - .map_err(|e| HostApiSectionError::from(e.to_string())) - } - - fn validate_section_with_context( - &self, - context: &HostApiManifestContext<'_>, - host_api: &HostApiRefV2, - section: &toml::Value, - ) -> Result<(), HostApiSectionError> { - ProductAdapterHostApiSection::from_value( - context.extension_id, - host_api.section.clone(), - section.clone(), - ) - .map(|_| ()) - .map_err(|e| HostApiSectionError::from(e.to_string())) - } - - fn project_section_with_context( - &self, - context: &HostApiManifestContext<'_>, - host_api: &HostApiRefV2, - section: &toml::Value, - ) -> Result { - let parsed = ProductAdapterHostApiSection::from_value( - context.extension_id, - host_api.section.clone(), - section.clone(), - ) - .map_err(|e| HostApiSectionError::from(e.to_string()))?; - // External-channel adapter sections are the extension's channel - // surface. The other product surface kinds (`web`, `cli`, - // `synchronous_api`) describe host-native surfaces and project no - // extension surface. - let surfaces = match parsed.surface_kind() { - ProductSurfaceKind::ExternalChannel => vec![CapabilitySurfaceKind::Channel], - ProductSurfaceKind::Web - | ProductSurfaceKind::Cli - | ProductSurfaceKind::SynchronousApi => Vec::new(), - }; - Ok(HostApiManifestProjection { - capabilities: Vec::new(), - surfaces, - }) - } -} - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum RegistryError { - #[error(transparent)] - Installation(#[from] ExtensionInstallationError), - #[error(transparent)] - Manifest(#[from] ManifestV2Error), - #[error("invalid {field}: {reason}")] - InvalidValue { field: &'static str, reason: String }, - #[error("product adapter manifest section {section} parse failed: {reason}")] - ManifestSectionParse { - section: ManifestSectionPath, - reason: String, - }, - #[error("inline secret material is not allowed in manifest field {field}")] - InlineSecretMaterial { field: String }, - #[error("duplicate credential handle {handle}")] - DuplicateCredentialHandle { handle: EgressCredentialHandle }, - #[error("duplicate egress target")] - DuplicateEgressTarget, - #[error("egress references undeclared credential handle {handle}")] - UndeclaredEgressCredentialHandle { handle: EgressCredentialHandle }, - #[error("host-ingress route references undeclared credential handle {handle}")] - UndeclaredIngressCredentialHandle { handle: EgressCredentialHandle }, - #[error("auth-required host-ingress route {route_id} declares no verifying credential handle")] - IngressRouteMissingCredential { route_id: IngressRouteId }, - #[error( - "public host-ingress route {route_id} declares a verifying credential handle but is not authenticated" - )] - PublicIngressRouteHasCredential { route_id: IngressRouteId }, - #[error("duplicate host-ingress route {route_id}")] - DuplicateIngressRoute { route_id: IngressRouteId }, - #[error("installation references unknown extension manifest {extension_id}")] - UnknownManifest { extension_id: ExtensionId }, - #[error("installation binds undeclared credential handle {handle}")] - UndeclaredCredentialHandle { handle: EgressCredentialHandle }, - #[error( - "installation extension {extension_id} does not match manifest extension {manifest_extension_id}" - )] - ManifestExtensionMismatch { - extension_id: ExtensionId, - manifest_extension_id: ExtensionId, - }, - #[error( - "installation manifest hash does not match registered manifest hash for {extension_id}" - )] - ManifestHashMismatch { extension_id: ExtensionId }, -} - -// --------------------------------------------------------------------------- -// Internal validation helpers -// --------------------------------------------------------------------------- - -fn validate_auth_requirement(requirement: &AuthRequirement) -> Result<(), RegistryError> { - match requirement { - AuthRequirement::RequestSignature { - header_name, - timestamp_header_name, - } => { - validate_http_token("auth.header_name", header_name)?; - if let Some(t) = timestamp_header_name.as_deref() { - validate_http_token("auth.timestamp_header_name", t)?; - } - } - AuthRequirement::SharedSecretHeader { header_name } => { - validate_http_token("auth.header_name", header_name)?; - } - AuthRequirement::SessionCookie { name } => { - validate_http_token("auth.name", name)?; - } - AuthRequirement::BearerToken => {} - } - Ok(()) -} - -fn validate_http_token(field: &'static str, value: &str) -> Result<(), RegistryError> { - if value.is_empty() { - return Err(RegistryError::InvalidValue { - field, - reason: "must not be empty".to_string(), - }); - } - for c in value.chars() { - if !is_http_tchar(c) { - return Err(RegistryError::InvalidValue { - field, - reason: format!( - "must be an RFC 7230 token (no CTL, whitespace, or separators); got {value:?}" - ), - }); - } - } - Ok(()) -} - -fn is_http_tchar(c: char) -> bool { - matches!( - c, - '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~' - ) || c.is_ascii_alphanumeric() -} - -fn reject_inline_secret_material_value( - path: &str, - value: &toml::Value, -) -> Result<(), RegistryError> { - match value { - toml::Value::Table(table) => { - for (key, value) in table { - let child_path = format!("{path}.{key}"); - if is_secret_key_name(key) { - return Err(RegistryError::InlineSecretMaterial { field: child_path }); - } - reject_inline_secret_material_value(&child_path, value)?; - } - } - toml::Value::Array(values) => { - for (index, value) in values.iter().enumerate() { - reject_inline_secret_material_value(&format!("{path}[{index}]"), value)?; - } - } - toml::Value::String(value) if looks_like_inline_secret(value) => { - return Err(RegistryError::InlineSecretMaterial { - field: path.to_string(), - }); - } - _ => {} - } - Ok(()) -} - -fn is_secret_key_name(key: &str) -> bool { - let normalised: String = key - .chars() - .map(|c| { - if c == '-' { - '_' - } else { - c.to_ascii_lowercase() - } - }) - .collect(); - matches!( - normalised.as_str(), - "secret" - | "secrets" - | "secret_value" - | "client_secret" - | "webhook_secret" - | "token" - | "raw_token" - | "access_token" - | "refresh_token" - | "bearer_token" - | "oauth_token" - | "auth_token" - | "id_token" - | "api_key" - | "apikey" - | "api_secret" - | "private_key" - | "password" - | "passphrase" - ) -} - -fn looks_like_inline_secret(value: &str) -> bool { - let lower = value.to_ascii_lowercase(); - if lower.starts_with("sha256:") { - return false; - } - const PREFIXES: &[&str] = &[ - "sk-", // OpenAI / Anthropic style API keys. - "xoxb-", // Slack bot token. - "xoxa-", // Slack app token. - "xoxp-", // Slack user token. - "xoxs-", // Slack service token. - "xoxe-", // Slack configuration token. - "ghp_", // GitHub personal access token. - "gho_", // GitHub OAuth token. - "ghu_", // GitHub user-to-server token. - "ghs_", // GitHub server-to-server token. - "ghr_", // GitHub refresh token. - ]; - PREFIXES.iter().any(|p| lower.starts_with(p)) - || looks_like_aws_access_key(value) - || lower.contains("begin private key") - || lower.contains("begin rsa private key") - || (value.len() >= 30 && value.starts_with("eyJ") && value.contains('.')) - || has_uri_userinfo(value) - || looks_like_telegram_token(value) -} - -fn looks_like_aws_access_key(value: &str) -> bool { - if value.len() != 20 { - return false; - } - let Some(prefix) = value.get(..4) else { - return false; - }; - (prefix.eq_ignore_ascii_case("AKIA") || prefix.eq_ignore_ascii_case("ASIA")) - && value[4..] - .chars() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) -} - -fn has_uri_userinfo(value: &str) -> bool { - let Some((_, rest)) = value.split_once("://") else { - return false; - }; - rest.split('/').next().unwrap_or_default().contains('@') -} - -fn looks_like_telegram_token(value: &str) -> bool { - let Some((prefix, suffix)) = value.split_once(':') else { - return false; - }; - prefix.len() >= 6 - && prefix.chars().all(|c| c.is_ascii_digit()) - && suffix.len() >= 10 - && suffix - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') -} - -fn project_product_adapter_sections( - raw_toml: &str, - manifest: &ExtensionManifestV2, -) -> Result, RegistryError> { - // Safety: PRODUCT_ADAPTER_SECTION_PREFIX is a non-empty, control-char-free - // ASCII identifier defined as a module constant. - let root_section = ManifestSectionPath::new(PRODUCT_ADAPTER_SECTION_PREFIX) - .map_err(RegistryError::Manifest)?; - // `ironclaw_extensions` validates host-api sections from its internal - // TOML section table but does not expose that table as a public projection - // API. Re-parse here so this crate can build typed ProductAdapter entries - // without reaching through the manifest parser's private representation. - // If profiling shows this is material, add a targeted section projection - // API in `ironclaw_extensions` instead of caching private parser state here. - let value: toml::Value = - toml::from_str(raw_toml).map_err(|error| RegistryError::ManifestSectionParse { - section: root_section.clone(), - reason: error.to_string(), - })?; - let mut sections = Vec::new(); - for host_api in &manifest.host_apis { - if host_api.id.as_str() != PRODUCT_ADAPTER_HOST_API_ID { - continue; - } - let section_value = section_value(&value, &host_api.section)?; - sections.push(ProductAdapterHostApiSection::from_value( - &manifest.id, - host_api.section.clone(), - section_value.clone(), - )?); - } - Ok(sections) -} - -fn section_value<'a>( - root: &'a toml::Value, - path: &ManifestSectionPath, -) -> Result<&'a toml::Value, RegistryError> { - let mut current = root; - for segment in path.as_str().split('.') { - current = current - .as_table() - .and_then(|table| table.get(segment)) - .ok_or_else(|| RegistryError::ManifestSectionParse { - section: path.clone(), - reason: "section path does not exist".to_string(), - })?; - } - Ok(current) -} - -// --------------------------------------------------------------------------- -// Raw deserialization shapes for ProductAdapter section -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawProductAdapterSection { - surface_kind: ProductSurfaceKind, - auth: RawProductAdapterAuth, - capabilities: RawProductAdapterCapabilities, - #[serde(default)] - required_credentials: Vec, - #[serde(default)] - egress: Vec, - #[serde(default)] - host_ingress: Vec, -} - -/// Manifest shape for a declared host-ingress route: the full host-owned -/// [`IngressRouteDescriptor`] (validated by `ironclaw_host_api`'s own -/// `Deserialize` — `deny_unknown_fields`, dotted route id, absolute path, and -/// all policy cross-field invariants) plus the credential handles that verify -/// it. Credential coherence against `required_credentials` is enforced in -/// [`ProductAdapterHostApiSection::validate`]. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawHostIngressRoute { - descriptor: IngressRouteDescriptor, - #[serde(default)] - credential_handles: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawProductAdapterCapabilities { - flags: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawProductAdapterCredential { - handle: EgressCredentialHandle, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -enum RawProductAdapterAuth { - RequestSignature { - header_name: String, - #[serde(default)] - timestamp_header_name: Option, - }, - SharedSecretHeader { - header_name: String, - }, - SessionCookie { - name: String, - }, - BearerToken, -} - -impl RawProductAdapterAuth { - fn into_auth_requirement(self) -> Result { - let requirement = match self { - Self::RequestSignature { - header_name, - timestamp_header_name, - } => AuthRequirement::RequestSignature { - header_name, - timestamp_header_name, - }, - Self::SharedSecretHeader { header_name } => { - AuthRequirement::SharedSecretHeader { header_name } - } - Self::SessionCookie { name } => AuthRequirement::SessionCookie { name }, - Self::BearerToken => AuthRequirement::BearerToken, - }; - validate_auth_requirement(&requirement)?; - Ok(requirement) - } -} - -#[cfg(test)] -mod tests { - //! Unit coverage for host-ingress credential coherence — the novel logic - //! this crate adds on top of host_api's already-validated ingress - //! descriptor. Descriptors are built in Rust (not TOML text) so these - //! cases are robust to serde renames; the wire path is covered end-to-end - //! in `tests/manifest_ingestion.rs`. - use super::*; - use ironclaw_host_api::{ - action::NetworkMethod, - ingress::{ - AllowedEffectPath, AuditTraceClass, BodyLimitPolicy, CorsPolicy, IngressAuthScheme, - IngressJustification, IngressPolicy, IngressPolicyParts, IngressScopeSource, - ListenerClass, RateLimitPolicy, RateLimitScope, StreamingMode, WebSocketOriginPolicy, - }, - }; - use serde::Serialize; - use std::num::{NonZeroU32, NonZeroU64}; - - /// A fail-closed public-webhook descriptor mirroring the values Slack's - /// `slack_events_policy()` uses, parameterized by route id. - fn webhook_descriptor(route_id: &str) -> IngressRouteDescriptor { - let policy = IngressPolicy::new(IngressPolicyParts { - listener_class: ListenerClass::PublicWebhook, - auth: IngressAuthPolicy::Required { - schemes: vec![IngressAuthScheme::WebhookSignature], - }, - scope_source: IngressScopeSource::HostResolved, - body_limit: BodyLimitPolicy::Limited { - max_bytes: NonZeroU64::new(262_144).expect("nonzero"), - }, - rate_limit: RateLimitPolicy::Limited { - scope: RateLimitScope::Global, - max_requests: NonZeroU32::new(600).expect("nonzero"), - window_seconds: NonZeroU32::new(60).expect("nonzero"), - }, - cors: CorsPolicy::NotApplicable, - websocket_origin: WebSocketOriginPolicy::NotApplicable, - streaming: StreamingMode::None, - audit: AuditTraceClass::PublicCallback, - effect_path: AllowedEffectPath::ProductSurface, - }) - .expect("policy validates"); - IngressRouteDescriptor::new( - route_id, - NetworkMethod::Post, - "/webhooks/telegram/updates", - policy, - ) - .expect("descriptor validates") - } - - /// A valid public (no-auth) route, mirroring the SSO login mount's policy - /// combination (LocalGateway + Public + PublicRoute + NoEffect). - fn public_descriptor(route_id: &str) -> IngressRouteDescriptor { - let policy = IngressPolicy::new(IngressPolicyParts { - listener_class: ListenerClass::LocalGateway, - auth: IngressAuthPolicy::Public { - justification: IngressJustification::new("ingress", "public test route") - .expect("justification"), - }, - scope_source: IngressScopeSource::PublicRoute, - body_limit: BodyLimitPolicy::Limited { - max_bytes: NonZeroU64::new(4096).expect("nonzero"), - }, - rate_limit: RateLimitPolicy::Limited { - scope: RateLimitScope::PerIp, - max_requests: NonZeroU32::new(60).expect("nonzero"), - window_seconds: NonZeroU32::new(60).expect("nonzero"), - }, - cors: CorsPolicy::SameOriginOnly, - websocket_origin: WebSocketOriginPolicy::NotApplicable, - streaming: StreamingMode::None, - audit: AuditTraceClass::PublicCallback, - effect_path: AllowedEffectPath::NoEffect, - }) - .expect("public policy validates"); - IngressRouteDescriptor::new(route_id, NetworkMethod::Post, "/public/callback", policy) - .expect("descriptor validates") - } - - #[derive(Serialize)] - struct RouteFixture { - descriptor: IngressRouteDescriptor, - credential_handles: Vec, - } - - /// Build a ProductAdapter section `toml::Value` with a valid base and the - /// given host-ingress routes, then run it through the real projection. - fn project(routes: Vec) -> Result { - let mut value: toml::Value = toml::from_str( - r#" -surface_kind = "external_channel" -[auth] -kind = "shared_secret_header" -header_name = "X-Telegram-Bot-Api-Secret-Token" -[capabilities] -flags = ["inbound_messages"] -[[required_credentials]] -handle = "telegram_bot_token" -"#, - ) - .expect("base section parses"); - let host_ingress = toml::Value::try_from(routes).expect("routes serialize"); - value - .as_table_mut() - .expect("section is a table") - .insert("host_ingress".to_string(), host_ingress); - - let extension_id = ExtensionId::new("telegram-v2").expect("extension id"); - let section = ManifestSectionPath::new("product_adapter.inbound").expect("section path"); - ProductAdapterHostApiSection::from_value(&extension_id, section, value) - } - - fn route(route_id: &str, credential_handles: &[&str]) -> RouteFixture { - RouteFixture { - descriptor: webhook_descriptor(route_id), - credential_handles: credential_handles.iter().map(|h| h.to_string()).collect(), - } - } - - #[test] - fn host_ingress_route_projects_descriptor_and_handles() { - let section = project(vec![route("telegram.updates", &["telegram_bot_token"])]) - .expect("valid section projects"); - assert_eq!(section.host_ingress().len(), 1); - let projected = §ion.host_ingress()[0]; - assert_eq!( - projected.descriptor().route_id().as_str(), - "telegram.updates" - ); - assert_eq!( - projected.descriptor().route_pattern().as_str(), - "/webhooks/telegram/updates" - ); - assert_eq!(projected.credential_handles().len(), 1); - assert_eq!( - projected.credential_handles()[0].as_str(), - "telegram_bot_token" - ); - } - - #[test] - fn host_ingress_undeclared_credential_handle_rejected() { - let err = project(vec![route("telegram.updates", &["not_declared_token"])]) - .expect_err("undeclared handle must reject"); - assert!( - matches!(err, RegistryError::UndeclaredIngressCredentialHandle { .. }), - "got {err:?}" - ); - } - - #[test] - fn host_ingress_auth_required_route_needs_credential() { - // Fail closed: an auth-required route with no verifying credential - // handle must reject, not mount a route nothing can authenticate. - let err = project(vec![route("telegram.updates", &[])]) - .expect_err("auth-required route without a credential must reject"); - assert!( - matches!(err, RegistryError::IngressRouteMissingCredential { .. }), - "got {err:?}" - ); - } - - #[test] - fn host_ingress_duplicate_route_id_rejected() { - let err = project(vec![ - route("telegram.updates", &["telegram_bot_token"]), - route("telegram.updates", &["telegram_bot_token"]), - ]) - .expect_err("duplicate route id must reject"); - assert!( - matches!(err, RegistryError::DuplicateIngressRoute { .. }), - "got {err:?}" - ); - } - - #[test] - fn host_ingress_public_route_must_not_declare_credentials() { - // Fail closed on the dual of the auth-required rule: a public (no-auth) - // route is verified by nothing, so declaring a credential handle on it - // is incoherent and would mislead a reader into assuming it is - // authenticated. - let err = project(vec![RouteFixture { - descriptor: public_descriptor("public.callback"), - credential_handles: vec!["telegram_bot_token".to_string()], - }]) - .expect_err("public route with a credential handle must reject"); - assert!( - matches!(err, RegistryError::PublicIngressRouteHasCredential { .. }), - "got {err:?}" - ); - } - - #[test] - fn host_ingress_public_route_without_credentials_projects() { - // The complement: a public route that declares no credentials is valid. - let section = project(vec![RouteFixture { - descriptor: public_descriptor("public.callback"), - credential_handles: vec![], - }]) - .expect("public route with no credentials projects"); - assert_eq!(section.host_ingress().len(), 1); - } -} diff --git a/crates/ironclaw_reborn_composition/src/admin_user_directory.rs b/crates/ironclaw_product/src/admin_user_directory.rs similarity index 78% rename from crates/ironclaw_reborn_composition/src/admin_user_directory.rs rename to crates/ironclaw_product/src/admin_user_directory.rs index 614df1b291e..6b308d0b756 100644 --- a/crates/ironclaw_reborn_composition/src/admin_user_directory.rs +++ b/crates/ironclaw_product/src/admin_user_directory.rs @@ -1,12 +1,13 @@ -//! Composition adapter implementing the product-workflow -//! [`AdminUserService`](ironclaw_product_contracts::admin_users::AdminUserService) port over -//! the Reborn identity user-directory + admin secret provisioner + a token -//! minter. +//! Product-tier implementation of the +//! [`AdminUserService`](ironclaw_product_contracts::admin_users::AdminUserService) +//! port over the Reborn identity user-directory, an admin secret provisioner, +//! and a token minter. //! -//! This is the one place identity, secrets, and token issuance meet — the -//! composition root is the only crate allowed to depend on all three, so the -//! product-workflow service and the webui_v2 routes stay free of those deps -//! (the crate boundary the architecture tests enforce). +//! Admin user management is product workflow — tenant scoping, role/status +//! transitions, one-time bearer issuance — so the adapter lives beside the rest +//! of the product surface rather than in the composition root (PROPOSAL +//! §6.10.1, WS6). Composition keeps only the *deployment* half: which secret +//! backend implements [`AdminSecretProvisioner`], and which minter is wired. use std::collections::BTreeMap; use std::sync::Arc; @@ -14,29 +15,74 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_host_api::ids::{SecretHandle, TenantId, UserId}; use ironclaw_product_contracts::admin_users::{ - AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, + AdminApiTokenMinter, AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, + AdminUserRole, AdminUserSecretMeta, AdminUserService, AdminUserStatus, }; use ironclaw_reborn_identity::{ RebornIdentityError, RebornUser, RebornUserDirectory, RebornUserProfileUpdate, RebornUserRole, RebornUserStatus, }; -use ironclaw_secrets::{SecretMetadata, SecretStoreError}; +use ironclaw_secrets::{SecretMaterial, SecretMetadata, SecretStoreError}; use secrecy::SecretString; -use crate::admin_secrets::AdminSecretProvisioner; -use crate::admin_token::AdminApiTokenMinter; +/// Admin provisioning of per-user secrets for an arbitrary target `(tenant, +/// user)`. +/// +/// The `ironclaw_secrets` store isolates tenant/user by the caller's +/// `MountView`, not the `ResourceScope` argument, so provisioning a secret for +/// a *target* user — the admin use case — needs a store mounted at that user's +/// subtree. Building that mount is deployment shape, so the port is declared +/// here (beside its one caller) and implemented in the composition root. +#[async_trait] +pub trait AdminSecretProvisioner: Send + Sync { + async fn list( + &self, + tenant: &TenantId, + user: &UserId, + ) -> Result, SecretStoreError>; + + async fn put( + &self, + tenant: &TenantId, + user: &UserId, + handle: SecretHandle, + material: SecretMaterial, + ) -> Result; + + async fn delete( + &self, + tenant: &TenantId, + user: &UserId, + handle: &SecretHandle, + ) -> Result; +} + +/// Fail-closed placeholder for composition paths that need an +/// [`AdminUserService`] handle purely for tenant-scoped role reads +/// (channel-command admission's `get_user` calls, which never mint tokens) +/// rather than the WebUI admin `create_user` route. +/// [`RebornAdminUserDirectory::create_user`] is the sole caller of the minter; +/// this always denies it rather than silently succeeding without a configured +/// minter. +pub struct RejectingAdminApiTokenMinter; + +#[async_trait] +impl AdminApiTokenMinter for RejectingAdminApiTokenMinter { + async fn mint(&self, _tenant: &TenantId, _user_id: &UserId) -> Result { + Err("admin API token minting is not configured for this composition path".to_string()) + } +} /// Adapter wiring the identity directory, admin secret provisioner, and token -/// minter into the product-workflow `AdminUserService` contract. -pub(crate) struct RebornAdminUserDirectory { +/// minter into the [`AdminUserService`] contract. +pub struct RebornAdminUserDirectory { directory: Arc, secrets: Arc, token_minter: Arc, } impl RebornAdminUserDirectory { - pub(crate) fn new( + pub fn new( directory: Arc, secrets: Arc, token_minter: Arc, diff --git a/crates/ironclaw_product/src/approval_interaction/types.rs b/crates/ironclaw_product/src/approval_interaction/types.rs index e7b6f932c05..b789f37b0e1 100644 --- a/crates/ironclaw_product/src/approval_interaction/types.rs +++ b/crates/ironclaw_product/src/approval_interaction/types.rs @@ -333,6 +333,58 @@ mod tests { ); } + /// The contracts-side prompt-lookup scope and this crate's interaction + /// scope must derive the *same* `ResourceScope` from the same turn. + /// + /// WS2.5 moved the approval-prompt lookup scope into + /// `ironclaw_product_contracts::approval_prompt` so the extension host can + /// read the approval store without reaching up into product. That leaves + /// two derivations of the same six fields in two crates, and nothing in the + /// compiler couples them — this pins the equivalence in both directions, so + /// a field added or re-mapped on either side fails here rather than + /// silently scoping one reader's store read differently from the other's. + /// Every field except `invocation_id` (freshly minted on each call, by + /// design) must agree, and the owner-over-actor precedence must agree too. + #[test] + fn approval_prompt_lookup_scope_matches_the_interaction_scope_projection() { + let actor = TurnActor::new(UserId::new("user:actor").unwrap()); + let owner_user_id = UserId::new("user:subject").unwrap(); + for scope in [ + // Shared/team subject: the explicit owner wins over the actor. + TurnScope::new_with_owner( + TenantId::new("tenant:shared").unwrap(), + Some(AgentId::new("agent:shared").unwrap()), + Some(ProjectId::new("project:shared").unwrap()), + ThreadId::new("thread:shared").unwrap(), + Some(owner_user_id.clone()), + ), + // Personal scope with no explicit owner: the actor is the owner, + // and the optional ids are absent. + TurnScope::new_with_owner( + TenantId::new("tenant:personal").unwrap(), + None, + None, + ThreadId::new("thread:personal").unwrap(), + None, + ), + ] { + let from_product = + ApprovalInteractionScope::from_turn(&scope, &actor).to_resource_scope(); + let from_contracts = + ironclaw_product_contracts::approval_prompt::approval_prompt_lookup_scope( + &scope, + &actor.user_id, + ); + + assert_eq!(from_contracts.tenant_id, from_product.tenant_id); + assert_eq!(from_contracts.user_id, from_product.user_id); + assert_eq!(from_contracts.agent_id, from_product.agent_id); + assert_eq!(from_contracts.project_id, from_product.project_id); + assert_eq!(from_contracts.mission_id, from_product.mission_id); + assert_eq!(from_contracts.thread_id, from_product.thread_id); + } + } + #[test] fn approval_gate_record_with_status_rejects_scope_without_thread_id() { let request = approval_request(); diff --git a/crates/ironclaw_product/src/approval_prompt.rs b/crates/ironclaw_product/src/approval_prompt.rs index 39b2ff33b0e..982deee77f6 100644 --- a/crates/ironclaw_product/src/approval_prompt.rs +++ b/crates/ironclaw_product/src/approval_prompt.rs @@ -1,21 +1,16 @@ //! Shared approval prompt lookup and redacted context projection. -use crate::{ - ApprovalPromptActionView, ApprovalPromptContextView, ApprovalPromptDestinationView, - ApprovalPromptDetailView, ApprovalPromptScopeView, -}; +use crate::ApprovalPromptContextView; use ironclaw_approvals::ApprovalRequestStorePort; use ironclaw_approvals::ApprovalStoreError; -use ironclaw_host_api::turn::{TurnActor, TurnGateRef, TurnScope}; -use ironclaw_host_api::{ - action::{Action, NetworkMethod, NetworkScheme}, - approval::ApprovalRequest, - ids::{InvocationId, UserId}, +use ironclaw_host_api::ids::{InvocationId, UserId}; +use ironclaw_host_api::turn::{TurnGateRef, TurnScope}; +use ironclaw_product_contracts::approval_prompt::{ + approval_prompt_context_for_request, approval_prompt_lookup_scope, + approval_request_id_from_gate_ref, }; use thiserror::Error; -use crate::{ApprovalInteractionScope, approval_request_id_from_gate_ref}; - #[derive(Debug, Default)] pub struct ApprovalPromptLookup { pub context: Option, @@ -36,16 +31,14 @@ pub async fn approval_prompt_lookup( turn_scope: &TurnScope, ) -> Result { let (store, request_id) = - match approval_requests.zip(approval_request_id_from_gate_ref(gate_ref).ok()) { + match approval_requests.zip(approval_request_id_from_gate_ref(gate_ref)) { Some(value) => value, None => return Ok(ApprovalPromptLookup::default()), }; - let scope = - ApprovalInteractionScope::from_turn(turn_scope, &TurnActor::new(owner_user_id.clone())) - .to_resource_scope(); + let scope = approval_prompt_lookup_scope(turn_scope, owner_user_id); match store.get(&scope, request_id).await { Ok(Some(record)) => Ok(ApprovalPromptLookup { - context: approval_context_for_request(&record.request), + context: approval_prompt_context_for_request(&record.request), invocation_id: Some(record.scope.invocation_id), }), Ok(None) => Ok(ApprovalPromptLookup::default()), @@ -63,134 +56,3 @@ pub async fn approval_prompt_context_view( .await .map(|lookup| lookup.context) } - -fn approval_context_for_request(request: &ApprovalRequest) -> Option { - let (tool_name, action, destination, details) = - approval_action_context(request.action.as_ref())?; - ApprovalPromptContextView::new( - tool_name, - action, - ApprovalPromptScopeView::new( - approval_scope_label(request), - request.reusable_scope.is_some(), - ) - .ok()?, - non_empty_string(&request.reason), - destination, - details, - ) - .ok() -} - -fn approval_action_context( - action: &Action, -) -> Option<( - String, - ApprovalPromptActionView, - Option, - Vec, -)> { - match action { - Action::Dispatch { - capability, - estimated_resources, - } => { - let mut details = vec![detail("Capability", capability.as_str())?]; - if let Some(bytes) = estimated_resources.network_egress_bytes { - details.push(detail("Estimated network egress", format_bytes(bytes))?); - } - Some(( - capability.as_str().to_string(), - ApprovalPromptActionView::new("Run tool", None).ok()?, - None, - details, - )) - } - Action::SpawnCapability { - capability, - estimated_resources, - } => { - let mut details = vec![detail("Capability", capability.as_str())?]; - if let Some(process_count) = estimated_resources.process_count { - details.push(detail("Processes", process_count.to_string())?); - } - Some(( - capability.as_str().to_string(), - ApprovalPromptActionView::new("Start tool", None).ok()?, - None, - details, - )) - } - Action::Network { - target, - method, - estimated_bytes, - } => { - let destination = - network_destination(method, target.scheme, &target.host, target.port)?; - let mut details = vec![detail("Method", method_label(method))?]; - if let Some(bytes) = estimated_bytes { - details.push(detail("Estimated transfer", format_bytes(*bytes))?); - } - Some(( - "builtin.http".to_string(), - ApprovalPromptActionView::new("Network request", Some(*method)).ok()?, - Some(destination), - details, - )) - } - _ => None, - } -} - -fn approval_scope_label(request: &ApprovalRequest) -> &'static str { - if request.reusable_scope.is_some() { - "Reusable grant" - } else { - "This request only" - } -} - -fn network_destination( - method: &NetworkMethod, - scheme: NetworkScheme, - host: &str, - port: Option, -) -> Option { - let scheme = match scheme { - NetworkScheme::Http => "http", - NetworkScheme::Https => "https", - }; - let authority = match port { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }; - let url = format!("{scheme}://{authority}"); - ApprovalPromptDestinationView::new( - format!("{} {url}", method_label(method)), - Some(url), - Some(host.to_string()), - ) - .ok() -} - -fn detail(label: impl Into, value: impl Into) -> Option { - ApprovalPromptDetailView::new(label, value).ok() -} - -fn method_label(method: &NetworkMethod) -> String { - method.to_string().to_ascii_uppercase() -} - -fn format_bytes(bytes: u64) -> String { - format!("{bytes} bytes") -} - -fn non_empty_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} diff --git a/crates/ironclaw_reborn_composition/src/blocked_auth_resume.rs b/crates/ironclaw_product/src/blocked_auth_resume.rs similarity index 99% rename from crates/ironclaw_reborn_composition/src/blocked_auth_resume.rs rename to crates/ironclaw_product/src/blocked_auth_resume.rs index 3c847f75a39..a7619ab72f3 100644 --- a/crates/ironclaw_reborn_composition/src/blocked_auth_resume.rs +++ b/crates/ironclaw_product/src/blocked_auth_resume.rs @@ -44,14 +44,14 @@ use crate::process_gate_turn_view::{turn_run_id_from_process_id, turn_scope_from /// Decorates the single-run continuation dispatcher with the caller-wide /// blocked-run fan-out described in the module docs. -pub(crate) struct BlockedAuthResumeFanout { +pub struct BlockedAuthResumeFanout { inner: Arc, gate_source: Arc>, turn_coordinator: Arc, } impl BlockedAuthResumeFanout { - pub(crate) fn new( + pub fn new( inner: Arc, gate_source: Arc>, turn_coordinator: Arc, diff --git a/crates/ironclaw_product/src/conversation_binding.rs b/crates/ironclaw_product/src/conversation_binding.rs index 954d5160832..6f2778de2e8 100644 --- a/crates/ironclaw_product/src/conversation_binding.rs +++ b/crates/ironclaw_product/src/conversation_binding.rs @@ -10,6 +10,10 @@ use crate::{ ConversationBindingService, ProductConversationRouteKind, ProductSurfaceFailure, ResolveBindingRequest, ResolvedBinding, }; +use ironclaw_product_contracts::actor_identity::{ + ProductActorUserResolutionRequest, ProductActorUserResolver, ResolvedProductActorUser, +}; +use ironclaw_product_contracts::error::ProductOperationFailure; use ironclaw_product_contracts::subject_route::{ ProductConversationRouteKey, ProductConversationSubjectRouteResolutionRequest, ProductConversationSubjectRouteResolver, @@ -32,74 +36,6 @@ impl ProductInstallationKey { } } -/// Request passed to host-owned actor-to-user resolvers before the workflow -/// writes a conversation pairing. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ProductActorUserResolutionRequest { - pub adapter_id: ProductAdapterId, - pub installation_id: AdapterInstallationId, - pub external_actor_ref: ExternalActorRef, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedProductActorUser { - pub user_id: UserId, - pub binding_epoch: Option, -} - -impl ResolvedProductActorUser { - pub fn new(user_id: UserId) -> Self { - Self { - user_id, - binding_epoch: None, - } - } - - pub fn with_binding_epoch( - user_id: UserId, - binding_epoch: ironclaw_conversations::ExternalActorBindingEpoch, - ) -> Self { - Self { - user_id, - binding_epoch: Some(binding_epoch), - } - } -} - -impl ProductActorUserResolutionRequest { - pub fn new( - adapter_id: ProductAdapterId, - installation_id: AdapterInstallationId, - external_actor_ref: ExternalActorRef, - ) -> Self { - Self { - adapter_id, - installation_id, - external_actor_ref, - } - } -} - -#[async_trait] -pub trait ProductActorUserResolver: Send + Sync { - async fn resolve_product_actor_user( - &self, - request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure>; - - async fn resolved_product_actor_user_is_current( - &self, - request: &ProductActorUserResolutionRequest, - expected: &ResolvedProductActorUser, - ) -> Result { - Ok(self - .resolve_product_actor_user(request.clone()) - .await? - .as_ref() - == Some(expected)) - } -} - /// Build a subject-route resolution request from an inbound binding request. /// /// A free function rather than an associated one: the request type is declared @@ -136,7 +72,7 @@ impl ProductActorUserResolver for StaticProductActorUserResolver { async fn resolve_product_actor_user( &self, request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { Ok(self .bindings .get(&request.external_actor_ref) diff --git a/crates/ironclaw_product/src/extension_account_setup.rs b/crates/ironclaw_product/src/extension_account_setup.rs index 5a576b07fdb..16795eafd86 100644 --- a/crates/ironclaw_product/src/extension_account_setup.rs +++ b/crates/ironclaw_product/src/extension_account_setup.rs @@ -8,12 +8,14 @@ use std::collections::{BTreeMap, btree_map::Entry as MapEntry}; use std::sync::{Arc, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use async_trait::async_trait; use ironclaw_host_api::{ decision::RuntimeCredentialAuthRequirement, ids::{ExtensionId, UserId}, }; use ironclaw_product_contracts::account_setup::{ AccountConnectionStatusSource, ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, + ExtensionAccountSetupReader, }; #[derive(Debug)] @@ -66,20 +68,17 @@ impl ExtensionAccountSetupRegistry { .get(extension_id) .is_some_and(|entry| entry.status_source.set(source).is_ok()) } +} - pub fn descriptor( - &self, - extension_id: &ExtensionId, - ) -> Option { +#[async_trait] +impl ExtensionAccountSetupReader for ExtensionAccountSetupRegistry { + fn descriptor(&self, extension_id: &ExtensionId) -> Option { read_entries(&self.entries) .get(extension_id) .map(|entry| entry.descriptor.clone()) } - /// Returns the requirement only when the declared account is disconnected. - /// Undeclared extensions have no account gate; declared extensions whose - /// host or status backend is unavailable fail closed. - pub async fn missing_requirement( + async fn missing_requirement( &self, extension_id: &ExtensionId, user_id: &UserId, diff --git a/crates/ironclaw_product/src/filesystem_ledger.rs b/crates/ironclaw_product/src/filesystem_ledger.rs index 5f319e2b955..55504d2c5c4 100644 --- a/crates/ironclaw_product/src/filesystem_ledger.rs +++ b/crates/ironclaw_product/src/filesystem_ledger.rs @@ -13,8 +13,6 @@ use crate::{ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use futures::StreamExt; -use ironclaw_filesystem::LibSqlRootFilesystem; -use ironclaw_filesystem::PostgresRootFilesystem; use ironclaw_filesystem::{ CasExpectation, Entry, FilesystemError, Filter, IndexKey, IndexValue, Page, RecordKind, RecordVersion, RootFilesystem, ScopedFilesystem, @@ -442,11 +440,28 @@ where } } -/// Scoped-filesystem-backed product workflow idempotency ledger. +/// Filesystem-backed product workflow idempotency ledger. /// -/// Construct with the same [`ScopedFilesystem`] handle used by the Reborn host -/// stores. The supplied [`ResourceScope`] is passed to the filesystem for every -/// operation so the filesystem's mount resolver owns any tenant/user rewriting. +/// **The one form.** It is generic over the `RootFilesystem` implementation, so +/// there is no per-backend wrapper: a libSQL ledger is +/// `RebornFilesystemIdempotencyLedger::::new_root(fs)` and +/// a PostgreSQL one is the same call with `PostgresRootFilesystem`. (WS5 collapsed +/// the `RebornLibSqlIdempotencyLedger` / `RebornPostgresIdempotencyLedger` +/// newtypes — fossilized boundaries of the per-backend sub-crates folded in by +/// #5540 — onto this generic form; they were byte-identical modulo the concrete +/// filesystem type and had zero production construction sites.) +/// +/// Two constructor families: +/// +/// - **scoped** ([`new`](Self::new), [`with_in_flight_lease`](Self::with_in_flight_lease), +/// [`with_root`](Self::with_root)) — construct with the same [`ScopedFilesystem`] +/// handle used by the Reborn host stores. The supplied [`ResourceScope`] is passed +/// to the filesystem for every operation so the filesystem's mount resolver owns +/// any tenant/user rewriting. This is what production wires. +/// - **root** ([`new_root`](Self::new_root), [`with_root_lease`](Self::with_root_lease), +/// [`with_virtual_root`](Self::with_virtual_root)) — construct straight from a raw +/// `RootFilesystem`; the ledger wraps it in a synthetic root scope itself. This is +/// the durable-backend contract suites' entry point. pub struct RebornFilesystemIdempotencyLedger where F: RootFilesystem, @@ -462,88 +477,26 @@ where Self::with_in_flight_lease(filesystem, scope, DEFAULT_IN_FLIGHT_LEASE) } - pub fn with_in_flight_lease( - filesystem: Arc>, - scope: ResourceScope, - in_flight_lease: Duration, - ) -> Self { - Self { - inner: FilesystemIdempotencyLedger::new_scoped(filesystem, scope, in_flight_lease), - } - } - - pub fn with_root( - filesystem: Arc>, - scope: ResourceScope, - root: ScopedPath, - in_flight_lease: Duration, - ) -> Self { - Self { - inner: FilesystemIdempotencyLedger::with_scoped_root( - filesystem, - scope, - root, - in_flight_lease, - ), - } - } - - pub fn with_settled_entry_limit(mut self, limit: NonZeroUsize) -> Self { - self.inner = self.inner.with_settled_entry_limit(limit); - self - } - - pub fn with_settled_prune_interval(mut self, interval: NonZeroUsize) -> Self { - self.inner = self.inner.with_settled_prune_interval(interval); - self - } -} - -#[async_trait] -impl IdempotencyLedger for RebornFilesystemIdempotencyLedger -where - F: RootFilesystem + ?Sized + 'static, -{ - async fn begin_or_replay( - &self, - fingerprint: ActionFingerprintKey, - received_at: DateTime, - ) -> Result { - self.inner.begin_or_replay(fingerprint, received_at).await - } - - async fn settle(&self, action: ProductInboundAction) -> Result<(), ProductSurfaceFailure> { - self.inner.settle(action).await - } - - async fn release(&self, action: ProductInboundAction) -> Result<(), ProductSurfaceFailure> { - self.inner.release(action).await - } -} - -/// libSQL-backed product workflow idempotency ledger using the shared -/// SQL filesystem backend for persistence. -pub struct RebornLibSqlIdempotencyLedger { - inner: FilesystemIdempotencyLedger, -} -impl RebornLibSqlIdempotencyLedger { - pub fn new(filesystem: Arc) -> Self { + /// Root-scoped construction from a raw filesystem handle. + pub fn new_root(filesystem: Arc) -> Self { Self { inner: FilesystemIdempotencyLedger::new_root(filesystem), } } - pub fn with_in_flight_lease( - filesystem: Arc, - in_flight_lease: Duration, - ) -> Self { + /// Root-scoped construction with an explicit in-flight lease. + pub fn with_root_lease(filesystem: Arc, in_flight_lease: Duration) -> Self { Self { inner: FilesystemIdempotencyLedger::with_root_lease(filesystem, in_flight_lease), } } - pub fn with_root( - filesystem: Arc, + /// Root-scoped construction with an explicit ledger root and lease. + /// + /// Named `with_virtual_root` rather than `with_root` because the scoped + /// family already owns that name with a different signature. + pub fn with_virtual_root( + filesystem: Arc, root: VirtualPath, in_flight_lease: Duration, ) -> Self { @@ -552,63 +505,29 @@ impl RebornLibSqlIdempotencyLedger { } } - pub fn with_settled_entry_limit(mut self, limit: NonZeroUsize) -> Self { - self.inner = self.inner.with_settled_entry_limit(limit); - self - } - - pub fn with_settled_prune_interval(mut self, interval: NonZeroUsize) -> Self { - self.inner = self.inner.with_settled_prune_interval(interval); - self - } -} -#[async_trait] -impl IdempotencyLedger for RebornLibSqlIdempotencyLedger { - async fn begin_or_replay( - &self, - fingerprint: ActionFingerprintKey, - received_at: DateTime, - ) -> Result { - self.inner.begin_or_replay(fingerprint, received_at).await - } - - async fn settle(&self, action: ProductInboundAction) -> Result<(), ProductSurfaceFailure> { - self.inner.settle(action).await - } - - async fn release(&self, action: ProductInboundAction) -> Result<(), ProductSurfaceFailure> { - self.inner.release(action).await - } -} - -/// PostgreSQL-backed product workflow idempotency ledger using the shared -/// SQL filesystem backend for persistence. -pub struct RebornPostgresIdempotencyLedger { - inner: FilesystemIdempotencyLedger, -} -impl RebornPostgresIdempotencyLedger { - pub fn new(filesystem: Arc) -> Self { - Self { - inner: FilesystemIdempotencyLedger::new_root(filesystem), - } - } - pub fn with_in_flight_lease( - filesystem: Arc, + filesystem: Arc>, + scope: ResourceScope, in_flight_lease: Duration, ) -> Self { Self { - inner: FilesystemIdempotencyLedger::with_root_lease(filesystem, in_flight_lease), + inner: FilesystemIdempotencyLedger::new_scoped(filesystem, scope, in_flight_lease), } } pub fn with_root( - filesystem: Arc, - root: VirtualPath, + filesystem: Arc>, + scope: ResourceScope, + root: ScopedPath, in_flight_lease: Duration, ) -> Self { Self { - inner: FilesystemIdempotencyLedger::with_root(filesystem, root, in_flight_lease), + inner: FilesystemIdempotencyLedger::with_scoped_root( + filesystem, + scope, + root, + in_flight_lease, + ), } } @@ -622,8 +541,12 @@ impl RebornPostgresIdempotencyLedger { self } } + #[async_trait] -impl IdempotencyLedger for RebornPostgresIdempotencyLedger { +impl IdempotencyLedger for RebornFilesystemIdempotencyLedger +where + F: RootFilesystem + ?Sized + 'static, +{ async fn begin_or_replay( &self, fingerprint: ActionFingerprintKey, diff --git a/crates/ironclaw_product/src/lib.rs b/crates/ironclaw_product/src/lib.rs index d4f273ff159..da6fbfc82c8 100644 --- a/crates/ironclaw_product/src/lib.rs +++ b/crates/ironclaw_product/src/lib.rs @@ -25,16 +25,16 @@ #![forbid(unsafe_code)] mod action; -pub mod adapter_registry; +mod admin_user_directory; mod approval_interaction; mod approval_prompt; mod auth_continuation; mod auth_interaction; -mod auth_prompt; mod automation_product_service; mod automation_thread_metadata; mod binding; mod binding_ref; +mod blocked_auth_resume; mod command_admission; mod command_dispatch; mod commands; @@ -55,7 +55,7 @@ mod ledger; mod lifecycle; mod outbound_delivery; mod policy; -mod product_auth_prompt; +mod process_gate_turn_view; mod product_surface_inbound; mod project_create_capability; mod project_service; @@ -66,11 +66,13 @@ mod scoped_fs; mod steering; mod workflow; -pub use product_auth_prompt::{blocked_auth_flow_canceller, product_auth_challenge_provider}; pub use project_create_capability::{PROJECT_CREATE_CAPABILITY_ID, project_create_capability}; pub use project_service::RebornProjectService; pub use action::{ActionDispatchKind, ActionPhase, ProductInboundAction}; +pub use admin_user_directory::{ + AdminSecretProvisioner, RebornAdminUserDirectory, RejectingAdminApiTokenMinter, +}; pub use approval_interaction::{ ApprovalBlockedTurnRun, ApprovalGateRecord, ApprovalInteractionActionView, ApprovalInteractionDecision, ApprovalInteractionReadModel, ApprovalInteractionRejectionKind, @@ -100,10 +102,12 @@ pub use auth_interaction::{ ListPendingAuthInteractionsResponse, PendingAuthInteractionView, ResolveAuthInteractionRequest, ResolveAuthInteractionResponse, is_auth_gate_ref, }; -pub use auth_prompt::{ - AuthChallengeProvider, AuthChallengeView, BlockedAuthFlowCanceller, PairingAuthChallengeView, - auth_prompt_view_for_blocked_auth, -}; +// `AuthChallengeProvider`, `AuthChallengeView`, `BlockedAuthFlowCanceller`, +// `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` moved to +// `ironclaw_auth::product_prompt` (WS2.5): every type in their signatures is +// auth's own vocabulary, and the extension host implements the challenge port. +// No re-export here — consumers import from the owner +// (`.claude/rules/type-placement.md`). pub use automation_product_service::RebornAutomationProductService; pub use automation_thread_metadata::{ AUTOMATION_TRIGGER_THREAD_SOURCE_TAG, automation_trigger_thread_metadata_json, @@ -113,6 +117,7 @@ pub use binding::{ ConversationBindingService, ProductConversationRouteKind, ResolveBindingRequest, ResolvedBinding, route_kind_for_inbound_payload, }; +pub use blocked_auth_resume::BlockedAuthResumeFanout; pub use command_admission::DirectConversationCommandAdmission; pub use command_dispatch::{ ProductCommandAdmission, ProductCommandAdmissionService, @@ -127,14 +132,17 @@ pub use commands::{ required_audience, validate_declared_product_command, }; pub use communication_context::RuntimeCommunicationContextProvider; +pub use process_gate_turn_view::{current_turn_gate_runs, first_turn_run_for_gate}; // `ProductConversationRouteKey`, `ProductConversationSubjectRouteResolutionRequest`, // and `ProductConversationSubjectRouteResolver` are deliberately absent: they // moved to `ironclaw_product_contracts::subject_route` (WS2.2), and that crate // grants no second import path (`reborn_product_contract_location_scan.rs`). +// `ProductActorUserResolutionRequest`, `ProductActorUserResolver` and +// `ResolvedProductActorUser` left for the same reason and under the same rule: +// `ironclaw_product_contracts::actor_identity` (WS2.5). pub use conversation_binding::{ - ProductActorBindingPolicy, ProductActorUserResolutionRequest, ProductActorUserResolver, - ProductConversationBindingService, ProductInstallationKey, ProductInstallationScope, - ResolvedProductActorUser, StaticProductActorUserResolver, StaticProductInstallationResolver, + ProductActorBindingPolicy, ProductConversationBindingService, ProductInstallationKey, + ProductInstallationScope, StaticProductActorUserResolver, StaticProductInstallationResolver, }; pub use error::{ AuthContinuationRejectionKind, ProductSurfaceFailure, lifecycle_product_surface_error, @@ -158,8 +166,6 @@ pub use scoped_fs::{ }; pub use filesystem_ledger::RebornFilesystemIdempotencyLedger; -pub use filesystem_ledger::RebornLibSqlIdempotencyLedger; -pub use filesystem_ledger::RebornPostgresIdempotencyLedger; pub use in_memory_ledger::InMemoryIdempotencyLedger; pub use inbound_turn::{ DefaultInboundTurnService, InboundTurnOutcome, InboundTurnService, InboundUserMessageDispatch, @@ -308,8 +314,8 @@ pub use reborn_services::{ AUTOMATION_RENAME_COMMAND, AUTOMATION_RESUME_CAPABILITY, AUTOMATION_RESUME_CAPABILITY_ID, AUTOMATION_RESUME_COMMAND, AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE, AUTOMATIONS_VIEW, AutomationListRequest, - AutomationProductService, CANCEL_RUN_COMMAND, CREATE_THREAD_COMMAND, ChannelAuthAccountState, - ChannelConnectionService, ChannelInboundSurfaceAdmission, ChannelInboundSurfaceOutcome, + AutomationProductService, CANCEL_RUN_COMMAND, CREATE_THREAD_COMMAND, + ChannelInboundSurfaceAdmission, ChannelInboundSurfaceOutcome, ChannelInboundSurfaceRejectedAdmission, ChannelInboundSurfaceRequest, EXTENSION_ACTIVATE_CAPABILITY, EXTENSION_ACTIVATE_CAPABILITY_ID, EXTENSION_IMPORT_CAPABILITY, EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_INSTALL_CAPABILITY, EXTENSION_INSTALL_CAPABILITY_ID, diff --git a/crates/ironclaw_reborn_composition/src/process_gate_turn_view.rs b/crates/ironclaw_product/src/process_gate_turn_view.rs similarity index 80% rename from crates/ironclaw_reborn_composition/src/process_gate_turn_view.rs rename to crates/ironclaw_product/src/process_gate_turn_view.rs index 5796f7bd986..9eda2653632 100644 --- a/crates/ironclaw_reborn_composition/src/process_gate_turn_view.rs +++ b/crates/ironclaw_product/src/process_gate_turn_view.rs @@ -1,3 +1,10 @@ +//! Pure projections of durable process-gate records into turn vocabulary. +//! +//! Read by the auth-interaction services and by the blocked-auth resume +//! fan-out beside them: a gate record is process-lifecycle evidence, and +//! what the product surface needs from it is the run it belongs to and the +//! turn scope to resume under. No I/O, no policy — mapping only. + use ironclaw_host_api::turn::{TurnGateRef, TurnRunId, TurnScope}; use ironclaw_host_api::{ ids::ProcessId, @@ -5,7 +12,7 @@ use ironclaw_host_api::{ }; use ironclaw_processes::ProcessGateRecord; -pub(crate) fn current_turn_gate_runs( +pub fn current_turn_gate_runs( records: impl IntoIterator, ) -> Vec<(TurnRunId, TurnGateRef)> { let mut runs = records @@ -21,7 +28,7 @@ pub(crate) fn current_turn_gate_runs( runs } -pub(crate) fn first_turn_run_for_gate( +pub fn first_turn_run_for_gate( records: impl IntoIterator, ) -> Option { let mut runs = records diff --git a/crates/ironclaw_product/src/product_auth_prompt.rs b/crates/ironclaw_product/src/product_auth_prompt.rs deleted file mode 100644 index b80c938738e..00000000000 --- a/crates/ironclaw_product/src/product_auth_prompt.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use ironclaw_auth::{ - AuthChallenge, AuthFlowOwnerScope, AuthGateRef, AuthProductError, RebornProductAuthServices, - TurnGateAuthFlowQuery, TurnRunRef, -}; -use ironclaw_host_api::{decision::RuntimeCredentialAuthRequirement, ids::UserId}; -use ironclaw_turns::{TurnRunId, TurnScope}; - -use crate::{ - AuthChallengeProvider, AuthChallengeView, AuthPromptChallengeKind, BlockedAuthFlowCanceller, -}; - -pub fn product_auth_challenge_provider( - product_auth: &Arc, -) -> Option> { - product_auth - .flow_record_source() - .map(|_| Arc::clone(product_auth) as Arc) -} - -pub fn blocked_auth_flow_canceller( - product_auth: &Arc, -) -> Option> { - product_auth - .flow_record_source() - .map(|_| Arc::clone(product_auth) as Arc) -} - -#[async_trait] -impl AuthChallengeProvider for RebornProductAuthServices { - async fn challenge_for_gate( - &self, - scope: &TurnScope, - owner_user_id: &UserId, - run_id: TurnRunId, - gate_ref: &str, - credential_requirements: &[RuntimeCredentialAuthRequirement], - ) -> Result, AuthProductError> { - let gate_ref = AuthGateRef::new(gate_ref.to_string()).map_err(|error| { - tracing::debug!(%error, "invalid gate_ref in auth challenge lookup"); - AuthProductError::BackendUnavailable - })?; - let Some(source) = self.flow_record_source() else { - return Ok(None); - }; - let flow_manager = self.flow_manager(); - if let Some(driver) = self.oauth_gate_driver() - && let Some(flow) = driver - .challenge_for_blocked_gate(ironclaw_auth::OAuthGateChallengeRequest { - flow_manager: &flow_manager, - flow_source: &source, - requirements: credential_requirements, - scope, - owner_user_id, - run_id, - gate_ref: &gate_ref, - }) - .await? - { - let Some(challenge) = flow.challenge.as_ref() else { - return Ok(None); - }; - return Ok(Some(auth_challenge_to_view(challenge, &flow.provider))); - } - let flow = source - .flow_for_turn_gate(TurnGateAuthFlowQuery { - owner: AuthFlowOwnerScope { - tenant_id: scope.tenant_id.clone(), - user_id: owner_user_id.clone(), - agent_id: scope.agent_id.clone(), - project_id: scope.project_id.clone(), - thread_id: scope.thread_id.clone(), - }, - turn_run_ref: TurnRunRef::new(run_id.to_string()).map_err(|error| { - tracing::debug!(%error, "invalid run_id in auth challenge lookup"); - AuthProductError::BackendUnavailable - })?, - gate_ref, - include_terminal: false, - }) - .await?; - let Some(flow) = flow else { - return Ok(None); - }; - let Some(challenge) = flow.challenge.as_ref() else { - return Ok(None); - }; - Ok(Some(auth_challenge_to_view(challenge, &flow.provider))) - } -} - -#[async_trait] -impl BlockedAuthFlowCanceller for RebornProductAuthServices { - async fn cancel_blocked_auth_flow( - &self, - scope: &TurnScope, - owner_user_id: &UserId, - run_id: TurnRunId, - gate_ref: &str, - ) -> Result<(), AuthProductError> { - self.cancel_blocked_auth_flow(scope, owner_user_id, run_id, gate_ref) - .await - } -} - -fn auth_challenge_to_view( - challenge: &AuthChallenge, - provider: &ironclaw_auth::AuthProviderId, -) -> AuthChallengeView { - match challenge { - AuthChallenge::OAuthUrl { - authorization_url, - expires_at, - } => AuthChallengeView { - kind: AuthPromptChallengeKind::OAuthUrl, - provider: provider.clone(), - account_label: None, - authorization_url: Some(authorization_url.clone()), - expires_at: Some(*expires_at), - // Product-auth OAuth relay: no channel-connection context. - pairing: None, - }, - AuthChallenge::ManualTokenRequired { - provider, - label, - expires_at, - .. - } => AuthChallengeView { - kind: AuthPromptChallengeKind::ManualToken, - provider: provider.clone(), - account_label: Some(label.clone()), - authorization_url: None, - expires_at: Some(*expires_at), - pairing: None, - }, - AuthChallenge::AccountSelectionRequired { .. } - | AuthChallenge::ReauthorizeRequired { .. } - | AuthChallenge::SetupRequired { .. } => AuthChallengeView { - kind: AuthPromptChallengeKind::Other, - provider: provider.clone(), - account_label: None, - authorization_url: None, - expires_at: None, - pairing: None, - }, - } -} diff --git a/crates/ironclaw_product/src/projection.rs b/crates/ironclaw_product/src/projection.rs index ca5b26a9ab5..e4d67393c32 100644 --- a/crates/ironclaw_product/src/projection.rs +++ b/crates/ironclaw_product/src/projection.rs @@ -54,11 +54,11 @@ pub mod display_preview; pub mod live_progress; pub mod runtime_replay; pub mod turn_events; -use crate::AuthChallengeProvider; use display_preview::{ CapabilityDisplayPreviewResolution, CapabilityDisplayPreviewSource, NoopCapabilityDisplayPreviewSource, }; +use ironclaw_auth::product_prompt::AuthChallengeProvider; use live_progress::{ LiveProgressMilestoneSink, LiveSkillActivationObserver, product_items_for_live_update, }; diff --git a/crates/ironclaw_product/src/projection/tests/turn_stream_auth.rs b/crates/ironclaw_product/src/projection/tests/turn_stream_auth.rs index 1910b0dfa2b..c8721d78d2e 100644 --- a/crates/ironclaw_product/src/projection/tests/turn_stream_auth.rs +++ b/crates/ironclaw_product/src/projection/tests/turn_stream_auth.rs @@ -1,6 +1,7 @@ use super::*; -use crate::{AuthChallengeProvider, AuthChallengeView, AuthPromptChallengeKind}; +use crate::AuthPromptChallengeKind; +use ironclaw_auth::product_prompt::{AuthChallengeProvider, AuthChallengeView}; use ironclaw_auth::{AuthProviderId, OAuthAuthorizationUrl}; use ironclaw_host_api::{ capability::RuntimeCredentialAccountSetup, decision::RuntimeCredentialAuthRequirement, @@ -77,7 +78,7 @@ impl AuthChallengeProvider for FakePairingAuthChallengeProvider { account_label: None, authorization_url: None, expires_at: None, - pairing: Some(crate::PairingAuthChallengeView { + pairing: Some(ironclaw_auth::product_prompt::PairingAuthChallengeView { code: "ABCD2345".to_string(), deep_link: Some("https://t.me/fixturebot?start=ABCD2345".to_string()), expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), diff --git a/crates/ironclaw_product/src/projection/turn_events.rs b/crates/ironclaw_product/src/projection/turn_events.rs index b9d0b1337f9..792ecc531cb 100644 --- a/crates/ironclaw_product/src/projection/turn_events.rs +++ b/crates/ironclaw_product/src/projection/turn_events.rs @@ -4,29 +4,27 @@ use std::{ sync::Arc, }; -use crate::{ApprovalInteractionScope, approval_request_id_from_gate_ref, is_approval_gate_ref}; +use crate::is_approval_gate_ref; use crate::{ - ApprovalPromptActionView, ApprovalPromptContextView, ApprovalPromptDestinationView, - ApprovalPromptDetailView, ApprovalPromptScopeView, AuthPromptContextView, GatePromptView, - ProductAdapterError, ProductGateKind, ProductOutboundPayload, ProductProjectionItem, - ProductProjectionState, ProductSurfaceRejectionKind, RedactedString, + ApprovalPromptContextView, AuthPromptContextView, GatePromptView, ProductAdapterError, + ProductGateKind, ProductOutboundPayload, ProductProjectionItem, ProductProjectionState, + ProductSurfaceRejectionKind, RedactedString, }; use async_trait::async_trait; use futures::{StreamExt, stream}; use ironclaw_approvals::ApprovalRequestStorePort; +use ironclaw_host_api::ids::{InvocationId, UserId}; use ironclaw_host_api::turn::{ - ModelInvalidOutputDetailReason, SanitizedFailure, TurnActor, TurnGateRef, TurnRunId, TurnScope, - TurnStatus, -}; -use ironclaw_host_api::{ - action::{Action, NetworkMethod, NetworkScheme}, - approval::ApprovalRequest, - ids::{InvocationId, UserId}, + ModelInvalidOutputDetailReason, SanitizedFailure, TurnGateRef, TurnRunId, TurnScope, TurnStatus, }; use ironclaw_loop_contracts::{ SystemInferenceIdentity, SystemInferencePort, SystemInferenceRequest, SystemInferenceTaskId, SystemPromptId, SystemPromptSource, SystemTaskKind, sanitize_model_visible_text, }; +use ironclaw_product_contracts::approval_prompt::{ + approval_prompt_context_for_request, approval_prompt_lookup_scope, + approval_request_id_from_gate_ref, +}; use ironclaw_turns::{ GetRunStateRequest, TurnBlockedGateKind, TurnCoordinator, TurnError, TurnEventKind, TurnEventProjectionCursor, TurnEventProjectionError, TurnEventProjectionRequest, @@ -34,8 +32,7 @@ use ironclaw_turns::{ }; use tokio::sync::{Mutex, OnceCell, Semaphore}; -use crate::AuthChallengeProvider; -use crate::auth_prompt_view_for_blocked_auth; +use ironclaw_auth::product_prompt::{AuthChallengeProvider, auth_prompt_view_for_blocked_auth}; use ironclaw_host_api::failure::categories::CHECKPOINT_REJECTED_CATEGORY; use ironclaw_host_api::failure::summary::{ checkpoint_rejection_host_explanation_from_detail, pinned_failure_summary_for_category, @@ -505,6 +502,10 @@ async fn approval_gate_prompt( /// so both surface the *same* "what is being approved" data from one source. /// Returns `None` when no store is wired, the gate ref is not an approval ref, /// the request is missing, or the lookup fails. +/// +/// The projection itself lives in +/// `ironclaw_product_contracts::approval_prompt`; this wrapper adds the store +/// read and the documented best-effort degradation. pub async fn approval_prompt_context_view( approval_requests: Option<&dyn ApprovalRequestStorePort>, gate_ref: &TurnGateRef, @@ -530,153 +531,20 @@ async fn approval_prompt_lookup( turn_scope: &TurnScope, ) -> Result { let (store, request_id) = - match approval_requests.zip(approval_request_id_from_gate_ref(gate_ref).ok()) { + match approval_requests.zip(approval_request_id_from_gate_ref(gate_ref)) { Some(value) => value, None => return Ok(ApprovalPromptLookup::default()), }; - let scope = - ApprovalInteractionScope::from_turn(turn_scope, &TurnActor::new(owner_user_id.clone())) - .to_resource_scope(); + let scope = approval_prompt_lookup_scope(turn_scope, owner_user_id); Ok(match store.get(&scope, request_id).await? { Some(record) => ApprovalPromptLookup { - context: approval_context_for_request(&record.request), + context: approval_prompt_context_for_request(&record.request), invocation_id: Some(record.scope.invocation_id), }, None => ApprovalPromptLookup::default(), }) } -fn approval_context_for_request(request: &ApprovalRequest) -> Option { - let (tool_name, action, destination, details) = - approval_action_context(request.action.as_ref())?; - ApprovalPromptContextView::new( - tool_name, - action, - ApprovalPromptScopeView::new( - approval_scope_label(request), - request.reusable_scope.is_some(), - ) - .ok()?, - non_empty_string(&request.reason), - destination, - details, - ) - .ok() -} - -fn approval_action_context( - action: &Action, -) -> Option<( - String, - ApprovalPromptActionView, - Option, - Vec, -)> { - match action { - Action::Dispatch { - capability, - estimated_resources, - } => { - let mut details = vec![detail("Capability", capability.as_str())?]; - if let Some(bytes) = estimated_resources.network_egress_bytes { - details.push(detail("Estimated network egress", format_bytes(bytes))?); - } - Some(( - capability.as_str().to_string(), - ApprovalPromptActionView::new("Run tool", None).ok()?, - None, - details, - )) - } - Action::SpawnCapability { - capability, - estimated_resources, - } => { - let mut details = vec![detail("Capability", capability.as_str())?]; - if let Some(process_count) = estimated_resources.process_count { - details.push(detail("Processes", process_count.to_string())?); - } - Some(( - capability.as_str().to_string(), - ApprovalPromptActionView::new("Start tool", None).ok()?, - None, - details, - )) - } - Action::Network { - target, - method, - estimated_bytes, - } => { - let destination = - network_destination(method, target.scheme, &target.host, target.port)?; - let mut details = vec![detail("Method", method_label(method))?]; - if let Some(bytes) = estimated_bytes { - details.push(detail("Estimated transfer", format_bytes(*bytes))?); - } - Some(( - "builtin.http".to_string(), - ApprovalPromptActionView::new("Network request", Some(*method)).ok()?, - Some(destination), - details, - )) - } - _ => None, - } -} - -fn approval_scope_label(request: &ApprovalRequest) -> &'static str { - if request.reusable_scope.is_some() { - "Reusable grant" - } else { - "This request only" - } -} - -fn network_destination( - method: &NetworkMethod, - scheme: NetworkScheme, - host: &str, - port: Option, -) -> Option { - let scheme = match scheme { - NetworkScheme::Http => "http", - NetworkScheme::Https => "https", - }; - let authority = match port { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }; - let url = format!("{scheme}://{authority}"); - ApprovalPromptDestinationView::new( - format!("{} {url}", method_label(method)), - Some(url), - Some(host.to_string()), - ) - .ok() -} - -fn detail(label: impl Into, value: impl Into) -> Option { - ApprovalPromptDetailView::new(label, value).ok() -} - -fn method_label(method: &NetworkMethod) -> String { - method.to_string().to_ascii_uppercase() -} - -fn format_bytes(bytes: u64) -> String { - format!("{bytes} bytes") -} - -fn non_empty_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - fn gate_prompt( event: &TurnLifecycleEvent, gate_ref: String, diff --git a/crates/ironclaw_product/src/reborn_services.rs b/crates/ironclaw_product/src/reborn_services.rs index abc3b15db8d..5e2b677dafc 100644 --- a/crates/ironclaw_product/src/reborn_services.rs +++ b/crates/ironclaw_product/src/reborn_services.rs @@ -45,9 +45,8 @@ use chrono::Utc; use futures::future::try_join_all; use ironclaw_attachments::{InboundAttachmentLander, InboundAttachmentReader}; use ironclaw_auth::{ - AuthFlowStatus, AuthProductScope, AuthProviderId, CredentialAccountId, - CredentialAccountProjection, CredentialAccountStatus, CredentialAccountUpdateBinding, - ProviderScope, + AuthProductScope, AuthProviderId, ChannelConnectionService, CredentialAccountId, + CredentialAccountProjection, CredentialAccountUpdateBinding, ProviderScope, }; use ironclaw_host_api::turn::{ AcceptedMessageRef, IdempotencyKey, SanitizedCancelReason, TurnActor, TurnGateRef, TurnRunId, @@ -661,67 +660,6 @@ fn rejected_busy_notice(status: TurnStatus) -> String { } } -/// The caller's durable auth-account signal for one channel extension's vendor -/// — the raw inputs the extensions-list service feeds to -/// [`ironclaw_auth::project_auth_account_state`] so an account renders its real -/// §6.3 state (`expired` / `refresh-failed` / `authenticating`) plus a typed -/// last error, instead of the connected/disconnected collapse the -/// [`ChannelConnectionService::caller_channel_connections`] bool alone permits. -/// -/// Both inputs are optional. A service that only knows the caller holds a live -/// grant leaves both `None` and the projection falls back to the connection -/// bool (a live grant backfills to `connected`, MIG-1); a service that reads the -/// durable credential-account status supplies `account_status` (and, mid-flow, -/// `active_flow_status`) so the wire surfaces the real state. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ChannelAuthAccountState { - /// The caller's durable credential-account status for the extension's - /// vendor, when the service can read it. - pub account_status: Option, - /// A live (non-terminal) auth flow for the extension's vendor, when one is - /// in progress — projects to `authenticating`. - pub active_flow_status: Option, -} - -/// Per-user channel connection state. Returns, for the calling user, which -/// channel extensions they have personally connected (for example, Slack OAuth). -/// Keyed by channel package id (e.g. `"slack"`) -> `true` when connected. -/// Only channels that have a per-user connection concept appear in the map; -/// absence means "no per-user connection concept for this channel". -#[async_trait] -pub trait ChannelConnectionService: Send + Sync { - async fn caller_channel_connections( - &self, - caller: ProductSurfaceCaller, - ) -> Result, ProductSurfaceError>; - - /// The caller's durable auth-account signal per channel extension, keyed by - /// channel package id — richer than the connected/disconnected bool - /// [`Self::caller_channel_connections`] returns. Lets the extensions wire - /// project the shared §6.3 auth-account state (`expired` / `refresh-failed`) - /// and its typed last error for each vendor account. - /// - /// Default: empty. A service that does not yet read durable credential-account - /// status reports none and the wire falls back to the connection bool; the - /// production channel-connection service overrides this to project each - /// caller's account status. - async fn caller_channel_account_states( - &self, - _caller: ProductSurfaceCaller, - ) -> Result, ProductSurfaceError> - { - Ok(std::collections::HashMap::new()) - } - - async fn disconnect_channel_for_caller( - &self, - _caller: ProductSurfaceCaller, - _channel: &str, - ) -> Result<(), ProductSurfaceError> { - Err(ProductSurfaceError::service_unavailable(false)) - } -} - #[derive(Debug, Clone, Default)] pub struct StaticChannelConnectionService; diff --git a/crates/ironclaw_product/src/reborn_services/extensions.rs b/crates/ironclaw_product/src/reborn_services/extensions.rs index 931783b028f..bedf9ff1fb2 100644 --- a/crates/ironclaw_product/src/reborn_services/extensions.rs +++ b/crates/ironclaw_product/src/reborn_services/extensions.rs @@ -9,7 +9,8 @@ use std::{ use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures::{StreamExt, TryStreamExt, stream}; use ironclaw_auth::{ - AuthAccountLastError, AuthAccountState, CredentialAccountStatus, project_auth_account_state, + AuthAccountLastError, AuthAccountState, ChannelAuthAccountState, ChannelConnectionService, + CredentialAccountStatus, project_auth_account_state, }; use ironclaw_extension_contracts::{ state::{InstallationState, LifecyclePublicState}, @@ -21,11 +22,11 @@ use ironclaw_product_contracts::surface::{ }; use crate::{ - ChannelAuthAccountState, ChannelConnectionService, LifecycleExtensionSummary, - LifecycleInstalledExtensionSummary, LifecycleProductAction, LifecycleProductPayload, - LifecycleProductResponse, ProductView, RebornAccountBindingSource, RebornAuthAccount, - RebornExtensionInfo, RebornExtensionListResponse, RebornExtensionRegistryEntry, - RebornExtensionRegistryResponse, RebornExtensionSurface, RebornVendorAuthAccounts, + LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, LifecycleProductAction, + LifecycleProductPayload, LifecycleProductResponse, ProductView, RebornAccountBindingSource, + RebornAuthAccount, RebornExtensionInfo, RebornExtensionListResponse, + RebornExtensionRegistryEntry, RebornExtensionRegistryResponse, RebornExtensionSurface, + RebornVendorAuthAccounts, }; use super::{ @@ -535,13 +536,14 @@ mod tests { use super::*; use crate::reborn_services::StaticChannelConnectionService; use crate::{ - ChannelConnectionRequirement, ChannelConnectionService, ExtensionCredentialStatusRequest, + ChannelConnectionRequirement, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LifecycleExtensionCredentialRequirement, LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleInstalledExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, LifecycleSearchExtensionSummary, RebornChannelConnectStrategy, }; + use ironclaw_auth::ChannelConnectionService; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; diff --git a/crates/ironclaw_product/src/run_delivery.rs b/crates/ironclaw_product/src/run_delivery.rs index 2f6ce94a0aa..dd156886d84 100644 --- a/crates/ironclaw_product/src/run_delivery.rs +++ b/crates/ironclaw_product/src/run_delivery.rs @@ -35,7 +35,7 @@ use ironclaw_outbound::{ }; use ironclaw_turns::{GetRunStateRequest, TurnCoordinator, TurnRunState}; -use crate::auth_prompt::BlockedAuthFlowCanceller; +use ironclaw_auth::product_prompt::BlockedAuthFlowCanceller; use ironclaw_product_contracts::prompt_source::{ ApprovalPromptContextSource, BlockedAuthPromptSource, }; diff --git a/crates/ironclaw_product/tests/durable_ledger_contract.rs b/crates/ironclaw_product/tests/durable_ledger_contract.rs index 902f9d71d39..d68e44d75d1 100644 --- a/crates/ironclaw_product/tests/durable_ledger_contract.rs +++ b/crates/ironclaw_product/tests/durable_ledger_contract.rs @@ -5,8 +5,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; use chrono::Duration; use ironclaw_filesystem::LibSqlRootFilesystem; use ironclaw_filesystem::PostgresRootFilesystem; -use ironclaw_product::RebornLibSqlIdempotencyLedger; -use ironclaw_product::RebornPostgresIdempotencyLedger; +use ironclaw_product::RebornFilesystemIdempotencyLedger; + +/// WS5 collapsed the per-backend ledger newtypes onto the generic fabric form. +/// These aliases keep the suite's two backend lanes named while proving both +/// resolve to the same generic type. +type RebornLibSqlIdempotencyLedger = RebornFilesystemIdempotencyLedger; +type RebornPostgresIdempotencyLedger = RebornFilesystemIdempotencyLedger; // Shared ledger test support was renamed on fold-in to avoid colliding with the // product_surface crate's own `tests/support/` module. @@ -40,8 +45,8 @@ async fn libsql_settled_action_survives_reopen_and_replays() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); let db_path = db_path.display().to_string(); - let ledger = RebornLibSqlIdempotencyLedger::new(libsql_filesystem(&db_path).await); - let reopened = RebornLibSqlIdempotencyLedger::new(libsql_filesystem(&db_path).await); + let ledger = RebornLibSqlIdempotencyLedger::new_root(libsql_filesystem(&db_path).await); + let reopened = RebornLibSqlIdempotencyLedger::new_root(libsql_filesystem(&db_path).await); assert_settled_action_survives_reopen_and_replays(&ledger, &reopened, "libsql-settled-replay") .await; @@ -50,7 +55,7 @@ async fn libsql_settled_action_survives_reopen_and_replays() { async fn libsql_in_flight_action_blocks_until_lease_expires() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let ledger = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path.display().to_string()).await, Duration::seconds(10), ); @@ -60,7 +65,7 @@ async fn libsql_in_flight_action_blocks_until_lease_expires() { async fn libsql_release_allows_retry_without_waiting_for_lease() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let ledger = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path.display().to_string()).await, Duration::seconds(60), ); @@ -71,11 +76,11 @@ async fn libsql_duplicate_reservation_contention_serializes() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); let db_path = db_path.display().to_string(); - let first = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let first = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path).await, Duration::seconds(10), ); - let second = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let second = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path).await, Duration::seconds(10), ); @@ -86,7 +91,7 @@ async fn libsql_duplicate_reservation_contention_serializes() { async fn libsql_settled_entry_limit_prunes_oldest() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let ledger = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path.display().to_string()).await, Duration::seconds(10), ) @@ -98,7 +103,7 @@ async fn libsql_settled_entry_limit_prunes_oldest() { async fn libsql_settled_prune_interval_defers_until_interval() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let ledger = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path.display().to_string()).await, Duration::seconds(10), ) @@ -111,7 +116,7 @@ async fn libsql_settled_prune_interval_defers_until_interval() { async fn libsql_superseded_reservation_cannot_settle() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = RebornLibSqlIdempotencyLedger::with_in_flight_lease( + let ledger = RebornLibSqlIdempotencyLedger::with_root_lease( libsql_filesystem(&db_path.display().to_string()).await, Duration::seconds(10), ); @@ -122,8 +127,9 @@ async fn libsql_superseded_reservation_cannot_settle() { async fn libsql_settle_missing_reservation_returns_transient() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); - let ledger = - RebornLibSqlIdempotencyLedger::new(libsql_filesystem(&db_path.display().to_string()).await); + let ledger = RebornLibSqlIdempotencyLedger::new_root( + libsql_filesystem(&db_path.display().to_string()).await, + ); assert_settle_missing_reservation_returns_transient(&ledger, "libsql-missing-settle").await; } @@ -132,12 +138,12 @@ async fn libsql_custom_root_isolated_from_default_root() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); let filesystem = libsql_filesystem(&db_path.display().to_string()).await; - let custom = RebornLibSqlIdempotencyLedger::with_root( + let custom = RebornLibSqlIdempotencyLedger::with_virtual_root( Arc::clone(&filesystem), custom_root("libsql"), Duration::seconds(60), ); - let default = RebornLibSqlIdempotencyLedger::new(filesystem); + let default = RebornLibSqlIdempotencyLedger::new_root(filesystem); assert_custom_root_isolated_from_default_root(&custom, &default, "libsql-custom-root").await; } @@ -146,7 +152,7 @@ async fn libsql_actor_identity_is_part_of_fingerprint_path() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("workflow-ledger.db"); let db_path = db_path.display().to_string(); - let ledger = RebornLibSqlIdempotencyLedger::new(libsql_filesystem(&db_path).await); + let ledger = RebornLibSqlIdempotencyLedger::new_root(libsql_filesystem(&db_path).await); assert_actor_identity_is_part_of_fingerprint_path(&ledger, "libsql-actor-isolation").await; } @@ -155,8 +161,8 @@ async fn postgres_settled_action_survives_reopen_and_replays_when_configured() { let Some(filesystem) = postgres_filesystem().await else { return; }; - let ledger = RebornPostgresIdempotencyLedger::new(Arc::clone(&filesystem)); - let reopened = RebornPostgresIdempotencyLedger::new(filesystem); + let ledger = RebornPostgresIdempotencyLedger::new_root(Arc::clone(&filesystem)); + let reopened = RebornPostgresIdempotencyLedger::new_root(filesystem); assert_settled_action_survives_reopen_and_replays( &ledger, @@ -171,7 +177,7 @@ async fn postgres_in_flight_action_blocks_until_lease_expires_when_configured() return; }; let ledger = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(10)); + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(10)); assert_in_flight_action_blocks_until_lease_expires(&ledger, &unique_suffix("postgres-lease")) .await; @@ -182,7 +188,7 @@ async fn postgres_release_allows_retry_without_waiting_for_lease_when_configured return; }; let ledger = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(60)); + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(60)); assert_release_allows_retry_without_waiting_for_lease( &ledger, @@ -195,12 +201,12 @@ async fn postgres_duplicate_reservation_contention_serializes_when_configured() let Some(filesystem) = postgres_filesystem().await else { return; }; - let first = RebornPostgresIdempotencyLedger::with_in_flight_lease( + let first = RebornPostgresIdempotencyLedger::with_root_lease( Arc::clone(&filesystem), Duration::seconds(10), ); let second = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(10)); + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(10)); assert_duplicate_reservation_contention_serializes( &first, @@ -215,7 +221,7 @@ async fn postgres_settled_entry_limit_prunes_oldest_when_configured() { return; }; let ledger = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(10)) + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(10)) .with_settled_entry_limit(NonZeroUsize::new(1).expect("non-zero limit")); assert_settled_entry_limit_prunes_oldest(&ledger, &unique_suffix("postgres-retention")).await; @@ -226,7 +232,7 @@ async fn postgres_settled_prune_interval_defers_until_interval_when_configured() return; }; let ledger = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(10)) + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(10)) .with_settled_entry_limit(NonZeroUsize::new(1).expect("non-zero limit")) .with_settled_prune_interval(NonZeroUsize::new(3).expect("non-zero interval")); @@ -242,7 +248,7 @@ async fn postgres_superseded_reservation_cannot_settle_when_configured() { return; }; let ledger = - RebornPostgresIdempotencyLedger::with_in_flight_lease(filesystem, Duration::seconds(10)); + RebornPostgresIdempotencyLedger::with_root_lease(filesystem, Duration::seconds(10)); assert_superseded_reservation_cannot_settle(&ledger, &unique_suffix("postgres-superseded")) .await; @@ -252,7 +258,7 @@ async fn postgres_settle_missing_reservation_returns_transient_when_configured() let Some(filesystem) = postgres_filesystem().await else { return; }; - let ledger = RebornPostgresIdempotencyLedger::new(filesystem); + let ledger = RebornPostgresIdempotencyLedger::new_root(filesystem); assert_settle_missing_reservation_returns_transient( &ledger, @@ -265,12 +271,12 @@ async fn postgres_custom_root_isolated_from_default_root_when_configured() { let Some(filesystem) = postgres_filesystem().await else { return; }; - let custom = RebornPostgresIdempotencyLedger::with_root( + let custom = RebornPostgresIdempotencyLedger::with_virtual_root( Arc::clone(&filesystem), custom_root("postgres"), Duration::seconds(60), ); - let default = RebornPostgresIdempotencyLedger::new(filesystem); + let default = RebornPostgresIdempotencyLedger::new_root(filesystem); assert_custom_root_isolated_from_default_root( &custom, @@ -284,7 +290,7 @@ async fn postgres_actor_identity_is_part_of_fingerprint_path_when_configured() { let Some(filesystem) = postgres_filesystem().await else { return; }; - let ledger = RebornPostgresIdempotencyLedger::new(filesystem); + let ledger = RebornPostgresIdempotencyLedger::new_root(filesystem); assert_actor_identity_is_part_of_fingerprint_path( &ledger, diff --git a/crates/ironclaw_product/tests/extension_account_setup_contract.rs b/crates/ironclaw_product/tests/extension_account_setup_contract.rs index 198b2d6d85a..62e5e99dbc3 100644 --- a/crates/ironclaw_product/tests/extension_account_setup_contract.rs +++ b/crates/ironclaw_product/tests/extension_account_setup_contract.rs @@ -12,7 +12,7 @@ use ironclaw_product::{ }; use ironclaw_product_contracts::account_setup::{ AccountConnectionStatusError, AccountConnectionStatusSource, ChannelConnectionNoticePolicy, - ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, + ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, ExtensionAccountSetupReader, }; fn extension_id(value: &str) -> ExtensionId { diff --git a/crates/ironclaw_product/tests/product_surface_contract.rs b/crates/ironclaw_product/tests/product_surface_contract.rs index 8c2d7b5e1ce..5092af8a892 100644 --- a/crates/ironclaw_product/tests/product_surface_contract.rs +++ b/crates/ironclaw_product/tests/product_surface_contract.rs @@ -11,10 +11,11 @@ use async_trait::async_trait; use chrono::{Duration, Utc}; use ironclaw_auth::{AuthFlowId, CredentialAccountId}; use ironclaw_conversations::{ - ConversationBindingService as ConversationBindingPort, ExternalActorBindingEpoch, - InMemoryConversationServices, + ConversationBindingService as ConversationBindingPort, InMemoryConversationServices, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorBindingEpoch, ExternalActorRef, ExternalConversationRef, }; -use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem}; use ironclaw_host_api::turn::{ AcceptedMessageRef, EventCursor, LoopGateRef, RunProfileId, RunProfileVersion, TurnActor, @@ -37,12 +38,11 @@ use ironclaw_product::{ InMemoryIdempotencyLedger, InboundTurnOutcome, InboundTurnService, InboundUserMessageDispatch, ListPendingApprovalsRequest, ListPendingApprovalsResponse, ListPendingAuthInteractionsRequest, ListPendingAuthInteractionsResponse, PendingApprovalInteractionView, - PendingAuthInteractionView, ProductActorUserResolutionRequest, ProductActorUserResolver, - ProductConversationBindingService, ProductInstallationKey, ProductInstallationScope, - ProductSurfaceFailure, RebornFilesystemIdempotencyLedger, ResolveApprovalInteractionRequest, - ResolveApprovalInteractionResponse, ResolveAuthInteractionRequest, - ResolveAuthInteractionResponse, ResolveBindingRequest, ResolvedBinding, - ResolvedProductActorUser, StaticProductInstallationResolver, approval_gate_ref, + PendingAuthInteractionView, ProductConversationBindingService, ProductInstallationKey, + ProductInstallationScope, ProductSurfaceFailure, RebornFilesystemIdempotencyLedger, + ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse, + ResolveAuthInteractionRequest, ResolveAuthInteractionResponse, ResolveBindingRequest, + ResolvedBinding, StaticProductInstallationResolver, approval_gate_ref, }; use ironclaw_product::{ AdapterInstallationId, ApprovalDecision, ApprovalResolutionPayload, AuthRequirement, @@ -59,6 +59,9 @@ use ironclaw_product_contracts::action::{ ActionFingerprintKey, AuthRequestRef, LinkedThreadActionId, ProductCommandName, SourceBindingKey, }; +use ironclaw_product_contracts::actor_identity::{ + ProductActorUserResolutionRequest, ProductActorUserResolver, ResolvedProductActorUser, +}; use ironclaw_product_contracts::error::ProductOperationFailure; use ironclaw_product_contracts::subject_route::{ ProductConversationRouteKey, ProductConversationSubjectRouteResolutionRequest, @@ -6545,7 +6548,7 @@ impl ProductActorUserResolver for MutableProductActorUserResolver { async fn resolve_product_actor_user( &self, _request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { Ok(self .current .lock() @@ -6575,7 +6578,7 @@ impl ProductActorUserResolver for RecordingProductActorUserResolver { async fn resolve_product_actor_user( &self, request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { self.calls .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -6622,7 +6625,7 @@ impl ProductActorUserResolver for ReplacingProductActorUserResolver { async fn resolve_product_actor_user( &self, request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { let call = self.calls.fetch_add(1, Ordering::SeqCst); if request.external_actor_ref != self.actor_ref { return Ok(None); @@ -6671,7 +6674,7 @@ impl ProductActorUserResolver for RevokingProductActorUserResolver { async fn resolve_product_actor_user( &self, request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { + ) -> Result, ProductOperationFailure> { let call = self.calls.fetch_add(1, Ordering::SeqCst); if call == 0 && request.external_actor_ref == self.actor_ref { Ok(Some(ResolvedProductActorUser::new(self.user_id.clone()))) @@ -6853,8 +6856,8 @@ impl ProductActorUserResolver for FailingProductActorUserResolver { async fn resolve_product_actor_user( &self, _request: ProductActorUserResolutionRequest, - ) -> Result, ProductSurfaceFailure> { - Err(ProductSurfaceFailure::BindingResolutionFailed { + ) -> Result, ProductOperationFailure> { + Err(ProductOperationFailure::BindingResolutionFailed { reason: "actor resolver backend down".into(), }) } diff --git a/crates/ironclaw_product/tests/prompt_projection_contract.rs b/crates/ironclaw_product/tests/prompt_projection_contract.rs index f725e96e05b..ea770109f01 100644 --- a/crates/ironclaw_product/tests/prompt_projection_contract.rs +++ b/crates/ironclaw_product/tests/prompt_projection_contract.rs @@ -1,6 +1,9 @@ use std::sync::Mutex; use async_trait::async_trait; +use ironclaw_auth::product_prompt::{ + AuthChallengeProvider, AuthChallengeView, auth_prompt_view_for_blocked_auth, +}; use ironclaw_auth::{AuthProductError, AuthProviderId, OAuthAuthorizationUrl}; use ironclaw_host_api::turn::{TurnGateRef, TurnRunId, TurnScope}; use ironclaw_host_api::{ @@ -9,10 +12,7 @@ use ironclaw_host_api::{ ids::{ExtensionId, TenantId, ThreadId, UserId, VendorId}, }; use ironclaw_product::AuthPromptChallengeKind; -use ironclaw_product::{ - AuthChallengeProvider, AuthChallengeView, approval_prompt_lookup, - auth_prompt_view_for_blocked_auth, -}; +use ironclaw_product::approval_prompt_lookup; use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; #[derive(Debug)] diff --git a/crates/ironclaw_product/tests/reborn_services_contract.rs b/crates/ironclaw_product/tests/reborn_services_contract.rs index dabdfdd9c73..b65753b3496 100644 --- a/crates/ironclaw_product/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product/tests/reborn_services_contract.rs @@ -23,8 +23,8 @@ use ironclaw_approvals::{ }; use ironclaw_attachments::{InboundAttachmentLander, InboundAttachmentReader}; use ironclaw_auth::{ - AuthAccountLastError, AuthAccountState, CredentialAccountId, CredentialAccountProjection, - CredentialAccountStatus, + AuthAccountLastError, AuthAccountState, ChannelAuthAccountState, ChannelConnectionService, + CredentialAccountId, CredentialAccountProjection, CredentialAccountStatus, }; use ironclaw_extension_contracts::hosted_mcp::HostedMcpAuthSelection; use ironclaw_extension_contracts::{ @@ -62,21 +62,20 @@ use ironclaw_product::{ AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE, AUTOMATION_TRIGGER_THREAD_SOURCE_TAG, AUTOMATIONS_VIEW, ApprovalInteractionActionView, ApprovalInteractionDecision, ApprovalInteractionScope, ApprovalInteractionService, AuthInteractionDecision, AuthInteractionService, - AutomationListRequest, AutomationProductService, ChannelAuthAccountState, - ChannelConnectionRequirement, ChannelConnectionService, CommandResultView, - EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_SETUP_SUBMIT_CAPABILITY_ID, EXTENSION_SETUP_VIEW, - EXTENSIONS_VIEW, EmptyProductCommandInput, ExtensionCredentialSetupService, - ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, FS_LIST_VIEW, - FS_MOUNTS_VIEW, FS_STAT_VIEW, FilesystemBrowseReader, FsMount, GLOBAL_AUTO_APPROVE_VIEW, - LLM_ACTIVE_SET_CAPABILITY_ID, LLM_CONFIG_VIEW, LLM_PROVIDER_DELETE_CAPABILITY_ID, - LLM_PROVIDER_UPSERT_CAPABILITY_ID, LOGS_VIEW, LifecycleChannelDirections, - LifecycleExtensionCredentialRequirement, LifecycleExtensionCredentialSetup, - LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, - LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, LifecyclePackageKind, - LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, LifecycleProductResponse, - LifecycleReadinessBlocker, ListPendingApprovalsRequest, ListPendingApprovalsResponse, - ListPendingAuthInteractionsRequest, ListPendingAuthInteractionsResponse, - OPERATOR_CONFIG_KEY_VIEW, OPERATOR_CONFIG_LIST_VIEW, + AutomationListRequest, AutomationProductService, ChannelConnectionRequirement, + CommandResultView, EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_SETUP_SUBMIT_CAPABILITY_ID, + EXTENSION_SETUP_VIEW, EXTENSIONS_VIEW, EmptyProductCommandInput, + ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, + ExtensionCredentialSubmitRequest, FS_LIST_VIEW, FS_MOUNTS_VIEW, FS_STAT_VIEW, + FilesystemBrowseReader, FsMount, GLOBAL_AUTO_APPROVE_VIEW, LLM_ACTIVE_SET_CAPABILITY_ID, + LLM_CONFIG_VIEW, LLM_PROVIDER_DELETE_CAPABILITY_ID, LLM_PROVIDER_UPSERT_CAPABILITY_ID, + LOGS_VIEW, LifecycleChannelDirections, LifecycleExtensionCredentialRequirement, + LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, + LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, + LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, + LifecycleProductResponse, LifecycleReadinessBlocker, ListPendingApprovalsRequest, + ListPendingApprovalsResponse, ListPendingAuthInteractionsRequest, + ListPendingAuthInteractionsResponse, OPERATOR_CONFIG_KEY_VIEW, OPERATOR_CONFIG_LIST_VIEW, OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY_ID, OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY_ID, OPERATOR_CONFIG_VALIDATE_VIEW, OPERATOR_DIAGNOSTICS_VIEW, OPERATOR_LOGS_VIEW, OPERATOR_SETUP_RUN_CAPABILITY_ID, diff --git a/crates/ironclaw_product_contracts/src/account_setup.rs b/crates/ironclaw_product_contracts/src/account_setup.rs index 67d1ed3ac97..2e2b2d771ed 100644 --- a/crates/ironclaw_product_contracts/src/account_setup.rs +++ b/crates/ironclaw_product_contracts/src/account_setup.rs @@ -7,11 +7,12 @@ //! [`AccountConnectionStatusSource`]. The declaration registry itself is //! product-owned mutable state and stays in `ironclaw_product`; what lives //! here is the descriptor it stores, the sanitized error classes it reports, -//! and the probe port `ironclaw_extension_host` implements over its pairing -//! service. +//! the probe port `ironclaw_extension_host` implements over its pairing +//! service, and — since WS2.5 — [`ExtensionAccountSetupReader`], the registry's +//! two-method *read* surface the extension host consumes. //! //! Never here: the registry, activation preflight policy, or any -//! implementation of the port. +//! implementation of either port. use async_trait::async_trait; use ironclaw_host_api::{ @@ -106,6 +107,37 @@ pub enum ExtensionAccountSetupError { }, } +/// The read half of the product-owned account-setup registry, as the extension +/// host consumes it. +/// +/// The registry itself — single-assignment declarations plus connected status +/// sources, under a lock — is product-owned mutable state and stays in +/// `ironclaw_product`, exactly as this module's header says. What the extension +/// host needs is these two reads, and both speak only `host_api` + +/// this module's vocabulary, so the port is declarable here and the state is +/// not. Dependency inversion: declared below, implemented above +/// (`.claude/rules/type-placement.md`, traits §2). +/// +/// A caller with **no** reader wired behaves exactly as an empty registry +/// does: no descriptor, no missing requirement. That equivalence is why the +/// extension host holds an `Option>` +/// rather than needing a null implementation in this crate. +#[async_trait] +pub trait ExtensionAccountSetupReader: Send + Sync { + /// The declared setup descriptor for an extension, if one was declared. + fn descriptor(&self, extension_id: &ExtensionId) -> Option; + + /// The outstanding credential requirement for a user, and only when the + /// declared account is disconnected. Undeclared extensions have no account + /// gate; a declared extension whose host or status backend is unavailable + /// fails closed with an error. + async fn missing_requirement( + &self, + extension_id: &ExtensionId, + user_id: &UserId, + ) -> Result, ExtensionAccountSetupError>; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ironclaw_product_contracts/src/actor_identity.rs b/crates/ironclaw_product_contracts/src/actor_identity.rs new file mode 100644 index 00000000000..c8b3c6963df --- /dev/null +++ b/crates/ironclaw_product_contracts/src/actor_identity.rs @@ -0,0 +1,100 @@ +//! External-actor → Reborn user resolution. +//! +//! A channel surface knows only a protocol-shaped actor (`ExternalActorRef`); +//! which Reborn user that actor *is* depends on host-owned identity bindings +//! product does not read. So product asks a resolver wired beside it. +//! +//! The port is declared here and implemented by the extension host (PROPOSAL +//! §6.1.3) — the same shape as [`crate::subject_route`]. It became declarable +//! here once two things stopped blocking it: the error is no longer product's +//! workflow type (see [`crate::error::ProductOperationFailure`], WS2.2), and +//! the binding epoch its response carries is no longer +//! `ironclaw_conversations`' — it moved to +//! `ironclaw_extension_contracts::external`, beside the actor ref whose binding +//! it versions. + +use async_trait::async_trait; +use ironclaw_extension_contracts::external::{ExternalActorBindingEpoch, ExternalActorRef}; +use ironclaw_host_api::ids::UserId; +use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; + +use crate::error::ProductOperationFailure; + +/// Request passed to host-owned actor-to-user resolvers before the workflow +/// writes a conversation pairing. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProductActorUserResolutionRequest { + pub adapter_id: ProductAdapterId, + pub installation_id: AdapterInstallationId, + pub external_actor_ref: ExternalActorRef, +} + +impl ProductActorUserResolutionRequest { + pub fn new( + adapter_id: ProductAdapterId, + installation_id: AdapterInstallationId, + external_actor_ref: ExternalActorRef, + ) -> Self { + Self { + adapter_id, + installation_id, + external_actor_ref, + } + } +} + +/// The resolved user, plus the generation of the binding that resolved it. +/// +/// The epoch is what makes staleness detectable: a resolver whose binding was +/// re-issued answers with the same `user_id` and a *different* epoch, and the +/// default [`ProductActorUserResolver::resolved_product_actor_user_is_current`] +/// compares the whole value, so a re-pairing invalidates a cached resolution +/// even when the user did not change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProductActorUser { + pub user_id: UserId, + pub binding_epoch: Option, +} + +impl ResolvedProductActorUser { + pub fn new(user_id: UserId) -> Self { + Self { + user_id, + binding_epoch: None, + } + } + + pub fn with_binding_epoch(user_id: UserId, binding_epoch: ExternalActorBindingEpoch) -> Self { + Self { + user_id, + binding_epoch: Some(binding_epoch), + } + } +} + +/// Resolve the Reborn user an external actor is bound to. +/// +/// `Ok(None)` means "this actor is not bound" — a routing decision the caller +/// turns into a pairing prompt, never an error. +#[async_trait] +pub trait ProductActorUserResolver: Send + Sync { + async fn resolve_product_actor_user( + &self, + request: ProductActorUserResolutionRequest, + ) -> Result, ProductOperationFailure>; + + /// Whether a previously resolved actor→user binding is still the current + /// one. Implementations that keep a positive cache MUST bypass it here: + /// this is the revocation/freshness check, not the hot path. + async fn resolved_product_actor_user_is_current( + &self, + request: &ProductActorUserResolutionRequest, + expected: &ResolvedProductActorUser, + ) -> Result { + Ok(self + .resolve_product_actor_user(request.clone()) + .await? + .as_ref() + == Some(expected)) + } +} diff --git a/crates/ironclaw_product_contracts/src/admin_users.rs b/crates/ironclaw_product_contracts/src/admin_users.rs index 54fa003cbb5..ee721a9f3f6 100644 --- a/crates/ironclaw_product_contracts/src/admin_users.rs +++ b/crates/ironclaw_product_contracts/src/admin_users.rs @@ -2,13 +2,19 @@ //! (PROPOSAL §6.1.3). //! //! [`AdminUserService`] is a dependency-inversion port: its only production -//! implementation lives in `ironclaw_reborn_composition`, over the identity -//! user-directory and the per-user secret store. It was declared inside -//! `ironclaw_product` so product and WebUI would not have to depend on +//! implementation is `ironclaw_product`'s `RebornAdminUserDirectory`, over the +//! identity user-directory and the per-user secret store. It was declared +//! inside `ironclaw_product` so product and WebUI would not have to depend on //! `ironclaw_reborn_identity` — the right inversion in the wrong crate, since //! `ironclaw_extension_host` reads the same directory to resolve a channel //! actor's admin role and had to depend on product to do it. //! +//! [`AdminApiTokenMinter`] is the second port of the same pair, inverted the +//! other way: the adapter above *calls* it, and the implementation is the +//! binary's session-token minter. Declared here (WS6, 2026-08-04) so neither +//! the product adapter nor `ironclaw_reborn_cli` has to route the trait +//! through the composition root. +//! //! The `Reborn*` HTTP wire DTOs that wrap these records live here too, since //! the WS5 port inversion (PROPOSAL §6.1.3, "product wire DTO homes"): WS1.4 //! left them in product alongside the frozen surface inventory, but the @@ -124,7 +130,22 @@ pub const ADMIN_USER_LIST_DEFAULT_LIMIT: usize = 100; /// response (and the backing directory scan) by passing a huge `limit`. pub const ADMIN_USER_LIST_MAX_LIMIT: usize = 200; -/// Admin user-management operations. Implemented by the composition adapter +/// Mints a one-time API bearer for a newly created user. +/// +/// A dependency-inversion port for the same reason [`AdminUserService`] is +/// one: the implementation is a serve-layer concern (a `SignedTokenSessionStore` +/// over the operator secret, built in `ironclaw_reborn_cli`), while the caller +/// is the product-tier `AdminUserService` adapter. Declaring it here means +/// neither side has to name the other's crate, and the trait carries no +/// WebUI/ingress types — just the canonical identifiers and a `SecretString`. +#[async_trait] +pub trait AdminApiTokenMinter: Send + Sync { + /// Mint a bearer for `(tenant, user_id)`. On failure returns a short reason + /// (logged, never surfaced to the client). + async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result; +} + +/// Admin user-management operations. Implemented by the product-tier adapter /// over the identity user-directory + per-user secret store. /// /// Every method is tenant-scoped from the trusted caller (never a request diff --git a/crates/ironclaw_product_contracts/src/approval_prompt.rs b/crates/ironclaw_product_contracts/src/approval_prompt.rs new file mode 100644 index 00000000000..7a22d5a77ab --- /dev/null +++ b/crates/ironclaw_product_contracts/src/approval_prompt.rs @@ -0,0 +1,208 @@ +//! The store-free half of approval-prompt rendering. +//! +//! Three pure pieces every approval-prompt reader needs and none of which +//! requires the approval *store*: the gate-ref parse, the lookup scope, and the +//! projection of an [`ApprovalRequest`] onto the redacted +//! [`ApprovalPromptContextView`] the wire carries. +//! +//! **Why here.** Every input and output is `ironclaw_host_api` vocabulary plus +//! this crate's own view family, and there are two consumers that must not +//! import one another: `ironclaw_product`'s projection layer and +//! `ironclaw_extension_host`'s `ApprovalPromptContextSource` implementation +//! (`crate::prompt_source`). Before WS2.5 the extension host reached *up* into +//! `ironclaw_product::projection::approval_prompt_context_view` for exactly +//! this, and product carried the projection twice — once in `approval_prompt.rs` +//! and again in `projection/turn_events.rs`. One definition, here. +//! +//! Never here: the store read itself. `ApprovalRequestStorePort` lives in +//! `ironclaw_approvals` (kernel) and a contracts crate may not name it — which +//! is the boundary that decides the split: whoever holds the store does the +//! `get` and calls these three. + +use ironclaw_host_api::action::{Action, NetworkMethod, NetworkScheme}; +use ironclaw_host_api::approval::ApprovalRequest; +use ironclaw_host_api::ids::{ApprovalRequestId, InvocationId, UserId}; +use ironclaw_host_api::resource::ResourceScope; +use ironclaw_host_api::turn::{TurnGateRef, TurnScope}; + +use crate::outbound::{ + ApprovalPromptActionView, ApprovalPromptContextView, ApprovalPromptDestinationView, + ApprovalPromptDetailView, ApprovalPromptScopeView, +}; + +/// The gate-ref prefix every approval gate carries. +pub const APPROVAL_GATE_PREFIX: &str = "gate:approval-"; + +/// Whether a raw gate-ref string names an approval gate. +pub fn is_approval_gate_ref(gate_ref: &str) -> bool { + gate_ref.starts_with(APPROVAL_GATE_PREFIX) +} + +/// The approval request behind an approval gate ref, or `None` when the ref is +/// not an approval ref or its id does not parse. +/// +/// Deliberately `Option`, not `Result`: this crate has no approval-rejection +/// vocabulary, and both prompt readers already discard the reason. Callers that +/// need a typed rejection wrap it — `ironclaw_product`'s +/// `approval_request_id_from_gate_ref` does exactly that. +pub fn approval_request_id_from_gate_ref(gate_ref: &TurnGateRef) -> Option { + let value = gate_ref.as_str().strip_prefix(APPROVAL_GATE_PREFIX)?; + ApprovalRequestId::parse(value).ok() +} + +/// The resource scope an approval-prompt lookup reads under. +/// +/// An explicit turn owner (a shared/team subject) wins over the acting user, +/// matching `ApprovalInteractionScope::from_turn`; the equivalence of the two +/// is pinned in `ironclaw_product` +/// (`approval_prompt_lookup_scope_matches_the_interaction_scope_projection`). +pub fn approval_prompt_lookup_scope( + turn_scope: &TurnScope, + owner_user_id: &UserId, +) -> ResourceScope { + ResourceScope { + tenant_id: turn_scope.tenant_id.clone(), + user_id: turn_scope + .explicit_owner_user_id() + .cloned() + .unwrap_or_else(|| owner_user_id.clone()), + agent_id: turn_scope.agent_id.clone(), + project_id: turn_scope.project_id.clone(), + mission_id: None, + thread_id: Some(turn_scope.thread_id.clone()), + invocation_id: InvocationId::new(), + } +} + +pub fn approval_prompt_context_for_request( + request: &ApprovalRequest, +) -> Option { + let (tool_name, action, destination, details) = + approval_action_context(request.action.as_ref())?; + ApprovalPromptContextView::new( + tool_name, + action, + ApprovalPromptScopeView::new( + approval_scope_label(request), + request.reusable_scope.is_some(), + ) + .ok()?, + non_empty_string(&request.reason), + destination, + details, + ) + .ok() +} + +fn approval_action_context( + action: &Action, +) -> Option<( + String, + ApprovalPromptActionView, + Option, + Vec, +)> { + match action { + Action::Dispatch { + capability, + estimated_resources, + } => { + let mut details = vec![detail("Capability", capability.as_str())?]; + if let Some(bytes) = estimated_resources.network_egress_bytes { + details.push(detail("Estimated network egress", format_bytes(bytes))?); + } + Some(( + capability.as_str().to_string(), + ApprovalPromptActionView::new("Run tool", None).ok()?, + None, + details, + )) + } + Action::SpawnCapability { + capability, + estimated_resources, + } => { + let mut details = vec![detail("Capability", capability.as_str())?]; + if let Some(process_count) = estimated_resources.process_count { + details.push(detail("Processes", process_count.to_string())?); + } + Some(( + capability.as_str().to_string(), + ApprovalPromptActionView::new("Start tool", None).ok()?, + None, + details, + )) + } + Action::Network { + target, + method, + estimated_bytes, + } => { + let destination = + network_destination(method, target.scheme, &target.host, target.port)?; + let mut details = vec![detail("Method", method_label(method))?]; + if let Some(bytes) = estimated_bytes { + details.push(detail("Estimated transfer", format_bytes(*bytes))?); + } + Some(( + "builtin.http".to_string(), + ApprovalPromptActionView::new("Network request", Some(*method)).ok()?, + Some(destination), + details, + )) + } + _ => None, + } +} + +fn approval_scope_label(request: &ApprovalRequest) -> &'static str { + if request.reusable_scope.is_some() { + "Reusable grant" + } else { + "This request only" + } +} + +fn network_destination( + method: &NetworkMethod, + scheme: NetworkScheme, + host: &str, + port: Option, +) -> Option { + let scheme = match scheme { + NetworkScheme::Http => "http", + NetworkScheme::Https => "https", + }; + let authority = match port { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let url = format!("{scheme}://{authority}"); + ApprovalPromptDestinationView::new( + format!("{} {url}", method_label(method)), + Some(url), + Some(host.to_string()), + ) + .ok() +} + +fn detail(label: impl Into, value: impl Into) -> Option { + ApprovalPromptDetailView::new(label, value).ok() +} + +fn method_label(method: &NetworkMethod) -> String { + method.to_string().to_ascii_uppercase() +} + +fn format_bytes(bytes: u64) -> String { + format!("{bytes} bytes") +} + +fn non_empty_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/crates/ironclaw_product_contracts/src/lib.rs b/crates/ironclaw_product_contracts/src/lib.rs index 84fac641a4d..effca45b1f9 100644 --- a/crates/ironclaw_product_contracts/src/lib.rs +++ b/crates/ironclaw_product_contracts/src/lib.rs @@ -34,7 +34,9 @@ pub mod account_setup; pub mod action; +pub mod actor_identity; pub mod admin_users; +pub mod approval_prompt; pub mod channel_config; pub mod command; pub mod delivery; diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 9b7c20bdace..7c8a128e1b7 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -70,7 +70,7 @@ struct SignedSessionTokenMinter { } #[async_trait::async_trait] -impl ironclaw_reborn_composition::AdminApiTokenMinter for SignedSessionTokenMinter { +impl ironclaw_product_contracts::admin_users::AdminApiTokenMinter for SignedSessionTokenMinter { async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result { // `false`: this session is for the admin-created `user_id`, not the // operator. Stamping `true` would let any admin-created user (even diff --git a/crates/ironclaw_reborn_cli/src/first_party/gsuite.rs b/crates/ironclaw_reborn_cli/src/first_party/gsuite.rs index bef1086ecd0..311d2ca2117 100644 --- a/crates/ironclaw_reborn_cli/src/first_party/gsuite.rs +++ b/crates/ironclaw_reborn_cli/src/first_party/gsuite.rs @@ -285,12 +285,13 @@ impl RuntimeCredentialAccountVisibilityPolicy for GsuiteRuntimeCredentialAccount #[cfg(test)] mod tests { - use ironclaw_extension_support::GMAIL_LIST_MESSAGES_CAPABILITY_ID; - use ironclaw_reborn_composition::{ + use ironclaw_auth::{ AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountId, CredentialAccountLabel, - CredentialAccountStatus, CredentialOwnership, RuntimeDispatchErrorKind, Timestamp, - host_api::{InvocationId, ResourceScope, UserId}, + CredentialAccountStatus, CredentialOwnership, Timestamp, }; + use ironclaw_extension_support::GMAIL_LIST_MESSAGES_CAPABILITY_ID; + use ironclaw_host_api::dispatch::RuntimeDispatchErrorKind; + use ironclaw_reborn_composition::host_api::{InvocationId, ResourceScope, UserId}; use super::*; diff --git a/crates/ironclaw_reborn_cli/src/runtime/native_extensions.rs b/crates/ironclaw_reborn_cli/src/runtime/native_extensions.rs index 6dd084c2dbb..9428fd8624a 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/native_extensions.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/native_extensions.rs @@ -10,6 +10,7 @@ use ironclaw_extension_host::{ BindContext, BindError, ExtensionBindings, ExtensionEntrypoint, LoadContext, NativeExtensionFactory, }; +use ironclaw_host_api::ids::ExtensionId; use ironclaw_reborn_composition::ChannelExtensionBinding; use ironclaw_telegram_extension::{TelegramChannelAdapter, TelegramPreferenceTargetCodec}; @@ -26,14 +27,14 @@ pub(crate) fn bundled_native_extension_factories() -> Vec Vec { vec![ ChannelExtensionBinding { - extension_id: "slack".to_string(), + extension_id: ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, )), }, ChannelExtensionBinding { - extension_id: "telegram".to_string(), + extension_id: ExtensionId::from_trusted("telegram".to_string()), adapter: Arc::new(TelegramChannelAdapter::default()), preference_target_codec: Some(Arc::new(TelegramPreferenceTargetCodec)), }, @@ -122,12 +123,12 @@ mod tests { let bindings = bundled_channel_extension_bindings(); let slack = bindings .iter() - .find(|binding| binding.extension_id == "slack") + .find(|binding| binding.extension_id.as_str() == "slack") .expect("the binary supplies the slack channel binding"); assert!(slack.preference_target_codec.is_some()); let telegram = bindings .iter() - .find(|binding| binding.extension_id == "telegram") + .find(|binding| binding.extension_id.as_str() == "telegram") .expect("the binary supplies the telegram deployment channel binding"); assert!( telegram.preference_target_codec.is_some(), @@ -140,7 +141,7 @@ mod tests { let bindings = bundled_channel_extension_bindings(); let telegram = bindings .iter() - .find(|binding| binding.extension_id == "telegram") + .find(|binding| binding.extension_id.as_str() == "telegram") .expect("the binary supplies the Telegram deployment channel binding"); let config = vec![( TELEGRAM_BOT_USERNAME_CONFIG.to_string(), @@ -185,7 +186,7 @@ mod tests { let outcome = telegram .adapter .inbound(VerifiedInbound { - extension_id: &telegram.extension_id, + extension_id: telegram.extension_id.as_str(), installation_id: "install_test", config: &config, body: &body, @@ -199,7 +200,7 @@ mod tests { let message = messages.remove(0); assert!( sink.admit(InboundAdmission { - extension_id: telegram.extension_id.clone(), + extension_id: telegram.extension_id.as_str().to_string(), installation_id: "install_test".to_string(), message, channel_adapter: Arc::clone(&telegram.adapter), diff --git a/crates/ironclaw_reborn_composition/src/admin_secrets.rs b/crates/ironclaw_reborn_composition/src/admin_secrets.rs index 589303744a3..2ea928e1102 100644 --- a/crates/ironclaw_reborn_composition/src/admin_secrets.rs +++ b/crates/ironclaw_reborn_composition/src/admin_secrets.rs @@ -1,4 +1,5 @@ -//! Admin-scoped per-user secret provisioning. +//! Admin-scoped per-user secret provisioning — the *deployment* half of +//! `ironclaw_product::AdminSecretProvisioner`. //! //! The `ironclaw_secrets` store isolates tenant/user by the caller's //! `MountView`, not the `ResourceScope` argument (`secret_owner_alias` only @@ -22,37 +23,11 @@ use ironclaw_host_api::{ ids::{InvocationId, SecretHandle, TenantId, UserId}, resource::ResourceScope, }; +use ironclaw_product::AdminSecretProvisioner; use ironclaw_secrets::{ SecretMaterial, SecretMetadata, SecretStore, SecretStoreError, SecretStorePort, SecretsCrypto, }; -/// Admin provisioning of per-user secrets for an arbitrary target `(tenant, -/// user)`. Implemented over the filesystem secret substrate; a `dyn` port so -/// the runtime can retain it without carrying the backend generic. -#[async_trait] -pub(crate) trait AdminSecretProvisioner: Send + Sync { - async fn list( - &self, - tenant: &TenantId, - user: &UserId, - ) -> Result, SecretStoreError>; - - async fn put( - &self, - tenant: &TenantId, - user: &UserId, - handle: SecretHandle, - material: SecretMaterial, - ) -> Result; - - async fn delete( - &self, - tenant: &TenantId, - user: &UserId, - handle: &SecretHandle, - ) -> Result; -} - /// Filesystem-backed admin secret provisioner: holds the shared raw root + the /// shared crypto and mints a per-target-user store per call. pub(crate) struct FilesystemAdminSecretProvisioner diff --git a/crates/ironclaw_reborn_composition/src/admin_token.rs b/crates/ironclaw_reborn_composition/src/admin_token.rs deleted file mode 100644 index be3f4be860d..00000000000 --- a/crates/ironclaw_reborn_composition/src/admin_token.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Admin API token minting port. -//! -//! Kept in its own module so it can appear in the `runtime.product_surface` -//! signature. The trait carries no WebUI/ingress types — just the canonical -//! identifiers and a `SecretString` — so it is dependency-free. - -use ironclaw_host_api::ids::{TenantId, UserId}; -use secrecy::SecretString; - -/// Mints a one-time API bearer for a newly created user. Implemented at the -/// serve layer over the session store (a `SignedTokenSessionStore` is stateless -/// and deterministic from the operator secret, so it can be built independently -/// of the ingress auth surface). Abstracted here so composition needs no -/// dependency on the ingress crate. -#[async_trait::async_trait] -pub trait AdminApiTokenMinter: Send + Sync { - /// Mint a bearer for `(tenant, user_id)`. On failure returns a short reason - /// (logged, never surfaced to the client). - async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result; -} - -/// Fail-closed placeholder for composition paths that need an -/// [`AdminUserService`](ironclaw_product_contracts::admin_users::AdminUserService) handle purely for -/// tenant-scoped role reads (channel-command admission's `get_user` calls, -/// which never mint tokens) rather than the WebUI admin `create_user` route. -/// `RebornAdminUserDirectory::create_user` is the sole caller of the minter; -/// this always denies it rather than silently succeeding without a -/// configured minter. -pub(crate) struct RejectingAdminApiTokenMinter; - -#[async_trait::async_trait] -impl AdminApiTokenMinter for RejectingAdminApiTokenMinter { - async fn mint(&self, _tenant: &TenantId, _user_id: &UserId) -> Result { - Err("admin API token minting is not configured for this composition path".to_string()) - } -} diff --git a/crates/ironclaw_reborn_composition/src/automation/trigger_poller.rs b/crates/ironclaw_reborn_composition/src/automation/trigger_poller.rs index 0ced3dbc14b..a4f37689787 100644 --- a/crates/ironclaw_reborn_composition/src/automation/trigger_poller.rs +++ b/crates/ironclaw_reborn_composition/src/automation/trigger_poller.rs @@ -21,7 +21,7 @@ pub(crate) use crate::automation::trigger_poller_trusted_submit::ConversationCon #[cfg(any(test, feature = "test-support"))] pub(crate) use crate::automation::trigger_poller_trusted_submit::TenantScopedTrustedTriggerFireAuthorizer; use crate::runtime_input::TriggerPollerSettings; -pub use ironclaw_extension_host::channel_triggered_delivery::PostSubmitDeliveryHook; +pub(crate) use ironclaw_extension_host::channel_triggered_delivery::PostSubmitDeliveryHook; mod active_run_lookup; pub(crate) use active_run_lookup::{ diff --git a/crates/ironclaw_reborn_composition/src/builtin_capability_policy.rs b/crates/ironclaw_reborn_composition/src/builtin_capability_policy.rs index f8f08e07508..8eb4fa31df3 100644 --- a/crates/ironclaw_reborn_composition/src/builtin_capability_policy.rs +++ b/crates/ironclaw_reborn_composition/src/builtin_capability_policy.rs @@ -14,7 +14,7 @@ use ironclaw_host_api::{ use serde::Deserialize; use thiserror::Error; -use crate::runtime_profile_approval_policy::RuntimeProfileApprovalGateEffectSets; +use ironclaw_approvals::RuntimeProfileApprovalGateEffectSets; const BUILTIN_CAPABILITY_POLICY_TOML: &str = include_str!("builtin_capability_policy.toml"); diff --git a/crates/ironclaw_reborn_composition/src/capability_authorization.rs b/crates/ironclaw_reborn_composition/src/capability_authorization.rs index b2fa3c34bbe..68f8b4f27e8 100644 --- a/crates/ironclaw_reborn_composition/src/capability_authorization.rs +++ b/crates/ironclaw_reborn_composition/src/capability_authorization.rs @@ -11,8 +11,10 @@ use std::{ use async_trait::async_trait; use ironclaw_approvals::{ - AutoApproveSettingKey, PersistentApprovalAction, PersistentApprovalPolicyKey, - PersistentApprovalScope, ToolPermissionOverride, ToolPermissionOverrideKey, + ApprovalSettingsProvider, AutoApproveSettingKey, PersistentApprovalAction, + PersistentApprovalPolicyKey, PersistentApprovalScope, ProfileApprovalGatePolicy, + RuntimeProfileApprovalGatePolicy, ToolPermissionOverride, ToolPermissionOverrideKey, + profile_approval_authorizer, }; use ironclaw_authorization::TrustAwareCapabilityDispatchAuthorizer; use ironclaw_host_api::{ @@ -26,12 +28,6 @@ use ironclaw_runtime_policy::MinimalApprovalBypass; use tokio::sync::Notify; use crate::builtin_capability_policy::BuiltinCapabilityPolicy; -use crate::{ - profile_approval_authorization::{ - ApprovalSettingsProvider, ProfileApprovalGatePolicy, profile_approval_authorizer, - }, - runtime_profile_approval_policy::RuntimeProfileApprovalGatePolicy, -}; pub(crate) fn capability_authorizer( runtime_policy: Option<&EffectiveRuntimePolicy>, diff --git a/crates/ironclaw_reborn_composition/src/capability_authorization/tests.rs b/crates/ironclaw_reborn_composition/src/capability_authorization/tests.rs index 00e6add836b..49f286b5b88 100644 --- a/crates/ironclaw_reborn_composition/src/capability_authorization/tests.rs +++ b/crates/ironclaw_reborn_composition/src/capability_authorization/tests.rs @@ -465,7 +465,7 @@ async fn trace_commons_authorize_decision( let authorizer = capability_authorizer( None, policy, - Arc::new(crate::profile_approval_authorization::EmptyApprovalSettingsProvider), + Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), ); authorizer .authorize_dispatch_with_trust( @@ -559,7 +559,7 @@ async fn native_memory_manifest_authorize_decision( let authorizer = capability_authorizer( None, policy, - Arc::new(crate::profile_approval_authorization::EmptyApprovalSettingsProvider), + Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), ); authorizer .authorize_dispatch_with_trust( diff --git a/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs b/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs index 914431cf47f..fc7cf2e63bf 100644 --- a/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use ironclaw_attachments::InboundAttachmentLander; +use ironclaw_auth::product_prompt::{AuthChallengeProvider, BlockedAuthFlowCanceller}; use ironclaw_extension_contracts::extension::ExtensionHostAssemblyConfig; use ironclaw_extensions::ExtensionInstallationStorePort; use ironclaw_filesystem::{CompositeRootFilesystem, RootFilesystem}; @@ -11,9 +12,8 @@ use ironclaw_host_api::{ }; use ironclaw_host_runtime::{ExtensionLaneToolBinder, HostRuntimeHttpEgressPort}; use ironclaw_product::{ - ApprovalInteractionService, AuthChallengeProvider, AuthInteractionService, - BlockedAuthFlowCanceller, ExtensionAccountSetupRegistry, ProjectFilesystemReader, - RunDeliverySettings, + ApprovalInteractionService, AuthInteractionService, ExtensionAccountSetupRegistry, + ProjectFilesystemReader, RunDeliverySettings, }; use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; use ironclaw_product_contracts::prompt_source::{ @@ -197,7 +197,7 @@ pub(crate) struct BackendChannelPairingAssemblyInput { pub(crate) account_status_reader: Arc, pub(crate) disconnect_slot: - Arc>>, + Arc>>, } pub(crate) async fn build_backend_channel_pairing( @@ -433,10 +433,10 @@ pub(crate) fn channel_admin_users( identity.agent_id.clone(), identity.project_id.clone(), ); - Arc::new(crate::admin_user_directory::RebornAdminUserDirectory::new( + Arc::new(ironclaw_product::RebornAdminUserDirectory::new( directory, Arc::clone(&services.admin_secret_provisioner), - Arc::new(crate::admin_token::RejectingAdminApiTokenMinter), + Arc::new(ironclaw_product::RejectingAdminApiTokenMinter), )) } diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 01b2816f9e2..ff66390fac3 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -331,7 +331,7 @@ pub(crate) struct RebornRuntimeStores { pub(crate) channel_dm_target_store: Arc, pub(crate) channel_disconnect_slot: - Arc>>, + Arc>>, pub(crate) runtime_http_egress: Option>, pub(crate) ironhub_link_state: Arc, pub(crate) skill_mounts: MountView, @@ -366,7 +366,7 @@ pub(crate) struct RebornRuntimeStores { pub(crate) broadcast_budget_event_sink: Arc, pub(crate) event_log: Arc, pub(crate) audit_log: Arc, - pub(crate) admin_secret_provisioner: Arc, + pub(crate) admin_secret_provisioner: Arc, pub(crate) project_service: Arc, pub(crate) trigger_conversation_services: RebornFilesystemConversationServices, /// Pre-minted scheduler wake wiring for the production composition path. diff --git a/crates/ironclaw_reborn_composition/src/factory/auth_engine_assembly.rs b/crates/ironclaw_reborn_composition/src/factory/auth_engine_assembly.rs index c4df6962368..52f5da7fe3d 100644 --- a/crates/ironclaw_reborn_composition/src/factory/auth_engine_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/factory/auth_engine_assembly.rs @@ -584,7 +584,7 @@ pub(crate) fn auth_continuation_dispatcher( // provider-blocked runs (pair/authorize once, all waiting chats // continue). Production-shaped builders pass None until their // turn-state snapshot source is wired. - Some(gate_source) => Arc::new(crate::blocked_auth_resume::BlockedAuthResumeFanout::new( + Some(gate_source) => Arc::new(ironclaw_product::BlockedAuthResumeFanout::new( single_run, gate_source, turn_coordinator, diff --git a/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs b/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs index 225f89fce5f..38d92a7807a 100644 --- a/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs @@ -530,7 +530,7 @@ pub(super) async fn build_backend_production( .await?; let event_log = Arc::clone(&event_stores.events); let audit_log = Arc::clone(&event_stores.audit); - let admin_secret_provisioner: Arc = + let admin_secret_provisioner: Arc = Arc::new(crate::admin_secrets::FilesystemAdminSecretProvisioner::new( Arc::clone(&stores.filesystem), Arc::clone(&stores.secret_credentials.crypto), @@ -866,7 +866,7 @@ pub(super) async fn build_backend_production( .filter_map(|manifest| { channel_extension_bindings .iter() - .find(|binding| binding.extension_id == manifest.id.as_str()) + .find(|binding| binding.extension_id == manifest.id) .map(|binding| { ironclaw_extension_host::DeploymentChannelBinding::new( Arc::clone(manifest), @@ -931,7 +931,7 @@ pub(super) async fn build_backend_production( ); let account_setups = ExtensionAccountSetupRegistry::default(); let channel_disconnect_slot: Arc< - std::sync::OnceLock>, + std::sync::OnceLock>, > = Arc::new(std::sync::OnceLock::new()); let extension_management = Arc::new( RebornLocalExtensionManagementPort::new( @@ -957,7 +957,7 @@ pub(super) async fn build_backend_production( }, }, ) - .with_account_setup_registry(account_setups.clone()) + .with_account_setup_registry(Arc::new(account_setups.clone())) .with_removal_cleanup_registry(removal_cleanup) .with_provider_instance_readiness(provider_instance_readiness) .with_channel_disconnect_slot(Arc::clone(&channel_disconnect_slot)), diff --git a/crates/ironclaw_reborn_composition/src/factory/test_support.rs b/crates/ironclaw_reborn_composition/src/factory/test_support.rs index 1bd1b7af73d..d50a7bef538 100644 --- a/crates/ironclaw_reborn_composition/src/factory/test_support.rs +++ b/crates/ironclaw_reborn_composition/src/factory/test_support.rs @@ -124,7 +124,7 @@ impl RebornRuntimeStores { #[cfg(any(test, feature = "test-support"))] pub(crate) fn channel_disconnect_slot_for_test( &self, - ) -> &Arc>> { + ) -> &Arc>> { &self.channel_disconnect_slot } diff --git a/crates/ironclaw_reborn_composition/src/factory/tests.rs b/crates/ironclaw_reborn_composition/src/factory/tests.rs index 6ecd1f17ed6..0b886b085b4 100644 --- a/crates/ironclaw_reborn_composition/src/factory/tests.rs +++ b/crates/ironclaw_reborn_composition/src/factory/tests.rs @@ -306,7 +306,7 @@ impl ConversationActorPairingService for FailingConversationActorPairingService _adapter_installation_id: AdapterInstallationId, _external_actor_ref: ExternalActorRef, _user_id: UserId, - _binding_epoch: ironclaw_conversations::ExternalActorBindingEpoch, + _binding_epoch: ironclaw_extension_contracts::external::ExternalActorBindingEpoch, ) -> Result<(), ironclaw_conversations::InboundTurnError> { Err(ironclaw_conversations::InboundTurnError::DurableState { reason: "raw durable store error".to_string(), diff --git a/crates/ironclaw_reborn_composition/src/input.rs b/crates/ironclaw_reborn_composition/src/input.rs index 460746c39b2..c9e94025486 100644 --- a/crates/ironclaw_reborn_composition/src/input.rs +++ b/crates/ironclaw_reborn_composition/src/input.rs @@ -236,7 +236,12 @@ pub struct RebornHostBindings { #[derive(Clone)] pub struct ChannelExtensionBinding { /// The extension id the manifest declares (also the adapter id). - pub extension_id: String, + /// + /// Typed: this is the product identity newtype + /// (`ironclaw_host_api::ids::ExtensionId`), not the transparent + /// `ironclaw_hooks::identity::ExtensionId` — the two coexist by design and + /// resolve by crate, never by name (see `ironclaw_hooks/src/identity.rs`). + pub extension_id: ironclaw_host_api::ids::ExtensionId, /// The channel adapter implementation linked into the deployment. pub adapter: std::sync::Arc, /// The vendor half of the preference-target codec, consumed by the @@ -1096,7 +1101,7 @@ fn resolve_production_runtime_policy( reason: format!("invalid [policy].default_profile `{default_profile}`: {error}"), } })?; - crate::resolve_runtime_policy(crate::RuntimePolicyResolveRequest::new( + ironclaw_runtime_policy::resolve(ironclaw_runtime_policy::ResolveRequest::new( deployment, requested_profile, )) diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index a2527bf3217..dc8f12f6a16 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -18,13 +18,10 @@ use std::sync::Arc; mod admin_secrets; -mod admin_token; -mod admin_user_directory; #[cfg(test)] mod approval_test_support; mod automation; mod backend_store_assembly; -mod blocked_auth_resume; mod builtin_capability_policy; mod capability_authorization; #[cfg(test)] @@ -48,17 +45,14 @@ mod operator_secret_store; mod operator_tool_catalog; mod outbound; mod outbound_store_assembly; -mod process_gate_turn_view; mod product_capability; mod product_surface; mod production_runtime_policy; -mod profile_approval_authorization; mod readiness; mod root; mod runtime; mod runtime_input; mod runtime_mounts; -mod runtime_profile_approval_policy; mod standalone_bootstrap_assembly; mod storage_catalog; mod support; @@ -67,128 +61,110 @@ pub mod test_support; mod trigger_fire_access; mod trigger_poller_assembly; -pub use admin_token::AdminApiTokenMinter; +// The public re-export wall — a *documented* surface (PROPOSAL §6.10, CHECKLIST WS6 +// "`RebornRuntime` slimmed"). An entry earns its place only when the consumer cannot +// reach the symbol at its owner: no dependency on that crate (the app tier has none by +// design), or a private home module here. Anything importable from its owner must be. +// `pinned by` = the test that fails if the entry goes, else the build that does. Gated by +// `reborn_composition_boundaries.rs`: `..._surface_matches_snapshot` pins the set, +// `..._entries_name_their_consumer` pins these annotations. +// consumer: `ironclaw_conversations::inbound`, `tests/integration/support/triggered_submit.rs` · pinned by: `ironclaw_conversations/tests/inbound_contract.rs` pub use automation::conversation_turn_submitter::conversation_turn_submitter; -pub use automation::trigger_poller::PostSubmitDeliveryHook; +// consumer: every `build_*` caller in the app tier and the test tiers · pinned by: `composition/tests/service_factory.rs` pub use error::RebornBuildError; +// consumer: `tests/integration/support/harness` recorder · pinned by: `tests/integration/support/harness/recorder.rs` #[cfg(feature = "test-support")] pub use factory::AttachmentTestSupport; +// consumer: root integration harness · pinned by: `tests/integration/extension_delivery.rs` #[cfg(feature = "test-support")] pub use factory::ChannelHostAssemblyTestWiring; +// consumer: root integration harness · pinned by: `tests/integration/support/harness/mod.rs` #[cfg(feature = "test-support")] pub use factory::RebornApprovalTestParts; +// consumer: `ironclaw_reborn_cli` onboard + runtime + status · pinned by: `ironclaw_reborn_cli/tests/smoke.rs` pub use factory::STANDALONE_SECRETS_MASTER_KEY_PATH; -/// Crate-root alias for composition's own unit tests (the src `#[cfg(test)]` -/// modules that build a production trust policy from the concrete inventory). +/// Crate-root alias for composition's own `#[cfg(test)]` trust-policy builders. #[cfg(test)] pub(crate) use factory::builtin_first_party_trust_policy; +// consumer: `ironclaw_reborn_cli` config/set + onboard + runtime · pinned by: `ironclaw_reborn_cli/tests/smoke.rs` pub use factory::open_standalone_secret_store; -/// Production first-party trust-policy builder over the neutral injected bundle -/// set. Public so integration tests (which convert the concrete first-party -/// inventory via the dev-dependency) can build the same trust policy the -/// production binary composes at build time. +/// Production first-party trust-policy builder over the neutral injected bundle set, +/// public so integration tests build the same policy the binary composes. +// consumer: composition's own contract tests · pinned by: `composition/tests/support/first_party.rs` pub use factory::production_first_party_trust_policy; +// consumer: `ironclaw_reborn_cli` onboard/master_key · pinned by: `ironclaw_reborn_cli` build (the outcome is the fn's return type; `factory` is private) pub use factory::{KeychainMasterKeyOutcome, provision_standalone_keychain_master_key}; +// consumer: `ironclaw_reborn_cli` status + runtime · pinned by: `ironclaw_reborn_cli` build pub use filesystem_assembly::standalone_db_path; +// consumer: `ironclaw_reborn_cli` config/set · pinned by: `ironclaw_reborn_cli` build (the error is the store's; module is private) pub use google_oauth_secret_store::{GoogleOauthSecretStore, GoogleOauthSecretStoreError}; +// consumer: `ironclaw_reborn_cli` serve/runtime/native_extensions, `harness/latency/runner` · pinned by: `composition/tests/admin_api_e2e.rs` pub use input::{ ChannelExtensionBinding, OAuthClientConfig, RebornHostBindings, RebornRuntimeProcessBinding, }; -/// OAuth redirect-URI newtype re-exported for runtime input construction; the -/// remaining product-auth contracts are named directly from `ironclaw_auth`. -pub use ironclaw_auth::OAuthRedirectUri; -#[cfg(any(test, feature = "test-support"))] -pub use ironclaw_auth::{ - AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountId, CredentialAccountLabel, - CredentialAccountStatus, CredentialOwnership, Timestamp, -}; -pub use ironclaw_auth::{CredentialAccount, CredentialAccountSelectionRequest}; -pub use ironclaw_host_api::{ - action::{NetworkScheme, NetworkTargetPattern}, - capability::{RuntimeCredentialRequirement, RuntimeCredentialRequirementSource}, - dispatch::RuntimeDispatchErrorKind, - error::HostApiError, - http::RuntimeCredentialTarget, - ids::{CapabilityId, SecretHandle}, -}; -pub use ironclaw_host_api::{ - capability::RuntimeCredentialAccountSetup, - decision::RuntimeCredentialAuthRequirement, - ids::{ExtensionId, VendorId}, -}; -pub use ironclaw_host_runtime::{ - FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, - FirstPartyCapabilityRequest, FirstPartyCapabilityResult, ProductAuthProviderRuntimePorts, -}; -/// The channel-adapter contract the assembling binary implements is reached at -/// its owner, `ironclaw_extension_contracts::channel_adapter` — WS1.4 deleted -/// the re-export chain that gave it a second import path through here. -pub use ironclaw_product::RebornChannelConnectStrategy; -pub use ironclaw_product::{ - LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleProductPayload, - LifecycleProductResponse, LifecycleSearchExtensionSummary, -}; -pub use ironclaw_product_contracts::account_setup::{ - ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, -}; -pub use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; -pub use ironclaw_runner::failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; +// WS1.4 deleted the `extension_contracts::channel_adapter` second import path; WS6 did +// the same for the `auth`/`host_api`/`host_runtime`/`product_contracts`/`failure_lane`/ +// `runtime_policy`/`triggers`/`provider_identity` pass-throughs. +// consumer: `ironclaw_reborn_cli` extension command (no `ironclaw_product` dep) · pinned by: `ironclaw_reborn_cli` build +pub use ironclaw_product::LifecycleProductResponse; +// consumer: `ironclaw_reborn_cli` runtime (no `ironclaw_runner` dep) · pinned by: `ironclaw_reborn_cli` build pub use ironclaw_runner::runtime::DEFAULT_TURN_RUNNER_WORKER_COUNT; -pub use ironclaw_runtime_policy::{ - ResolveRequest as RuntimePolicyResolveRequest, resolve as resolve_runtime_policy, -}; +// consumer: `ironclaw_reborn_cli` skills command (no `ironclaw_skills` dep) · pinned by: `ironclaw_reborn_cli` build pub use ironclaw_skills::{ - ManagedSkillSource as RebornSkillSource, SkillSummary as RebornSkillSummary, - skill_summary_json as reborn_skill_summary_json, + SkillSummary as RebornSkillSummary, skill_summary_json as reborn_skill_summary_json, }; -pub use ironclaw_triggers::TriggerId; +// consumer: `ironclaw_reborn_cli` runtime (no `ironclaw_turns` dep) · pinned by: `ironclaw_reborn_cli` build pub use ironclaw_turns::TurnStatus; +// consumer: `ironclaw_reborn_cli` serve wiring · pinned by: `ironclaw_reborn_cli` build pub use llm_admin::openai_compat_serve::build_openai_compat_route_mount; +// consumer: `ironclaw_reborn_cli` runtime · pinned by: `composition/tests/memory_mem0_swap.rs` pub use memory_binding::{memory_binding_diagnostics, resolve_memory_binding_policy}; +// consumer: `ironclaw_reborn_cli` runtime, `tests/integration/group_memory` · pinned by: `composition/tests/memory_mem0_swap.rs` (`MemoryLifecycleConsumers` is the fn's return type) pub use memory_provider_factory::{ Mem0ConnectionConfig, MemoryLifecycleConsumers, MemoryProviderDeps, ResolvedMemoryProvider, - create_provider, memory_lifecycle_consumers, resolve_memory_provider, + memory_lifecycle_consumers, resolve_memory_provider, }; +// consumer: composition's operator LLM-key wiring test · pinned by: `composition/tests/operator_llm_key_store_wiring.rs` pub use operator_secret_store::RuntimeOperatorSecretValueStore; -// Re-exported for the host-owned `ironclaw_webui::webui_v2_app` -// (hoisted up from this crate): its bearer-auth middleware mints tenant-scoped -// verified-bearer evidence for protected OpenAI-compatible mounts. Ingress must -// not depend on `ironclaw_product` directly (architecture boundary), so -// it reaches this helper through composition's facade. +// consumer: `ironclaw_reborn_cli` serve + runtime, `harness/latency/runner`, root QA suites · pinned by: `composition/tests/profile_acceptance.rs` +// (`RebornRuntimeProfileError` left: `deployment` is a `pub mod`, so it stays nameable there.) pub use deployment::{ - RebornRuntimeProfileError, RebornRuntimeProfileOptions, hosted_single_tenant_runtime_policy, + RebornRuntimeProfileOptions, hosted_single_tenant_runtime_policy, hosted_single_tenant_volume_runtime_policy, local_runtime_build_input, local_runtime_build_input_with_options, standalone_runtime_policy, standalone_unrestricted_runtime_policy, }; +// consumer: `ironclaw_product/tests/support/planned_agent_loop.rs`, root integration harness · pinned by: `composition/tests/budget_e2e.rs` #[cfg(any(test, feature = "test-support"))] -pub use deployment::{local_filesystem_build_input, local_filesystem_build_input_with_profile}; -pub use ironclaw_extension_host::provider_identity::ProviderIdentityActorResolver; -pub use ironclaw_host_api::user_identity::{ - RebornIdentityProviderId, RebornIdentityProviderUserId, RebornUserIdentityBinding, - RebornUserIdentityBindingDeleteStore, RebornUserIdentityBindingError, - RebornUserIdentityBindingStore, RebornUserIdentityLookup, RebornUserIdentityLookupError, - installation_scoped_provider_user_id, -}; +pub use deployment::local_filesystem_build_input; +// consumer: `ironclaw_reborn_cli` serve wiring · pinned by: `composition/tests/webui_v2_serve.rs` pub use ironhub_link_serve::{ IRONHUB_REGISTER_PATH, IronhubRegisterRouteState, ironhub_register_route_mount, }; +// consumer: root integration harness group wiring · pinned by: `tests/integration/support/group.rs` pub use observability::budget::build_default_budget_accountant; -pub use observability::budget_events::{BudgetEventObserver, TracingBudgetEventObserver}; +// consumer: composition budget contract tests · pinned by: `composition/tests/budget_e2e.rs` +pub use observability::budget_events::BudgetEventObserver; +// consumer: composition hook-projection tests · pinned by: `composition/tests/third_party_hook_projection.rs` (the factory type is the builder fn's return type; `observability` is private) pub use observability::hooks::{ - HOOKS_ENABLED_ENV, HOOKS_THIRD_PARTY_ENABLED_ENV, HookDispatcherBuilderFactory, - HookProjectionRegistry, HooksActivationConfig, MAX_INSTALLED_EXTENSIONS_CONSIDERED, - MAX_TOTAL_HOOKS_PER_TENANT, ThirdPartyDiscoveryInput, build_hook_dispatcher_builder_factory, - build_hook_dispatcher_builder_factory_for_tenant, build_hook_projection_registry, - tenant_extension_root, + HookDispatcherBuilderFactory, HookProjectionRegistry, HooksActivationConfig, + MAX_INSTALLED_EXTENSIONS_CONSIDERED, ThirdPartyDiscoveryInput, + build_hook_dispatcher_builder_factory, build_hook_projection_registry, }; +// consumer: root integration harness hook suites · pinned by: `tests/integration/hooks.rs` pub use observability::trajectory_observer::RebornTrajectoryObserver; +// consumer: `harness/latency/runner`, composition substrate suites · pinned by: `composition/tests/libsql_substrate.rs` pub use production_runtime_policy::RebornProductionRuntimePolicy; +// consumer: `ironclaw_reborn_cli` serve readiness reporting · pinned by: `composition/tests/profile_acceptance.rs` pub use readiness::{ RebornReadiness, RebornReadinessDiagnostic, RebornReadinessDiagnosticComponent, RebornReadinessDiagnosticReason, RebornReadinessDiagnosticStatus, RebornReadinessState, RebornServiceReadiness, RebornWorkerReadiness, }; +// consumer: `ironclaw_product` test support + root integration harness · pinned by: `composition/tests/product_live_adapters.rs` +// Reached through the `test-support`-featured dev-dependency in `ironclaw_product/Cargo.toml`. +// CHECKLIST WS6 called this block dead and asked for its deletion; it is NOT — deleting +// it strands a sibling crate's test support. See the row's recorded refutation. #[cfg(any(test, feature = "test-support"))] pub use root::product_live_adapters::{ ProductLiveCapabilityAuthorityResolver, ProductLiveCapabilityIo, ProductLiveModelRouteSettings, @@ -196,23 +172,29 @@ pub use root::product_live_adapters::{ ProductLivePlannedRuntimeAdapters, ProductLiveVisibleCapabilityRequestConfig, capability_allowlist, visible_capability_request_for_run, }; +// consumer: `ironclaw_reborn_cli` serve + runtime, `harness/latency/runner`, root QA suites · pinned by: `composition/tests/admin_api_e2e.rs` (the parse error is `FromStr::Err`; `root` is private) pub use root::profile::{RebornCompositionProfile, RebornCompositionProfileParseError}; +// consumer: composition + root QA turn-drive suites · pinned by: `composition/tests/runtime.rs` #[cfg(any(test, feature = "test-support"))] pub use runtime::RebornTurnDriveOutcome; +// consumer: `ironclaw_reborn_cli` (extension/ironhub/serve/runtime) · pinned by: `composition/tests/runtime.rs` +// Also `harness/latency/runner`, `ironclaw_product` test support, root integration + QA suites. +// The `RebornSkill*` types are `RebornRuntime`'s public skill signatures; `runtime` is private. pub use runtime::{ AssistantReply, ConversationId, RebornRuntime, RebornRuntimeError, RebornSkillActivation, RebornSkillActivationMode, RebornSkillActivationSource, RebornSkillAsset, RebornSkillBundle, - RebornSkillExecutionPlan, RebornSkillExecutionResult, blocked_auth_flow_canceller, - build_reborn_runtime, build_runtime, product_auth_challenge_provider, + RebornSkillExecutionPlan, RebornSkillExecutionResult, build_reborn_runtime, build_runtime, + product_auth_challenge_provider, }; +// consumer: `ironclaw_reborn_cli` runtime input construction · pinned by: `composition/tests/admin_api_e2e.rs` +// Also `harness/latency/runner`, `ironclaw_product` test support, root integration + QA suites. +// `TriggerFireAccess*` is `TriggerFireAccessPolicy`'s vocabulary; `runtime_input` is private. pub use runtime_input::{ - DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL, DEFAULT_TURN_RUNNER_POLL_INTERVAL, KeepaliveSweepSettings, PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, TriggerFireAccessError, TriggerFireAccessGrant, TriggerFireAccessPolicy, TriggerPollerSettings, TurnRunnerSettings, }; -pub use runtime_input::{RebornProviderFactory, ResolvedRebornLlm}; /// Re-exported IronHub command vocabulary for the `ironclaw` binary's /// `ironhub` subcommand and serve wiring. This facade keeps runtime input @@ -246,9 +228,10 @@ pub mod host_api { /// `ironclaw_reborn_identity` directly. The concrete filesystem-backed store /// stays private to this composition layer (composition CLAUDE.md: "keep /// lower substrate handles private"). +// consumer: `ironclaw_reborn_cli` user_directory + webui_auth (no `ironclaw_reborn_identity` dep) · pinned by: `composition/tests/production_runtime_identity.rs` pub use ironclaw_reborn_identity::{ - ExternalSubjectId, IdentityKeyError, ProviderInstanceId, ProviderKind, RebornIdentityError, - RebornIdentityResolver, ResolveExternalIdentity, SurfaceKind, + ExternalSubjectId, ProviderKind, RebornIdentityError, RebornIdentityResolver, + ResolveExternalIdentity, SurfaceKind, }; /// Test-support: build a standalone canonical Reborn identity resolver on an diff --git a/crates/ironclaw_reborn_composition/src/memory_provider_factory.rs b/crates/ironclaw_reborn_composition/src/memory_provider_factory.rs index 0d593401181..c6a08c479b0 100644 --- a/crates/ironclaw_reborn_composition/src/memory_provider_factory.rs +++ b/crates/ironclaw_reborn_composition/src/memory_provider_factory.rs @@ -34,10 +34,11 @@ use ironclaw_host_runtime::{ use ironclaw_loop_contracts::MemoryPromptContextService; use ironclaw_loop_host::HostUserProfileSource; use ironclaw_memory::{MemoryService, PromptWriteSafetyEventSink}; +#[cfg(all(test, feature = "memory-mem0"))] +use ironclaw_memory_mem0::MEM0_MEMORY_EXTENSION_ID; #[cfg(feature = "memory-mem0")] -use ironclaw_memory_mem0::{ - MEM0_MEMORY_EXTENSION_ID, Mem0Config, Mem0HttpTransport, Mem0MemoryService, Mem0Transport, -}; +use ironclaw_memory_mem0::{Mem0Config, Mem0HttpTransport, Mem0MemoryService, Mem0Transport}; +#[cfg(test)] use ironclaw_memory_native::NativeMemoryService; #[cfg(feature = "memory-mem0")] use secrecy::ExposeSecret; @@ -130,7 +131,8 @@ impl MemoryProviderDeps { /// its connection config over its real transport (or an injected mock). An /// unknown id, or missing/invalid mem0 connection settings, yield `None`. /// - `Disabled` → `None`. -pub fn create_provider( +#[cfg(test)] +pub(crate) fn create_provider( binding: &MemoryProviderBinding, deps: &MemoryProviderDeps, ) -> Option> { @@ -148,6 +150,7 @@ pub fn create_provider( } } +#[cfg(test)] fn create_third_party_provider( extension_id: &str, deps: &MemoryProviderDeps, diff --git a/crates/ironclaw_reborn_composition/src/observability/budget_events.rs b/crates/ironclaw_reborn_composition/src/observability/budget_events.rs index b1416a4f79c..358369582eb 100644 --- a/crates/ironclaw_reborn_composition/src/observability/budget_events.rs +++ b/crates/ironclaw_reborn_composition/src/observability/budget_events.rs @@ -33,7 +33,7 @@ pub trait BudgetEventObserver: Send + Sync + std::fmt::Debug + 'static { /// (e.g. tracing-only deploys, standalone binaries that just want the /// observability without an SSE bridge). #[derive(Debug, Default, Clone, Copy)] -pub struct TracingBudgetEventObserver; +pub(crate) struct TracingBudgetEventObserver; impl BudgetEventObserver for TracingBudgetEventObserver { fn observe(&self, event: BudgetEvent) { diff --git a/crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs b/crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs index bdb07cf972b..8a2ca8529ec 100644 --- a/crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs +++ b/crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs @@ -274,7 +274,7 @@ pub fn build_hook_dispatcher_builder_factory( /// to that tenant, not the synthetic `"reborn-hook-projection"` fallback. This /// closes the observability gap where discovery-time audits carried the real /// tenant but install-time audits did not. -pub fn build_hook_dispatcher_builder_factory_for_tenant( +pub(crate) fn build_hook_dispatcher_builder_factory_for_tenant( config: HooksActivationConfig, registry: &HookProjectionRegistry, tenant_id: &ironclaw_host_api::ids::TenantId, diff --git a/crates/ironclaw_reborn_composition/src/observability/hooks/mod.rs b/crates/ironclaw_reborn_composition/src/observability/hooks/mod.rs index 2fe16a75fae..386a4fec031 100644 --- a/crates/ironclaw_reborn_composition/src/observability/hooks/mod.rs +++ b/crates/ironclaw_reborn_composition/src/observability/hooks/mod.rs @@ -84,12 +84,11 @@ mod tests; pub use ironclaw_runner::loop_driver_host::HookDispatcherBuilderFactory; // Public surface of the activation path (consumed by `crate::runtime`). -pub use factory::{ - build_hook_dispatcher_builder_factory, build_hook_dispatcher_builder_factory_for_tenant, -}; +pub use factory::build_hook_dispatcher_builder_factory; +pub(crate) use factory::build_hook_dispatcher_builder_factory_for_tenant; pub use projection::{ - HookProjectionRegistry, MAX_INSTALLED_EXTENSIONS_CONSIDERED, MAX_TOTAL_HOOKS_PER_TENANT, - ThirdPartyDiscoveryInput, build_hook_projection_registry, tenant_extension_root, + HookProjectionRegistry, MAX_INSTALLED_EXTENSIONS_CONSIDERED, ThirdPartyDiscoveryInput, + build_hook_projection_registry, }; /// Activation configuration for the hook framework. @@ -133,12 +132,12 @@ pub struct HooksActivationConfig { /// Environment variable that flips the hook framework on. Absent / empty / /// any value other than a recognized truthy token ⇒ OFF. -pub const HOOKS_ENABLED_ENV: &str = "HOOKS_ENABLED"; +pub(crate) const HOOKS_ENABLED_ENV: &str = "HOOKS_ENABLED"; /// Environment variable that additionally flips *third-party installed /// extension* hook activation on. Requires [`HOOKS_ENABLED_ENV`] to also be /// truthy. Absent / empty / non-truthy ⇒ OFF. -pub const HOOKS_THIRD_PARTY_ENABLED_ENV: &str = "HOOKS_THIRD_PARTY_ENABLED"; +pub(crate) const HOOKS_THIRD_PARTY_ENABLED_ENV: &str = "HOOKS_THIRD_PARTY_ENABLED"; impl HooksActivationConfig { /// Explicitly enabled (master flag only; third-party still OFF). diff --git a/crates/ironclaw_reborn_composition/src/observability/hooks/projection.rs b/crates/ironclaw_reborn_composition/src/observability/hooks/projection.rs index 219a879c3b3..1dd64af5344 100644 --- a/crates/ironclaw_reborn_composition/src/observability/hooks/projection.rs +++ b/crates/ironclaw_reborn_composition/src/observability/hooks/projection.rs @@ -29,7 +29,7 @@ pub const MAX_INSTALLED_EXTENSIONS_CONSIDERED: usize = 64; /// running total past this budget is quarantined (skipped + audited), not /// whole-build failed. Builtin / host-bundled bindings do not count against /// this third-party budget (they are trusted and fail-closed-whole-build). -pub const MAX_TOTAL_HOOKS_PER_TENANT: usize = 256; +pub(crate) const MAX_TOTAL_HOOKS_PER_TENANT: usize = 256; /// The hook-only metadata extracted from ONE extension package: exactly the /// fields the projection needs, and NOTHING from the capability / runtime / @@ -155,7 +155,7 @@ impl std::fmt::Debug for HookProjectionRegistry { /// `openat2(RESOLVE_BENEATH)` / `O_NOFOLLOW` backend hardening lands, because /// that hardening is precisely what protects the scoped-FS-is-the-boundary /// property against symlink/`..` escapes below the virtual layer. -pub fn tenant_extension_root( +pub(crate) fn tenant_extension_root( _tenant_id: &ironclaw_host_api::ids::TenantId, ) -> Result { ironclaw_host_api::path::VirtualPath::new("/system/extensions").map_err(|error| { diff --git a/crates/ironclaw_reborn_composition/src/observability/mod.rs b/crates/ironclaw_reborn_composition/src/observability/mod.rs index 9a589dacaa5..64ca596f83e 100644 --- a/crates/ironclaw_reborn_composition/src/observability/mod.rs +++ b/crates/ironclaw_reborn_composition/src/observability/mod.rs @@ -2,5 +2,4 @@ pub(crate) mod budget; pub(crate) mod budget_events; pub(crate) mod budget_evidence; pub(crate) mod hooks; -pub(crate) mod trace_capture; pub(crate) mod trajectory_observer; diff --git a/crates/ironclaw_reborn_composition/src/product_surface.rs b/crates/ironclaw_reborn_composition/src/product_surface.rs index aba36164881..00e66643603 100644 --- a/crates/ironclaw_reborn_composition/src/product_surface.rs +++ b/crates/ironclaw_reborn_composition/src/product_surface.rs @@ -6,15 +6,16 @@ use chrono::Utc; use async_trait::async_trait; use ironclaw_attachments::ProjectScopedAttachmentLander; +use ironclaw_auth::ChannelConnectionService; #[cfg(test)] use ironclaw_extensions::SharedExtensionRegistry; use ironclaw_host_api::{ids::InvocationId, resource::ResourceScope}; use ironclaw_operator::OperatorServiceLifecycle; use ironclaw_product::{ - ChannelConnectionService, ProjectScopedAttachmentReader, ProjectScopedFilesystemReader, - RebornAutomationProductService, RebornServices as ProductRebornServices, - RebornSkillContentResponse, RebornSkillInfo, RebornSkillListResponse, - RebornSkillSearchResponse, RebornSkillSourceKind, RebornSkillTrustLevel, SkillsProductService, + ProjectScopedAttachmentReader, ProjectScopedFilesystemReader, RebornAutomationProductService, + RebornServices as ProductRebornServices, RebornSkillContentResponse, RebornSkillInfo, + RebornSkillListResponse, RebornSkillSearchResponse, RebornSkillSourceKind, + RebornSkillTrustLevel, SkillsProductService, }; use ironclaw_product_contracts::operator_llm::LlmConfigService; use ironclaw_product_contracts::operator_service::OperatorStatusService; @@ -97,13 +98,12 @@ pub(crate) fn build_product_surface_with_channel_connection( // Admin user-management surface: the directory and secret provisioner are // core runtime handles; only token minting is deployment-supplied. if let Some(minter) = runtime.reborn_admin_token_minter() { - api = api.with_admin_user_service(Arc::new( - crate::admin_user_directory::RebornAdminUserDirectory::new( + api = + api.with_admin_user_service(Arc::new(ironclaw_product::RebornAdminUserDirectory::new( runtime.reborn_user_directory(), runtime.reborn_admin_secret_provisioner(), minter, - ), - )); + ))); } if let Some(workspace_filesystem) = runtime.webui_workspace_filesystem() { api = api diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index b2ed589fa6f..6acfd4dcc4a 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -138,8 +138,9 @@ use crate::outbound::{ OutboundDeliveryTargetProvider, RebornOutboundPreferencesService, outbound_delivery_synthetic_provider, }; -use crate::process_gate_turn_view::{current_turn_gate_runs, first_turn_run_for_gate}; use crate::root::default_system_prompt::DefaultSystemPromptIdentitySource; +pub(crate) use ironclaw_auth::product_prompt::blocked_auth_flow_canceller; +pub use ironclaw_auth::product_prompt::product_auth_challenge_provider; use ironclaw_extension_host::AdminConfigurationCatalogUse; #[cfg(any(test, feature = "test-support"))] use ironclaw_extension_host::channel_pairing::ChannelPairingConsumeOutcome; @@ -149,7 +150,7 @@ use ironclaw_extension_manager::admin_configuration::{ ComposedAdminConfigurationService, ComposedExtensionAdminConfigurationResolver, }; use ironclaw_product::projection::{RebornProjectionServices, build_reborn_projection_services}; -pub use ironclaw_product::{blocked_auth_flow_canceller, product_auth_challenge_provider}; +use ironclaw_product::{current_turn_gate_runs, first_turn_run_for_gate}; use ironclaw_secrets::SecretStorePort; use ironclaw_skills::ScopedSkillManagementPort; @@ -192,15 +193,13 @@ use crate::runtime_input::{ PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessChecker, TriggerFireAccessGrant, }; -use crate::trigger_fire_access::{ - CompositeTriggerFireChecker, IdentityMembershipTriggerFireChecker, - StaticOwnerTriggerFireChecker, -}; +use crate::trigger_fire_access::IdentityMembershipTriggerFireChecker; use crate::trigger_poller_assembly::{ build_trigger_active_run_lookup, build_trigger_poller_services, poller_user_directory, validate_trigger_poller_authorization, }; use crate::{RebornBuildError, RebornReadiness}; +use ironclaw_triggers::{CompositeTriggerFireChecker, StaticOwnerTriggerFireChecker}; use production::{ EmptyCapabilitySurfaceResolver, EmptyIdentityContextSource, UnavailableApprovalInteractionService, UnavailableCapabilityIo, @@ -234,7 +233,7 @@ struct RuntimeStoreParts { trigger_repository: Arc, /// Process lifecycle source for trigger active-run lookup. Every substrate /// now provides the same typed process-journal projection. - admin_secret_provisioner: Arc, + admin_secret_provisioner: Arc, project_service: Arc, trigger_conversation_services: Option, } @@ -549,7 +548,7 @@ pub struct RebornRuntime { pub(crate) extension_lifecycle_surface_context: LifecycleProductSurfaceContext, pub(crate) secret_store: Arc, pub(crate) scoped_filesystem: Arc>, - pub(crate) admin_secret_provisioner: Arc, + pub(crate) admin_secret_provisioner: Arc, pub(crate) project_service: Arc, pub(crate) trigger_repository: Arc, #[cfg(any(test, feature = "test-support"))] @@ -605,7 +604,7 @@ pub struct RebornRuntime { #[cfg(any(test, feature = "test-support"))] pub(crate) delivery_coordinator: Option>, pub(crate) channel_facade_slot: - Arc>>, + Arc>>, pub(crate) admin_configuration: Arc, pub(crate) admin_configuration_uses: Arc>, pub(crate) channel_config_service: Arc, @@ -636,7 +635,7 @@ pub struct RebornRuntime { turn_scheduler: RuntimeTurnScheduler, trigger_poller_handle: Option, credential_refresh_worker_handle: Option, - trace_flush_worker: crate::observability::trace_capture::TraceQueueFlushWorkerHandle, + trace_flush_worker: ironclaw_reborn_traces::capture::TraceQueueFlushWorkerHandle, skill_learning_extraction_tasks: Option>, #[cfg(any(test, feature = "test-support"))] @@ -649,7 +648,8 @@ pub struct RebornRuntime { /// Mints the one-time API bearer on admin user creation. Read by /// `runtime.product_surface` when wiring the admin surface. `None` leaves the /// admin create path reporting the token minter unavailable. - admin_api_token_minter: Option>, + admin_api_token_minter: + Option>, actor_user_id: UserId, source_binding_ref: SourceBindingRef, reply_target_binding_ref: ReplyTargetBindingRef, @@ -1109,10 +1109,10 @@ impl RebornRuntime { channel_pairing: self.channel_pairing.clone(), }; let admin_users: Arc = - Arc::new(crate::admin_user_directory::RebornAdminUserDirectory::new( + Arc::new(ironclaw_product::RebornAdminUserDirectory::new( self.reborn_user_directory(), self.reborn_admin_secret_provisioner(), - Arc::new(crate::admin_token::RejectingAdminApiTokenMinter), + Arc::new(ironclaw_product::RejectingAdminApiTokenMinter), )); Some(crate::extension_host_assembly::start_channel_host( &source, @@ -1585,7 +1585,7 @@ impl RebornRuntime { /// `admin_secrets.rs`. pub(crate) fn reborn_admin_secret_provisioner( &self, - ) -> Arc { + ) -> Arc { Arc::clone(&self.admin_secret_provisioner) } @@ -1597,7 +1597,9 @@ impl RebornRuntime { /// The admin API-token minter supplied via /// [`RebornRuntimeInput::with_admin_api_token_minter`], if any. - pub(crate) fn reborn_admin_token_minter(&self) -> Option> { + pub(crate) fn reborn_admin_token_minter( + &self, + ) -> Option> { self.admin_api_token_minter.clone() } @@ -1702,7 +1704,7 @@ impl RebornRuntime { /// channel-identity storage. pub(crate) fn generic_channel_connection_facade( &self, - ) -> Option> { + ) -> Option> { let identity_store = self.channel_identity_store.clone(); let installation_store = Some(self.extension_management.installation_store_handle()); let credential_cleanup = Some(Arc::clone(&self.product_auth) @@ -3434,12 +3436,12 @@ pub(crate) async fn build_runtime_with_resource_governor( thread_scope.tenant_id.as_str(), actor_user_id.as_str(), ); - let trace_capture_scopes: crate::observability::trace_capture::ObservedTraceScopes = + let trace_capture_scopes: ironclaw_reborn_traces::capture::ObservedTraceScopes = Arc::new(std::sync::Mutex::new(std::collections::BTreeSet::from([ runtime_owner_trace_scope, ]))); let trace_capture_sink: Arc = Arc::new( - crate::observability::trace_capture::TraceCaptureTurnEventSink::new( + ironclaw_runner::trace_capture::TraceCaptureTurnEventSink::new( Arc::clone(&thread_service), Arc::clone(&trace_capture_scopes), ), @@ -4070,7 +4072,7 @@ pub(crate) async fn build_runtime_with_resource_governor( crate::factory::CredentialRefreshWorkerReady::Absent => None, }; let trace_flush_worker = - crate::observability::trace_capture::spawn_trace_queue_flush_worker(trace_capture_scopes); + ironclaw_reborn_traces::capture::spawn_trace_queue_flush_worker(trace_capture_scopes); // Scheduler is running (started inside build_default_planned_runtime); mark readiness. services.readiness.workers.turn_runner = true; services.readiness.workers.trigger_poller = trigger_poller_handle.is_some(); @@ -4086,7 +4088,8 @@ pub(crate) async fn build_runtime_with_resource_governor( // `RebornRuntimeInput::with_budget_event_observer`. let budget_event_projection = Some({ let observer = budget_event_observer.unwrap_or_else(|| { - Arc::new(crate::TracingBudgetEventObserver) as Arc + Arc::new(crate::observability::budget_events::TracingBudgetEventObserver) + as Arc }); crate::observability::budget_events::BudgetEventProjection::spawn( broadcast_budget_event_sink.as_ref(), diff --git a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs index 8b7b8b9e364..c966b3f6748 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs @@ -16,7 +16,7 @@ use ironclaw_product::{ ResolveAuthInteractionResponse, }; -use crate::process_gate_turn_view::{current_turn_gate_runs, first_turn_run_for_gate}; +use ironclaw_product::{current_turn_gate_runs, first_turn_run_for_gate}; pub(super) struct ProcessGateAuthInteractionReadModel { gates: Arc>, diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host.rs index 7b815953a9b..e3aa3279b55 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host.rs @@ -47,9 +47,9 @@ use ironclaw_turns::{ExternalToolCatalog, LoopResultRef}; use crate::builtin_capability_policy::BuiltinCapabilityPolicy; use crate::capability_authorization::{StoreApprovalSettingsProvider, effects_require_approval}; use crate::factory::RebornRuntimeStores; -use crate::profile_approval_authorization::ApprovalSettingsProvider; use crate::runtime::ComposedSelectableSkillContextSource; use crate::runtime_mounts::{WorkspaceMountPolicy, scoped_skill_management_mount_view}; +use ironclaw_approvals::ApprovalSettingsProvider; use ironclaw_product::projection::{CapabilityDisplayPreviewResult, CapabilityDisplayPreviewStore}; mod outbound_delivery; diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host/outbound_delivery.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host/outbound_delivery.rs index 5a59d674dd4..ed14d6aaa37 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host/outbound_delivery.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host/outbound_delivery.rs @@ -44,7 +44,7 @@ use crate::outbound::{ outbound_delivery_targets_list_input_schema, parse_outbound_delivery_target_set_input, parse_outbound_delivery_targets_list_input, set_outbound_delivery_target_for_model, }; -use crate::profile_approval_authorization::ApprovalSettingsProvider; +use ironclaw_approvals::ApprovalSettingsProvider; // Synthetic outbound handler now also carries the host-private replay-payload // store it persists at its approval-gate raise and reconstitutes from on resume. // arch-exempt: too_many_args, outbound handler carries the replay-payload store (§5.3 Stage 2a-i), plan #6175 diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host/refreshing_capability_port.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host/refreshing_capability_port.rs index d049d8bc4bb..b80e0b61341 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host/refreshing_capability_port.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host/refreshing_capability_port.rs @@ -26,9 +26,9 @@ use ironclaw_turns::ExternalToolCatalog; use tokio::sync::Mutex as AsyncMutex; use crate::builtin_capability_policy::BuiltinCapabilityPolicy; -use crate::profile_approval_authorization::ApprovalSettingsProvider; use crate::runtime::ComposedSelectableSkillContextSource; use crate::runtime::capability_host::outbound_delivery::outbound_delivery_capabilities; +use ironclaw_approvals::ApprovalSettingsProvider; use ironclaw_extension_host::capability_surface::ExtensionCapabilitySurfaceSource; use ironclaw_first_party_extension_ports::skill_activation_capability; use ironclaw_loop_host::result_read_capability; diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host/shell_tests.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host/shell_tests.rs index 5bec4c62671..adca5785c2d 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host/shell_tests.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host/shell_tests.rs @@ -107,9 +107,7 @@ async fn standalone_yolo_shell_translates_workspace_workdir_without_scoped_mount trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: std::sync::Arc::new(ironclaw_approvals::GateRecordStore::new( diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host/tests.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host/tests.rs index 5b0baeec718..4c866e571e7 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host/tests.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host/tests.rs @@ -1580,9 +1580,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -1891,9 +1889,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -2436,9 +2432,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), @@ -2687,9 +2681,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), @@ -2774,9 +2766,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -2977,9 +2967,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -3315,9 +3303,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -3746,9 +3732,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( @@ -4763,9 +4747,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), @@ -4880,9 +4862,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), @@ -5130,9 +5110,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), @@ -5252,9 +5230,7 @@ mod tests { trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), project_service: Arc::clone(&runtime_surfaces.project_service), thread_service: Arc::new(InMemorySessionThreadService::default()), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), diff --git a/crates/ironclaw_reborn_composition/src/runtime/capability_host/workspace_scoping_tests.rs b/crates/ironclaw_reborn_composition/src/runtime/capability_host/workspace_scoping_tests.rs index 68e45a46882..8abc2b2e703 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/capability_host/workspace_scoping_tests.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/capability_host/workspace_scoping_tests.rs @@ -99,9 +99,7 @@ async fn invoke_workspace_tool_as( trajectory_observer: None, outbound_preferences_service: None, outbound_delivery_target_set_requires_approval: false, - approval_settings: Arc::new( - crate::profile_approval_authorization::EmptyApprovalSettingsProvider, - ), + approval_settings: Arc::new(ironclaw_approvals::EmptyApprovalSettingsProvider), approval_requests: runtime_surfaces.approval_requests_for_test().clone(), capability_leases: runtime_surfaces.capability_leases_for_test().clone(), gate_record_store: Arc::new(ironclaw_approvals::GateRecordStore::new( diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs index 2a879eeec3e..ab0b7d2a0e1 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs @@ -139,7 +139,7 @@ async fn runtime_channel_identity_bind_uses_deployment_channel_before_user_activ .with_runtime_policy(standalone_runtime_policy()) .with_network_http_egress_for_test(network_egress.clone()) .with_channel_extension_bindings(vec![crate::input::ChannelExtensionBinding { - extension_id: "slack".to_string(), + extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: None, }]); diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index fa6262720f4..3e9b41cd0ea 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -23,11 +23,7 @@ use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; -use ironclaw_host_api::{ - Timestamp, - ids::{AgentId, ProjectId, TenantId, UserId}, -}; +use ironclaw_host_api::ids::{AgentId, ProjectId, UserId}; #[cfg(any(test, feature = "test-support"))] use ironclaw_loop_host::HostManagedModelGateway; use ironclaw_loop_host::HostSkillContextSource; @@ -38,7 +34,7 @@ use ironclaw_runner::runtime::{ DEFAULT_MAX_CONCURRENT_RUNS_PER_USER, DEFAULT_MAX_CONCURRENT_TRIGGER_RUNS, DEFAULT_TURN_RUNNER_WORKER_COUNT, }; -use ironclaw_triggers::{TriggerId, TriggerPollerWorkerConfig}; +use ironclaw_triggers::TriggerPollerWorkerConfig; use crate::input::RebornHostBindings; use crate::observability::hooks::HooksActivationConfig; @@ -73,61 +69,20 @@ impl Default for RebornRuntimeIdentity { } } -pub const DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); -pub const DEFAULT_TURN_RUNNER_POLL_INTERVAL: Duration = Duration::from_millis(200); - -/// Fire-time access request for a persisted trigger. -/// -/// This is the host/composition-facing access check shape. Checks are exact: -/// `None` for `agent_id` or `project_id` means the trigger has no value for -/// that scope dimension, not that the checker should treat it as a wildcard. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TriggerFireAccessCheck { - /// Tenant that owns the persisted trigger. - pub tenant_id: TenantId, - /// User that created the persisted trigger and whose access is evaluated - /// again at fire time. - pub creator_user_id: UserId, - /// Optional agent scope stored on the trigger. - pub agent_id: Option, - /// Optional project scope stored on the trigger. - pub project_id: Option, - /// Trigger being fired. Included so production access checks can audit or - /// apply trigger-specific policy without changing this request shape. - pub trigger_id: TriggerId, - /// Deterministic fire slot being submitted. Included for audit and policy - /// decisions that depend on scheduled fire identity. - pub fire_slot: Timestamp, -} - -/// Result of a fire-time trigger access check. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TriggerFireAccessDecision { - /// The trigger creator is still authorized for the exact trigger scope. - Allowed, - /// The trigger creator is not authorized for the exact trigger scope. - Denied { reason: String }, -} - -/// Error returned when the access backend cannot answer the request. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum TriggerFireAccessError { - /// The backing access source was unavailable; trigger fire handling should - /// treat this as retryable rather than a permanent denial. - #[error("trigger fire access backend unavailable: {reason}")] - Unavailable { reason: String }, -} - -/// Fire-time trigger access checker supplied by the composition root. -#[async_trait] -pub trait TriggerFireAccessChecker: Send + Sync { - /// Check whether the persisted trigger creator may fire the trigger for - /// the exact stored tenant/agent/project scope. - async fn check_trigger_fire_access( - &self, - request: TriggerFireAccessCheck, - ) -> Result; -} +pub(crate) const DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +pub(crate) const DEFAULT_TURN_RUNNER_POLL_INTERVAL: Duration = Duration::from_millis(200); + +/// The fire-time access contract lives in `ironclaw_triggers` (CHECKLIST WS6): +/// the check is a decision about a persisted trigger's own stored scope, so the +/// request/decision vocabulary and the checkers that carry no backend belong +/// beside the trigger record and the worker that consults them. What stays in +/// this file is the *deployment grant* the `serve`/`run` edge resolves — §6.10.1 +/// names config-as-data as composition's charter — and `build_reborn_runtime` +/// still turns one into the other. +pub use ironclaw_triggers::{ + TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, + TriggerFireAccessError, +}; /// A single fire-time access grant. The granted scope is exact (`None` project /// means "no project", never a wildcard), matching [`TriggerFireAccessCheck`]. @@ -202,7 +157,7 @@ impl TriggerFireAccessPolicy { } } -pub use ironclaw_operator::{RebornProviderFactory, ResolvedRebornLlm}; +pub(crate) use ironclaw_operator::ResolvedRebornLlm; /// Configuration for the turn-runner worker spawned by the runtime. #[derive(Debug, Clone)] @@ -414,7 +369,8 @@ pub struct RebornRuntimeInput { /// Mints the one-time API bearer returned when an admin creates a user. The /// serve layer supplies a session-store-backed minter; when unset, the admin /// user-management surface stays unwired (create reports unavailable). - pub admin_api_token_minter: Option>, + pub admin_api_token_minter: + Option>, #[cfg(any(test, feature = "test-support"))] pub(crate) model_gateway_override: Option>, /// Cost table to pair with the model-gateway override. Without this, @@ -539,7 +495,7 @@ impl RebornRuntimeInput { /// admin user-management surface stays unwired. pub fn with_admin_api_token_minter( mut self, - minter: Arc, + minter: Arc, ) -> Self { self.admin_api_token_minter = Some(minter); self diff --git a/crates/ironclaw_reborn_composition/src/test_support/channel_connection.rs b/crates/ironclaw_reborn_composition/src/test_support/channel_connection.rs index 694f13931be..307b8ace3f7 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/channel_connection.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/channel_connection.rs @@ -30,12 +30,12 @@ use std::sync::Arc; +use ironclaw_auth::ChannelConnectionService; use ironclaw_auth::{AuthProductScope, AuthSurface, OAuthProviderIdentity}; use ironclaw_host_api::{ ids::{AgentId, InvocationId, TenantId, UserId}, resource::ResourceScope, }; -use ironclaw_product::ChannelConnectionService; use ironclaw_product_contracts::surface::ProductSurfaceCaller; use ironclaw_extension_contracts::channel_identity::ChannelIdentityPostBindFactory; diff --git a/crates/ironclaw_reborn_composition/src/test_support/trace_capture.rs b/crates/ironclaw_reborn_composition/src/test_support/trace_capture.rs index 7850e92131e..e3e52d181a7 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/trace_capture.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/trace_capture.rs @@ -19,12 +19,12 @@ pub fn trace_capture_turn_event_sink_for_test( actor_user_id: &str, ) -> (std::sync::Arc, String) { let scope = ironclaw_reborn_traces::contribution::trace_scope_key(tenant_id, actor_user_id); - let observed_scopes: crate::observability::trace_capture::ObservedTraceScopes = + let observed_scopes: ironclaw_reborn_traces::capture::ObservedTraceScopes = std::sync::Arc::new(std::sync::Mutex::new(std::collections::BTreeSet::from([ scope.clone(), ]))); let sink = std::sync::Arc::new( - crate::observability::trace_capture::TraceCaptureTurnEventSink::new( + ironclaw_runner::trace_capture::TraceCaptureTurnEventSink::new( thread_service, observed_scopes, ), diff --git a/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs b/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs index bdc6f226590..aaa19018a80 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs @@ -1,90 +1,31 @@ -//! Fire-time trigger access checkers built from [`TriggerFireAccessPolicy`]. +//! The one fire-time trigger-access checker that is assembly, not policy. //! -//! These replaced the former `ironclaw_runner::local_trigger_access` shadow -//! store (arch-simplification §4.4). Trigger-fire authorization is no longer a -//! persisted parallel access table: it is either a pure comparison against a -//! config-supplied owner ([`StaticOwnerTriggerFireChecker`]) or a membership -//! lookup against the canonical identity directory the SSO login path already -//! populates ([`IdentityMembershipTriggerFireChecker`]). The composition build -//! selects one from [`TriggerFireAccessPolicy`] when the trigger poller is -//! enabled. +//! The check contract (`TriggerFireAccessCheck`/`Decision`/`Error`/`Checker`), +//! the exact-scope comparison, the static-owner grant, and the OR-combinator +//! moved to `ironclaw_triggers::fire_access` with CHECKLIST WS6 — they are +//! decisions about this host's own trigger records and carry no backend. +//! +//! What stays is the checker that is a *lookup against a backend composition +//! selects*: tenant membership resolved at fire time from the canonical +//! identity directory the SSO login path populates. It renders the same denial +//! reason and applies the same exact-scope rule as its siblings by calling the +//! trigger crate's [`trigger_fire_access_denied`] / [`trigger_fire_scope_matches`] +//! rather than restating either — a second copy of the deny string is exactly +//! how two checkers drift apart. +//! +//! The composition build still selects this one from `TriggerFireAccessPolicy`, +//! which stays here: §6.10.1's Keeps list names deployment config-as-data as +//! composition's charter. use std::sync::Arc; use async_trait::async_trait; -use ironclaw_host_api::ids::{AgentId, ProjectId, TenantId, UserId}; - -use crate::runtime_input::{ +use ironclaw_host_api::ids::{AgentId, ProjectId, TenantId}; +use ironclaw_triggers::{ TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, - TriggerFireAccessError, + TriggerFireAccessError, trigger_fire_access_denied, trigger_fire_scope_matches, }; -const DENY_REASON: &str = "trigger creator does not have active access for this scope"; - -/// Does the fire-time check's exact scope match the granted `(agent, project)` -/// grant? Scope is exact — `None` project means "no project", never a wildcard -/// (matches [`TriggerFireAccessCheck`] semantics). -fn scope_matches( - check: &TriggerFireAccessCheck, - agent: &AgentId, - project: &Option, -) -> bool { - check.agent_id.as_ref() == Some(agent) && &check.project_id == project -} - -fn denied() -> TriggerFireAccessDecision { - TriggerFireAccessDecision::Denied { - reason: DENY_REASON.to_string(), - } -} - -/// A single configured owner may fire triggers for one exact scope — the -/// env-token `serve` and CLI `run` owner grant. Pure comparison, no I/O. -/// -/// The `tenant_id` bound is load-bearing: the due-trigger repository is global, -/// so a fire-time check that matched only owner + scope could authorize a -/// foreign tenant's trigger whose creator id happened to equal this owner. The -/// former store keyed every row on tenant; this preserves that. -pub(crate) struct StaticOwnerTriggerFireChecker { - tenant_id: TenantId, - owner: UserId, - agent: AgentId, - project: Option, -} - -impl StaticOwnerTriggerFireChecker { - pub(crate) fn new( - tenant_id: TenantId, - owner: UserId, - agent: AgentId, - project: Option, - ) -> Self { - Self { - tenant_id, - owner, - agent, - project, - } - } -} - -#[async_trait] -impl TriggerFireAccessChecker for StaticOwnerTriggerFireChecker { - async fn check_trigger_fire_access( - &self, - request: TriggerFireAccessCheck, - ) -> Result { - let allowed = request.tenant_id == self.tenant_id - && request.creator_user_id == self.owner - && scope_matches(&request, &self.agent, &self.project); - Ok(if allowed { - TriggerFireAccessDecision::Allowed - } else { - denied() - }) - } -} - /// Any active member of the host tenant may fire triggers for one exact scope — /// the SSO/WebUI deployment. Membership is resolved at fire time from the /// canonical identity directory (the `StoredUser` records SSO login persists), @@ -119,8 +60,8 @@ impl TriggerFireAccessChecker for IdentityMembershipTriggerFireChecker { &self, request: TriggerFireAccessCheck, ) -> Result { - if !scope_matches(&request, &self.agent, &self.project) { - return Ok(denied()); + if !trigger_fire_scope_matches(&request, &self.agent, &self.project) { + return Ok(trigger_fire_access_denied()); } let user = self .directory @@ -145,60 +86,15 @@ impl TriggerFireAccessChecker for IdentityMembershipTriggerFireChecker { Ok(if allowed { TriggerFireAccessDecision::Allowed } else { - denied() + trigger_fire_access_denied() }) } } -/// OR-combines several checkers: `Allowed` if any grant allows; otherwise -/// `Unavailable` if any grant's backend was unavailable (retryable, so a -/// transient identity-store fault is not a hard denial); otherwise `Denied`. -pub(crate) struct CompositeTriggerFireChecker { - checkers: Vec>, -} - -impl CompositeTriggerFireChecker { - pub(crate) fn new(checkers: Vec>) -> Self { - Self { checkers } - } -} - -#[async_trait] -impl TriggerFireAccessChecker for CompositeTriggerFireChecker { - async fn check_trigger_fire_access( - &self, - request: TriggerFireAccessCheck, - ) -> Result { - // Split so the last checker takes `request` by move — no redundant - // final clone (the common case is a single StaticOwner + SsoMembership - // pair, so this saves one clone per fire). - let Some((last, rest)) = self.checkers.split_last() else { - return Ok(denied()); - }; - let mut unavailable: Option = None; - for checker in rest { - match checker.check_trigger_fire_access(request.clone()).await { - Ok(TriggerFireAccessDecision::Allowed) => { - return Ok(TriggerFireAccessDecision::Allowed); - } - Ok(TriggerFireAccessDecision::Denied { .. }) => {} - Err(error) => unavailable = Some(error), - } - } - match last.check_trigger_fire_access(request).await { - Ok(TriggerFireAccessDecision::Allowed) => Ok(TriggerFireAccessDecision::Allowed), - Ok(TriggerFireAccessDecision::Denied { .. }) => match unavailable { - Some(error) => Err(error), - None => Ok(denied()), - }, - Err(error) => Err(error), - } - } -} - #[cfg(test)] mod tests { use super::*; + use ironclaw_host_api::ids::UserId; fn check(creator: &str, agent: Option<&str>, project: Option<&str>) -> TriggerFireAccessCheck { TriggerFireAccessCheck { @@ -211,110 +107,6 @@ mod tests { } } - fn static_checker() -> StaticOwnerTriggerFireChecker { - StaticOwnerTriggerFireChecker::new( - TenantId::new("tenant").expect("tenant"), - UserId::new("owner").expect("user"), - AgentId::new("agent").expect("agent"), - Some(ProjectId::new("project").expect("project")), - ) - } - - #[tokio::test] - async fn static_owner_allows_exact_owner_and_scope() { - let decision = static_checker() - .check_trigger_fire_access(check("owner", Some("agent"), Some("project"))) - .await - .expect("check"); - assert_eq!(decision, TriggerFireAccessDecision::Allowed); - } - - #[tokio::test] - async fn static_owner_denies_non_owner() { - let decision = static_checker() - .check_trigger_fire_access(check("intruder", Some("agent"), Some("project"))) - .await - .expect("check"); - assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); - } - - #[tokio::test] - async fn static_owner_denies_scope_mismatch() { - // Right owner, wrong project scope. - let decision = static_checker() - .check_trigger_fire_access(check("owner", Some("agent"), Some("other"))) - .await - .expect("check"); - assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); - // Right owner, missing project where one was granted. - let decision = static_checker() - .check_trigger_fire_access(check("owner", Some("agent"), None)) - .await - .expect("check"); - assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); - } - - #[tokio::test] - async fn static_owner_denies_foreign_tenant() { - // The due-trigger repository is global: a foreign tenant's trigger with - // a matching owner id + scope must NOT be authorized (regression guard). - let foreign = TriggerFireAccessCheck { - tenant_id: TenantId::new("other-tenant").expect("tenant"), - creator_user_id: UserId::new("owner").expect("user"), - agent_id: Some(AgentId::new("agent").expect("agent")), - project_id: Some(ProjectId::new("project").expect("project")), - trigger_id: ironclaw_triggers::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }; - let decision = static_checker() - .check_trigger_fire_access(foreign) - .await - .expect("check"); - assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); - } - - #[tokio::test] - async fn composite_allows_if_any_grant_allows() { - // Two static owners; only the second matches the creator. - let checkers: Vec> = vec![ - Arc::new(StaticOwnerTriggerFireChecker::new( - TenantId::new("tenant").expect("tenant"), - UserId::new("owner-a").expect("user"), - AgentId::new("agent").expect("agent"), - Some(ProjectId::new("project").expect("project")), - )), - Arc::new(StaticOwnerTriggerFireChecker::new( - TenantId::new("tenant").expect("tenant"), - UserId::new("owner-b").expect("user"), - AgentId::new("agent").expect("agent"), - Some(ProjectId::new("project").expect("project")), - )), - ]; - let composite = CompositeTriggerFireChecker::new(checkers); - let decision = composite - .check_trigger_fire_access(check("owner-b", Some("agent"), Some("project"))) - .await - .expect("check"); - assert_eq!(decision, TriggerFireAccessDecision::Allowed); - } - - #[tokio::test] - async fn composite_denies_if_no_grant_allows() { - let checkers: Vec> = - vec![Arc::new(StaticOwnerTriggerFireChecker::new( - TenantId::new("tenant").expect("tenant"), - UserId::new("owner-a").expect("user"), - AgentId::new("agent").expect("agent"), - None, - ))]; - let composite = CompositeTriggerFireChecker::new(checkers); - let decision = composite - .check_trigger_fire_access(check("stranger", Some("agent"), None)) - .await - .expect("check"); - assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); - } - mod identity { use super::*; use ironclaw_reborn_identity::{ diff --git a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs index c0d706fc8f5..078ebac659d 100644 --- a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs +++ b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs @@ -32,8 +32,8 @@ use ironclaw_loop_host::{ HostManagedModelRequest, HostManagedModelResponse, }; use ironclaw_reborn_composition::{ - AdminApiTokenMinter, PollSettings, RebornHostBindings, RebornRuntime, RebornRuntimeIdentity, - RebornRuntimeInput, build_reborn_runtime, + PollSettings, RebornHostBindings, RebornRuntime, RebornRuntimeIdentity, RebornRuntimeInput, + build_reborn_runtime, }; use ironclaw_webui::{ EnvBearerAuthenticator, SessionAuthenticator, SignedTokenSessionStore, signed_session_store, @@ -74,7 +74,7 @@ struct SessionTokenMinter { } #[async_trait] -impl AdminApiTokenMinter for SessionTokenMinter { +impl ironclaw_product_contracts::admin_users::AdminApiTokenMinter for SessionTokenMinter { async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result { self.store .create_session( @@ -161,9 +161,10 @@ async fn build_admin_harness_from( // always accepted. let operator_secret = SecretString::from(OPERATOR_TOKEN.to_string()); let session_store = signed_session_store(&operator_secret, &tenant); - let minter: Arc = Arc::new(SessionTokenMinter { - store: session_store.clone(), - }); + let minter: Arc = + Arc::new(SessionTokenMinter { + store: session_store.clone(), + }); let input = RebornRuntimeInput::from_build_input(build_input) .with_identity(RebornRuntimeIdentity { diff --git a/crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs b/crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs index 3e387b0ce07..aaa0e31c02e 100644 --- a/crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs +++ b/crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs @@ -595,7 +595,7 @@ async fn build_runtime_with_slack_delivery( .with_bundled_first_party_for_test() .with_network_http_egress_for_test(slack_provider) .with_channel_extension_bindings(vec![ChannelExtensionBinding { - extension_id: "slack".to_string(), + extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, diff --git a/crates/ironclaw_reborn_identity/CONTRACT.md b/crates/ironclaw_reborn_identity/CONTRACT.md index 73b60c8ebce..867ddb085d7 100644 --- a/crates/ironclaw_reborn_identity/CONTRACT.md +++ b/crates/ironclaw_reborn_identity/CONTRACT.md @@ -11,13 +11,40 @@ This crate is **also the durable home of the minimal user profile** (email, display name, timestamps), not only an identity→`UserId` map. Resolving an identity persists a `StoredUser` record keyed by `UserId`, so "what do we know about this user, and where is it stored" is answered *here* — this is the only -user *profile* store in the Reborn stack. (It is not the only external-identity -*binding* store: `ironclaw_extension_host`'s `FilesystemChannelIdentityStore` -implements `ironclaw_host_api::RebornUserIdentityBindingStore` for channel -actors. That one binds; it does not own profiles.) Any future enumeration or -admin surface extends this store; it does not stand up a new one. See +user *profile* store in the Reborn stack. Any future enumeration or admin +surface extends this store; it does not stand up a new one. See [Persisted records](#persisted-records) for the exact shapes. +## Two external-identity stores, and the line between them + +The Reborn stack has **two** durable external-identity stores. They are not +rivals and neither is a migration target for the other; a reader who assumes +one is redundant will delete live behavior. The split is by *concern*: + +| | This crate — `identity_store` | `ironclaw_extension_host::channel_identity_store` | +|---|---|---| +| Owns | **Principal identity**: external subject → canonical `UserId`, plus the user profile and the verified-email index | **Post-OAuth channel binding**: `(provider, provider_user_id)` → user, plus an advisory by-user inverse index | +| Key | `(tenant, surface_kind, provider_kind, provider_instance, subject)` — five separate path segments | `(provider, provider_user_id)`, where `provider_user_id` is the installation-scoped composite from `ironclaw_host_api::user_identity` | +| Mints users? | Yes — `resolve_or_create` is the only user-minting path in the stack | Never. It binds an *already-authenticated* user | +| Ports | Owns its own trait (`RebornIdentityResolver`) | Implements `ironclaw_host_api::user_identity`'s three ports | +| Tenancy | Tenant is part of every key | One store instance is fixed to one tenant at construction | + +**The ports stay in `ironclaw_host_api`.** Moving them into this crate was +proposed by the target-architecture WS6 row and **refuted** (2026-08-04): the +sole production implementor is the channel identity store, this crate +implements none of the three ports, and since this crate already depends on +`ironclaw_host_api` — not the reverse — the move would force +`ironclaw_extension_host` to take a **new** dependency purely to name a port it +implements, separating a port from its implementor. + +**Channel actors are not bound here.** This crate rejects `ChannelActor` on +`resolve_or_create` (`RebornIdentityError::ChannelActorNotMintable`) and no +longer offers a binding path of its own: `ExternalIdentityKey` and +`RebornIdentityResolver::lookup` / `::bind` were retired in #5618 after audit +showed zero production callers, with the channel-binding role resolved onto the +store that actually serves it. What remains here for channel actors is the +fail-closed guard, which is deliberate. + ## Position in the stack Bottom-of-stack, downstream-facing. Among internal `ironclaw_*` crates it @@ -70,9 +97,15 @@ tracked as #5616.) email, or creates a new user. **`SurfaceKind::ChannelActor` is rejected** (`ChannelActorNotMintable`): channel actors are never mint-capable and must fail closed, not auto-provision. -- `lookup` — link-only; returns the bound user or `None`, never creates. -- `bind` — links an external identity to an **already-existing** user (upsert, - last-writer-wins). The caller must have authenticated the user first. +- ~~`lookup` — link-only; returns the bound user or `None`, never creates.~~ +- ~~`bind` — links an external identity to an **already-existing** user (upsert, + last-writer-wins). The caller must have authenticated the user first.~~ + **Both retired 2026-08-04 (#5618), with `ExternalIdentityKey`.** Neither had a + production caller and the key was absent from the composition facade, so no + downstream crate could construct one. Binding an already-authenticated user to + a channel identity is the channel identity store's job — and note its rule is + the *opposite* of the retired `bind`'s: it rejects a re-point with + `ProviderIdentityAlreadyBound` rather than upserting last-writer-wins. - `adopt_migrated_identity` — seeds a pre-existing identity carried from a legacy store, preserving its `user_id` and (for a verified email) the verified-email index. Never mints. Idempotent — existing identity/index records win. @@ -136,7 +169,8 @@ service can map it to a 404. `adopt_migrated_identity` writes identity-then-index (safe for its same-identity fast path; see the migration race note below). 4. **Channel actors never mint** — enforced at the top of `resolve_or_create`; - `bind`/`adopt_migrated_identity` take an explicit authenticated `user_id`. + `adopt_migrated_identity`, the only other write path, takes an explicit + authenticated `user_id` rather than minting one. 5. **`delete_user` cascades, and is the one sanctioned unwind of invariants 1/3.** Deleting a user removes, in order: every external-identity record in the tenant subtree bound to that `user_id` (walked iteratively over the @@ -194,8 +228,22 @@ unreferenced user rows is out of scope for this crate. Filed from the de-slop review: - **#5614** — cross-process divergent-email logins can split a principal. -- **#5615** — `bind()` has no OAuth-surface guard (defense-in-depth). +- ~~**#5615** — `bind()` has no OAuth-surface guard (defense-in-depth).~~ + **Closed 2026-08-04 by deletion**: the method it guards no longer exists (#5618). - **#5616** — `adopt_migrated_identity` never writes `StoredUser` and reverses the index/identity write order. - **#5617** — the login seam is tested only with fakes on both sides. -- **#5618** — decide the `ExternalIdentityKey` + `lookup`/`bind` public surface. +- ~~**#5618** — decide the `ExternalIdentityKey` + `lookup`/`bind` public + surface.~~ **Closed 2026-08-04: dropped.** Both trait methods and the key type + had zero production callers and the key was deliberately absent from the + composition facade, so downstream could not construct one; the channel-actor + path they were documented to serve is served by the channel identity store. + See "Two external-identity stores" above. #5615 (`bind()` has no OAuth-surface + guard) is closed by the same deletion — the method it guards is gone. + + One capability went with them and is recorded rather than lost: those methods + took the tenant **per call**, where the channel identity store fixes one + tenant per instance. Tenant keying of *this* store is unaffected + (`resolve_or_create` remains tenant-keyed and cross-tenant isolation is still + tested). If multi-tenant channel binding is ever required, the channel store's + shape — not this crate — is what needs revisiting. diff --git a/crates/ironclaw_reborn_identity/src/identity_store.rs b/crates/ironclaw_reborn_identity/src/identity_store.rs index 14c5e37ac83..4dd0427e233 100644 --- a/crates/ironclaw_reborn_identity/src/identity_store.rs +++ b/crates/ironclaw_reborn_identity/src/identity_store.rs @@ -47,10 +47,7 @@ use ironclaw_host_api::{ use serde::{Serialize, de::DeserializeOwned}; use uuid::Uuid; -use crate::{ - ExternalIdentityKey, RebornIdentityError, RebornIdentityResolver, ResolveExternalIdentity, - SurfaceKind, -}; +use crate::{RebornIdentityError, RebornIdentityResolver, ResolveExternalIdentity, SurfaceKind}; use paths::{identity_path, user_path, user_tombstone_path, verified_email_path}; use record::{ StoredExternalIdentity, StoredUser, StoredUserRole, StoredUserStatus, StoredUserTombstone, @@ -208,22 +205,6 @@ where .is_some()) } - /// Read the user already bound to an external identity, or `None`. - async fn identity_user( - &self, - tenant: &str, - surface: SurfaceKind, - provider: &str, - instance: &str, - subject: &str, - ) -> Result, RebornIdentityError> { - let path = identity_path(tenant, surface, provider, instance, subject)?; - match self.read_record::(&path).await? { - Some(record) => Ok(Some(to_user_id(record.user_id)?)), - None => Ok(None), - } - } - /// Write the identity record with `CasExpectation::Absent`; if a racing /// creator already wrote it, reconcile by returning the persisted user. async fn put_identity_reconciling( @@ -266,10 +247,12 @@ where &self, identity: ResolveExternalIdentity, ) -> Result { - // Channel actors are never mint-capable: the resolver contract routes - // them through lookup/bind so an unbound actor fails closed instead of - // auto-provisioning. Only OAuth-surface identities (admission gated up - // front by the email-domain allowlist) may mint here. + // Channel actors are never mint-capable, so an unbound actor fails + // closed here instead of auto-provisioning an account. Their binding is + // owned by `ironclaw_extension_host`'s channel identity store, not by + // this crate (CONTRACT.md, "Two external-identity stores"). Only + // OAuth-surface identities — admission gated up front by the + // email-domain allowlist — may mint here. if identity.surface_kind == SurfaceKind::ChannelActor { return Err(RebornIdentityError::ChannelActorNotMintable); } @@ -444,74 +427,6 @@ where .await } - async fn lookup( - &self, - key: ExternalIdentityKey, - ) -> Result, RebornIdentityError> { - let instance = key - .provider_instance_id - .as_ref() - .map(|value| value.as_str()) - .unwrap_or(""); - self.identity_user( - key.tenant_id.as_str(), - key.surface_kind, - key.provider_kind.as_str(), - instance, - key.external_subject_id.as_str(), - ) - .await - } - - async fn bind( - &self, - key: ExternalIdentityKey, - user_id: &UserId, - ) -> Result<(), RebornIdentityError> { - let instance = key - .provider_instance_id - .as_ref() - .map(|value| value.as_str()) - .unwrap_or(""); - let path = identity_path( - key.tenant_id.as_str(), - key.surface_kind, - key.provider_kind.as_str(), - instance, - key.external_subject_id.as_str(), - )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let lock = self.lock_for(format!("identity:{}", path.as_str())); - let _guard = lock.lock().await; - // Re-binding the same key re-points it at `user_id` (upsert). Channel - // actors carry no email, so the record stores none. - let record = StoredExternalIdentity { - user_id: user_id.as_str().to_string(), - email: None, - email_verified: false, - created_at: now, - }; - let cas = match self - .filesystem - .get(&self.scope, &path) - .await - .map_err(backend)? - { - Some(versioned) => CasExpectation::Version(versioned.version), - None => CasExpectation::Absent, - }; - match self.write_record(&path, &record, cas).await { - Ok(()) => Ok(()), - Err(FilesystemError::VersionMismatch { .. }) => { - // Lost a concurrent write; overwrite to honor re-point semantics. - self.write_record(&path, &record, CasExpectation::Any) - .await - .map_err(backend) - } - Err(error) => Err(backend(error)), - } - } - async fn adopt_migrated_identity( &self, identity: ResolveExternalIdentity, diff --git a/crates/ironclaw_reborn_identity/src/identity_store/tests.rs b/crates/ironclaw_reborn_identity/src/identity_store/tests.rs index 581ff120043..67d1bc71133 100644 --- a/crates/ironclaw_reborn_identity/src/identity_store/tests.rs +++ b/crates/ironclaw_reborn_identity/src/identity_store/tests.rs @@ -91,28 +91,21 @@ fn channel_actor( } } -fn channel_key(tenant: &TenantId, provider: &str, actor: &str) -> ExternalIdentityKey { - ExternalIdentityKey { - tenant_id: tenant.clone(), - surface_kind: SurfaceKind::ChannelActor, - provider_kind: ProviderKind::new(provider).expect("provider"), - provider_instance_id: None, - external_subject_id: ExternalSubjectId::new(actor).expect("actor"), - } -} - -fn channel_key_with_instance( +fn oauth_with_instance( tenant: &TenantId, provider: &str, instance: &str, - actor: &str, -) -> ExternalIdentityKey { - ExternalIdentityKey { + sub: &str, +) -> ResolveExternalIdentity { + ResolveExternalIdentity { tenant_id: tenant.clone(), - surface_kind: SurfaceKind::ChannelActor, + surface_kind: SurfaceKind::Oauth, provider_kind: ProviderKind::new(provider).expect("provider"), provider_instance_id: Some(ProviderInstanceId::new(instance).expect("instance")), - external_subject_id: ExternalSubjectId::new(actor).expect("actor"), + external_subject_id: ExternalSubjectId::new(sub).expect("subject"), + email: None, + email_verified: false, + display_name: None, } } @@ -247,27 +240,40 @@ async fn verified_email_link_is_tenant_scoped() { #[tokio::test] async fn different_provider_instance_does_not_collide() { - // provider_instance_id is part of the identity key: the same actor id - // under two adapter installations addresses two distinct paths, so a - // binding made under one installation is invisible under the other. + // `provider_instance_id` is part of the identity key: the same subject id + // under two provider installations addresses two distinct paths, so one + // never resolves to the other's user. + // + // Driven through `resolve_or_create` since #5618 retired `bind`/`lookup`. + // The key axis under test is unchanged — this is the same property, read + // off the surviving API. It uses the OAuth surface because channel actors + // are not mint-capable here at all (see `resolve_or_create_rejects_channel_actor`). let store = store(); let t = tenant("t"); - store - .bind( - channel_key_with_instance(&t, "telegram", "inst-1", "actor-7"), - &UserId::new("reborn-user-1").unwrap(), - ) + let under_first = store + .resolve_or_create(oauth_with_instance(&t, "google", "inst-1", "subject-7")) .await - .expect("bind"); - let under_other_instance = store - .lookup(channel_key_with_instance( - &t, "telegram", "inst-2", "actor-7", - )) + .expect("resolve under first installation"); + let under_second = store + .resolve_or_create(oauth_with_instance(&t, "google", "inst-2", "subject-7")) .await - .expect("lookup"); - assert!( - under_other_instance.is_none(), - "the same actor id under a different installation must not collide" + .expect("resolve under second installation"); + assert_ne!( + under_first.as_str(), + under_second.as_str(), + "the same subject id under a different installation must not collide" + ); + + // …and the axis is stable, not merely mint-happy: re-resolving the first + // key returns the first user rather than minting a third. + let first_again = store + .resolve_or_create(oauth_with_instance(&t, "google", "inst-1", "subject-7")) + .await + .expect("re-resolve under first installation"); + assert_eq!( + first_again.as_str(), + under_first.as_str(), + "re-resolving one installation's key must return that installation's user" ); } @@ -510,137 +516,26 @@ async fn adopt_migrated_identity_does_not_clobber_a_live_record() { ); } -#[tokio::test] -async fn lookup_unbound_actor_returns_none() { - let store = store(); - let resolved = store - .lookup(channel_key(&tenant("t"), "slack", "U-unbound")) - .await - .expect("lookup"); - assert!(resolved.is_none(), "an unbound actor must fail closed"); -} - -#[tokio::test] -async fn bind_then_lookup_returns_bound_user() { - let store = store(); - let t = tenant("t"); - let user = UserId::new("reborn-user-7").expect("user"); - store - .bind(channel_key(&t, "slack", "U-1"), &user) - .await - .expect("bind"); - let resolved = store - .lookup(channel_key(&t, "slack", "U-1")) - .await - .expect("lookup"); - assert_eq!(resolved.as_ref().map(UserId::as_str), Some("reborn-user-7")); -} - -#[tokio::test] -async fn rebind_repoints_to_new_user() { - let store = store(); - let t = tenant("t"); - store - .bind( - channel_key(&t, "slack", "U-1"), - &UserId::new("user-a").unwrap(), - ) - .await - .expect("first bind"); - store - .bind( - channel_key(&t, "slack", "U-1"), - &UserId::new("user-b").unwrap(), - ) - .await - .expect("rebind"); - let resolved = store - .lookup(channel_key(&t, "slack", "U-1")) - .await - .expect("lookup"); - assert_eq!( - resolved.as_ref().map(UserId::as_str), - Some("user-b"), - "re-binding the same key re-points it" - ); -} - -#[tokio::test] -async fn bind_is_scoped_per_tenant() { - let store = store(); - let user = UserId::new("user-a").expect("user"); - store - .bind(channel_key(&tenant("tenant-a"), "slack", "U-1"), &user) - .await - .expect("bind"); - let other = store - .lookup(channel_key(&tenant("tenant-b"), "slack", "U-1")) - .await - .expect("lookup"); - assert!( - other.is_none(), - "a binding in one tenant is invisible in another" - ); -} - -#[tokio::test] -async fn concurrent_rebind_converges_and_a_later_bind_repoints() { - // bind() reads the current version then writes with CAS::Version, falling - // through to a CAS::Any overwrite on VersionMismatch to honor re-point - // semantics under a lost race. Two processes (shared backend, independent - // lock maps, so the per-key lock does not serialize them) rebind the SAME - // channel key concurrently across several rounds to drive that overwrite - // branch: every bind must succeed (never surface VersionMismatch) and - // lookup must resolve to one of the two writers. A final explicit bind - // then re-points deterministically and must be observed. - let t = tenant("t"); - for round in 0..16 { - let (p1, p2) = store_pair(); - let (p1, p2) = (Arc::new(p1), Arc::new(p2)); - let observer = Arc::clone(&p1); - let (a, b) = (Arc::clone(&p1), Arc::clone(&p2)); - let (ka, kb) = ( - channel_key(&t, "slack", "U-1"), - channel_key(&t, "slack", "U-1"), - ); - let (ra, rb) = tokio::join!( - tokio::spawn(async move { a.bind(ka, &UserId::new("user-a").unwrap()).await }), - tokio::spawn(async move { b.bind(kb, &UserId::new("user-b").unwrap()).await }), - ); - ra.expect("join") - .unwrap_or_else(|err| panic!("round {round}: first concurrent bind errored: {err}")); - rb.expect("join") - .unwrap_or_else(|err| panic!("round {round}: second concurrent bind errored: {err}")); - - let raced = observer - .lookup(channel_key(&t, "slack", "U-1")) - .await - .expect("lookup after race") - .expect("a concurrent bind must leave the key bound"); - assert!( - matches!(raced.as_str(), "user-a" | "user-b"), - "round {round}: concurrent rebind must converge on a writer, got {}", - raced.as_str() - ); - - observer - .bind( - channel_key(&t, "slack", "U-1"), - &UserId::new("user-final").unwrap(), - ) - .await - .expect("final rebind"); - let resolved = observer - .lookup(channel_key(&t, "slack", "U-1")) - .await - .expect("lookup after final rebind"); - assert_eq!( - resolved.as_ref().map(UserId::as_str), - Some("user-final"), - "round {round}: a later explicit bind must re-point the key" - ); - } -} +// The five `bind`/`lookup` tests that stood here were deleted with the surface +// they covered (#5618): `lookup_unbound_actor_returns_none`, +// `bind_then_lookup_returns_bound_user`, `rebind_repoints_to_new_user`, +// `bind_is_scoped_per_tenant`, and +// `concurrent_rebind_converges_and_a_later_bind_repoints`. Every one drove code +// with no production caller. Two are worth naming rather than silently losing: +// +// * `rebind_repoints_to_new_user` pinned an *upsert* re-point. The store that +// actually binds channel identities in production asserts the OPPOSITE and +// fails closed with `ProviderIdentityAlreadyBound` +// (`ironclaw_extension_host::channel_identity_store`), which +// `channel_pairing` maps to `AlreadyBoundToOtherUser`. The retired +// semantic was contrary to the shipped contract, not a gap in it. +// * `bind_is_scoped_per_tenant` was the only per-call multi-tenant *binding* +// test. Tenant keying of this store is still covered through +// `resolve_or_create` (see the cross-tenant verified-email test above); +// what went with it is an implementation that took the tenant per call, +// where the channel identity store fixes one tenant per instance. If +// multi-tenant channel binding is ever needed, that is the shape to +// revisit — the retired implementation is in git history. #[tokio::test] async fn empty_verified_email_does_not_index_or_link() { @@ -719,7 +614,8 @@ async fn resolve_or_create_keys_on_provider_instance() { async fn corrupt_persisted_user_id_surfaces_invalid_user_id() { // A persisted identity record whose `user_id` fails `UserId` validation on // read-back must surface `InvalidUserId` (backend inconsistency), never be - // silently dropped. Drives both the `lookup` fast path and `resolve_or_create`. + // silently dropped. Driven through `resolve_or_create` — the `lookup` arm + // went with that method in #5618; the read path under test is the same one. let store = store(); let t = tenant("t"); let path = @@ -738,20 +634,6 @@ async fn corrupt_persisted_user_id_surfaces_invalid_user_id() { .await .expect("seed corrupt record"); - let via_lookup = store - .lookup(ExternalIdentityKey { - tenant_id: t.clone(), - surface_kind: SurfaceKind::Oauth, - provider_kind: ProviderKind::new("google").expect("provider"), - provider_instance_id: None, - external_subject_id: ExternalSubjectId::new("g-corrupt").expect("subject"), - }) - .await; - assert!( - matches!(via_lookup, Err(RebornIdentityError::InvalidUserId(_))), - "lookup of a corrupt persisted user id must surface InvalidUserId, got {via_lookup:?}" - ); - let via_resolve = store .resolve_or_create(oauth(&t, "google", "g-corrupt", Some("a@x.com"), true)) .await; @@ -764,7 +646,9 @@ async fn corrupt_persisted_user_id_surfaces_invalid_user_id() { #[tokio::test] async fn corrupt_json_body_surfaces_backend_error() { // A stored body that is not valid JSON for the record type must surface - // `Backend` (deserialize failure), not panic and not be swallowed. + // `Backend` (deserialize failure), not panic and not be swallowed. Driven + // through `resolve_or_create` — the `lookup` arm went with that method in + // #5618; the deserialize path under test is the same one. let store = store(); let t = tenant("t"); let path = @@ -781,13 +665,7 @@ async fn corrupt_json_body_surfaces_backend_error() { .expect("seed raw bytes"); let result = store - .lookup(ExternalIdentityKey { - tenant_id: t.clone(), - surface_kind: SurfaceKind::Oauth, - provider_kind: ProviderKind::new("google").expect("provider"), - provider_instance_id: None, - external_subject_id: ExternalSubjectId::new("g-badjson").expect("subject"), - }) + .resolve_or_create(oauth(&t, "google", "g-badjson", Some("a@x.com"), true)) .await; assert!( matches!(result, Err(RebornIdentityError::Backend(_))), diff --git a/crates/ironclaw_reborn_identity/src/lib.rs b/crates/ironclaw_reborn_identity/src/lib.rs index a4b2ea6942c..51dfeaafc50 100644 --- a/crates/ironclaw_reborn_identity/src/lib.rs +++ b/crates/ironclaw_reborn_identity/src/lib.rs @@ -1,16 +1,24 @@ -//! Canonical Reborn identity layer. +//! Canonical Reborn **principal** identity layer. //! -//! One boundary that maps every external identity — WebUI OAuth logins -//! (`google`, `github`, …) and external channel/product actors -//! (`telegram`, `slack`, triggers, …) — to a stable Reborn [`UserId`] +//! The boundary that maps an external identity to a stable Reborn [`UserId`] //! *before* any runtime state (conversation binding, thread ownership) is -//! touched. +//! touched, and the only path in the stack that **mints** a user. //! //! - Identity provisioning lives HERE, not in WebUI ingress and not in //! `ironclaw_conversations` (which stays lookup/binding-oriented and //! consumes an already-resolved `UserId`). -//! - WebUI OAuth and product/channel adapters feed normalized -//! [`ResolveExternalIdentity`] values into [`RebornIdentityResolver`]. +//! - WebUI OAuth feeds normalized [`ResolveExternalIdentity`] values into +//! [`RebornIdentityResolver`]. +//! - **Channel actors are not bound here.** `SurfaceKind::ChannelActor` is +//! rejected on `resolve_or_create` ([`RebornIdentityError::ChannelActorNotMintable`]) +//! and this crate offers no binding path of its own: post-OAuth channel +//! binding belongs to `ironclaw_extension_host`'s channel identity store, +//! behind the `ironclaw_host_api::user_identity` ports. The two stores and +//! the line between them are specified in `CONTRACT.md`, "Two +//! external-identity stores". `ExternalIdentityKey` and +//! `RebornIdentityResolver::{lookup, bind}` were retired in #5618 — they +//! had no production caller and the key was not even constructible +//! downstream. //! //! The external identity is keyed by `(tenant_id, surface_kind, //! provider_kind, provider_instance_id, external_subject_id)` so two @@ -92,18 +100,6 @@ pub struct ResolveExternalIdentity { pub display_name: Option, } -/// The identity-only key part of an external identity (no email / -/// profile). Used by the link-only [`lookup`](RebornIdentityResolver::lookup) -/// and [`bind`](RebornIdentityResolver::bind) paths that channel actors -/// (e.g. Slack) use, where there is no email and no minting. -pub struct ExternalIdentityKey { - pub tenant_id: TenantId, - pub surface_kind: SurfaceKind, - pub provider_kind: ProviderKind, - pub provider_instance_id: Option, - pub external_subject_id: ExternalSubjectId, -} - /// Failure modes of the canonical identity layer. #[derive(Debug, thiserror::Error)] pub enum RebornIdentityError { @@ -125,11 +121,15 @@ pub enum RebornIdentityError { #[error("user account is suspended: {0}")] UserSuspended(String), /// `resolve_or_create` was called for a `ChannelActor` identity. Channel - /// actors are never mint-capable — the resolver contract routes them - /// through [`lookup`](RebornIdentityResolver::lookup) / - /// [`bind`](RebornIdentityResolver::bind) so an unbound actor fails closed - /// instead of auto-provisioning a Reborn account. - #[error("channel-actor identities must resolve through lookup/bind, not resolve_or_create")] + /// actors are never mint-capable, and this crate does not bind them at + /// all: post-OAuth channel binding is owned by + /// `ironclaw_extension_host::channel_identity_store` behind the + /// `ironclaw_host_api::user_identity` ports (see `CONTRACT.md`, "Two + /// external-identity stores"). The guard keeps a channel actor from + /// auto-provisioning a Reborn account through this path. + #[error( + "channel-actor identities are bound by the channel identity store, not resolve_or_create" + )] ChannelActorNotMintable, } @@ -147,30 +147,14 @@ pub trait RebornIdentityResolver: Send + Sync { /// email-domain allowlist). A [`ChannelActor`](SurfaceKind::ChannelActor) /// identity is rejected with /// [`ChannelActorNotMintable`](RebornIdentityError::ChannelActorNotMintable): - /// channel actors are never mint-capable and must resolve through - /// [`lookup`](Self::lookup) / [`bind`](Self::bind). + /// channel actors are never mint-capable, and their binding is owned by + /// the channel identity store, not by this trait (`CONTRACT.md`, "Two + /// external-identity stores"). async fn resolve_or_create( &self, identity: ResolveExternalIdentity, ) -> Result; - /// Link-only lookup: return the user already bound to this external - /// identity, or `None`. NEVER creates a user. Channel actors (e.g. - /// Slack) resolve through this so an unbound actor fails closed - /// instead of auto-provisioning a Reborn account. - async fn lookup(&self, key: ExternalIdentityKey) - -> Result, RebornIdentityError>; - - /// Link an external identity to an ALREADY-EXISTING user (no user - /// creation). Re-binding the same key re-points it at `user_id`. The - /// caller must have authenticated `user_id` first (e.g. Slack personal - /// binding proves the actor is a known Reborn user before binding). - async fn bind( - &self, - key: ExternalIdentityKey, - user_id: &UserId, - ) -> Result<(), RebornIdentityError>; - /// Adopt a pre-existing external identity carried over from a legacy /// store, preserving BOTH its canonical `user_id` and its /// verified-email linkage. diff --git a/crates/ironclaw_reborn_openai_compat/Cargo.toml b/crates/ironclaw_reborn_openai_compat/Cargo.toml index ff6e6465ac3..0102aab1863 100644 --- a/crates/ironclaw_reborn_openai_compat/Cargo.toml +++ b/crates/ironclaw_reborn_openai_compat/Cargo.toml @@ -42,9 +42,6 @@ futures-core = { version = "0.3" } tokio = { version = "1", features = ["sync", "time"] } [dev-dependencies] -# Force `storage` on for this crate's own test builds so the ref-store contract -# suite runs under a bare `cargo test -p ironclaw_reborn_openai_compat`. -ironclaw_reborn_openai_compat = { path = "." } http = "1" http-body-util = "0.1" ironclaw_product = { path = "../ironclaw_product", version = "0.1.0", features = ["test-support"] } diff --git a/crates/ironclaw_reborn_openai_compat/src/lib.rs b/crates/ironclaw_reborn_openai_compat/src/lib.rs index c82768972d2..67946e54deb 100644 --- a/crates/ironclaw_reborn_openai_compat/src/lib.rs +++ b/crates/ironclaw_reborn_openai_compat/src/lib.rs @@ -86,8 +86,6 @@ pub use refs::{ OpenAiResponseId, unix_timestamp_now, }; pub use refs_storage::OpenAiCompatRefStore; -pub use refs_storage::RebornLibSqlOpenAiCompatRefStore; -pub use refs_storage::RebornPostgresOpenAiCompatRefStore; pub use responses::{ OpenAiResponseErrorObject, OpenAiResponseInputTokensDetails, OpenAiResponseObject, OpenAiResponseOutputItem, OpenAiResponseOutputItemStatus, OpenAiResponseStatus, diff --git a/crates/ironclaw_reborn_openai_compat/src/refs_storage.rs b/crates/ironclaw_reborn_openai_compat/src/refs_storage.rs index 98f8c9c5e3d..0161aaa5838 100644 --- a/crates/ironclaw_reborn_openai_compat/src/refs_storage.rs +++ b/crates/ironclaw_reborn_openai_compat/src/refs_storage.rs @@ -2,9 +2,10 @@ //! //! This module keeps persistence behind the //! [`OpenAiCompatRefStorePort`](crate::OpenAiCompatRefStorePort) -//! port. Contract-only consumers keep the default feature set; Reborn -//! composition enables `storage` when it needs the filesystem-backed adapter for -//! concrete route behavior. +//! port. There is exactly one adapter, [`OpenAiCompatRefStore`], and it is +//! backend-neutral: it holds an `Arc`, so composition picks +//! the concrete backend by profile. (This crate declares no cargo features; the +//! `storage`/`libsql`/`postgres` gating this doc used to describe is retired.) use std::sync::Arc; @@ -16,8 +17,6 @@ use crate::{ OpenAiCompatResourceBinding, OpenAiCompatResourceMapping, OpenAiCompatRouteSurface, }; use async_trait::async_trait; -use ironclaw_filesystem::LibSqlRootFilesystem; -use ironclaw_filesystem::PostgresRootFilesystem; use ironclaw_filesystem::{ CasExpectation, Entry, FilesystemError, RecordKind, RecordVersion, RootFilesystem, }; @@ -310,116 +309,6 @@ impl OpenAiCompatRefStore { Err(OpenAiCompatRefError::StoreUnavailable) } } -pub struct RebornLibSqlOpenAiCompatRefStore { - inner: OpenAiCompatRefStore, -} -impl RebornLibSqlOpenAiCompatRefStore { - pub fn new(filesystem: Arc) -> Self { - Self { - inner: OpenAiCompatRefStore::new(filesystem), - } - } - - pub fn with_root(filesystem: Arc, root: VirtualPath) -> Self { - Self { - inner: OpenAiCompatRefStore::with_root(filesystem, root), - } - } -} -#[async_trait] -impl OpenAiCompatRefStorePort for RebornLibSqlOpenAiCompatRefStore { - async fn reserve( - &self, - request: OpenAiCompatRefReservation, - ) -> Result { - self.inner.reserve(request).await - } - - async fn bind_internal_refs( - &self, - request: OpenAiCompatBindInternalRefs, - ) -> Result, OpenAiCompatRefError> { - self.inner.bind_internal_refs(request).await - } - - async fn record_accepted_ack( - &self, - request: OpenAiCompatRecordAcceptedAck, - ) -> Result, OpenAiCompatRefError> { - self.inner.record_accepted_ack(request).await - } - - async fn mark_external_tool_resume_completed( - &self, - request: OpenAiCompatMarkExternalToolResumeCompleted, - ) -> Result, OpenAiCompatRefError> { - self.inner - .mark_external_tool_resume_completed(request) - .await - } - - async fn lookup_authorized( - &self, - request: OpenAiCompatRefLookup, - ) -> Result, OpenAiCompatRefError> { - self.inner.lookup_authorized(request).await - } -} -pub struct RebornPostgresOpenAiCompatRefStore { - inner: OpenAiCompatRefStore, -} -impl RebornPostgresOpenAiCompatRefStore { - pub fn new(filesystem: Arc) -> Self { - Self { - inner: OpenAiCompatRefStore::new(filesystem), - } - } - - pub fn with_root(filesystem: Arc, root: VirtualPath) -> Self { - Self { - inner: OpenAiCompatRefStore::with_root(filesystem, root), - } - } -} -#[async_trait] -impl OpenAiCompatRefStorePort for RebornPostgresOpenAiCompatRefStore { - async fn reserve( - &self, - request: OpenAiCompatRefReservation, - ) -> Result { - self.inner.reserve(request).await - } - - async fn bind_internal_refs( - &self, - request: OpenAiCompatBindInternalRefs, - ) -> Result, OpenAiCompatRefError> { - self.inner.bind_internal_refs(request).await - } - - async fn record_accepted_ack( - &self, - request: OpenAiCompatRecordAcceptedAck, - ) -> Result, OpenAiCompatRefError> { - self.inner.record_accepted_ack(request).await - } - - async fn mark_external_tool_resume_completed( - &self, - request: OpenAiCompatMarkExternalToolResumeCompleted, - ) -> Result, OpenAiCompatRefError> { - self.inner - .mark_external_tool_resume_completed(request) - .await - } - - async fn lookup_authorized( - &self, - request: OpenAiCompatRefLookup, - ) -> Result, OpenAiCompatRefError> { - self.inner.lookup_authorized(request).await - } -} #[async_trait] impl OpenAiCompatRefStorePort for OpenAiCompatRefStore { diff --git a/crates/ironclaw_reborn_openai_compat/tests/ref_store_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/ref_store_contract.rs index d5f53a6add9..3d7f1f68e68 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/ref_store_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/ref_store_contract.rs @@ -1,4 +1,6 @@ -// The filesystem-backed ref store lives behind the storage feature. +// The filesystem-backed ref store is unconditional: this crate declares no +// cargo features, and `OpenAiCompatRefStore` holds an `Arc`, +// so the suite drives it over whichever backend it constructs. use std::sync::Arc; diff --git a/crates/ironclaw_reborn_traces/Cargo.toml b/crates/ironclaw_reborn_traces/Cargo.toml index be9cf942664..e003de89d4a 100644 --- a/crates/ironclaw_reborn_traces/Cargo.toml +++ b/crates/ironclaw_reborn_traces/Cargo.toml @@ -32,6 +32,9 @@ serde_json = "1" sha2 = "0.11" thiserror = "2" tokio = { version = "1", features = ["full"] } +# `CancellationToken` for the periodic queue-flush worker's shutdown handle +# (`capture::spawn_trace_queue_flush_worker`). +tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" uuid = { version = "1", features = ["v4", "v5", "serde"] } diff --git a/crates/ironclaw_reborn_traces/src/capture.rs b/crates/ironclaw_reborn_traces/src/capture.rs new file mode 100644 index 00000000000..48aaed8ea5a --- /dev/null +++ b/crates/ironclaw_reborn_traces/src/capture.rs @@ -0,0 +1,234 @@ +//! Autonomous capture pipeline: standing-policy gate, envelope build, queue, +//! immediate flush, and the periodic queue-flush worker. +//! +//! This is the half of turn-end trace capture that is pure Trace Commons — +//! consent policy, envelope scoring/redaction, queue/hold semantics, retry +//! cadence. It is keyed on a **scope string** and this crate's own +//! [`ConversationMessage`], so it names no turn, thread, or runtime type; the +//! observer that turns a terminal turn lifecycle event into those two inputs +//! is `ironclaw_runner::trace_capture` (PROPOSAL §6.10.1, WS6 — the eviction's +//! two named destinations are "`trace_commons` + the turn-runner observer +//! seam", and this is the `trace_commons` half). +//! +//! Capture must never block or fail whatever produced the turn: every entry +//! point here is infallible from the caller's perspective and logs at +//! `debug!` only (`info!`/`warn!` corrupt the REPL). +//! +//! Credit-notice delivery (v1 broadcasts via `ChannelManager`) is +//! intentionally not wired here yet: there is no outbound notification +//! surface at this tier. The notice outbox still accumulates on disk and is +//! delivered when the same scope runs under the v1 binary; a Reborn-native +//! delivery path is a follow-up. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::ConversationMessage; +use crate::client::{ + TraceClientAutonomousCaptureOutcome, TraceClientAutonomousCaptureRequest, TraceClientHost, + TraceClientScope, +}; +use crate::contribution::{self as trace, resolve_effective_capture_policy}; + +/// Recent-transcript bound, mirroring v1 (last 24 messages, max 5 turns). +pub const CAPTURE_MESSAGE_LIMIT: usize = 24; +/// Per-envelope turn bound, mirroring v1. +pub const CAPTURE_MAX_TURNS: usize = 5; +/// Immediate flush limit after queueing one envelope (v1 parity). +const CAPTURE_FLUSH_LIMIT: usize = 10; +/// Periodic queue-flush cadence and per-scope limit (v1 parity). +const TRACE_QUEUE_WORKER_INTERVAL: Duration = Duration::from_secs(300); +const TRACE_QUEUE_WORKER_FLUSH_LIMIT: usize = 25; + +/// Scopes whose queues the periodic worker flushes. Seeded with the runtime +/// owner and extended with every scope seen at capture time. Queued items for +/// scopes not seen since boot only flush on that scope's next turn — this tier +/// has no user directory to enumerate (v1 lists active users from its +/// database). +pub type ObservedTraceScopes = Arc>>; + +/// Record `scope` as having produced capturable work, so the periodic worker +/// retries its queue. +pub fn record_observed_scope(observed_scopes: &ObservedTraceScopes, scope: &str) { + let mut scopes = match observed_scopes.lock() { + Ok(scopes) => scopes, + Err(poisoned) => poisoned.into_inner(), + }; + scopes.insert(scope.to_string()); +} + +/// One capture's best-effort pipeline: resolve the effective standing policy, +/// build the envelope, then queue (and immediately try to flush) it. +/// +/// Errors never propagate — every exit is a `debug!` line keyed by the +/// pseudonymous contributor ref, never raw content. +/// +/// `scope` is the tenant-scoped trace state key (see +/// [`trace::trace_scope_key`]); `task_failed` marks the transcript's terminal +/// outcome as a failure, which the caller knows and the transcript does not. +pub async fn capture_conversation_trace( + scope: &str, + messages: &[ConversationMessage], + task_failed: bool, +) { + if messages.is_empty() { + return; + } + let scope_ref = trace::local_pseudonymous_contributor_id(scope); + // Gate on the EFFECTIVE enrollment (personal-invite OR admin-provisioned + // instance), mirroring the flush gate: an instance-only-enrolled user has no + // enabled per-user policy, so a per-user-only check would drop their turns + // before queueing — leaving the instance-aware flush nothing to submit. The + // resolver returns the governing (and always-enabled) policy, or None when + // the scope is enrolled in neither. + let policy = match resolve_effective_capture_policy(Some(scope)) { + Ok(Some(policy)) => policy, + Ok(None) => return, + Err(error) => { + tracing::debug!(%error, %scope_ref, "Reborn trace capture could not resolve policy"); + return; + } + }; + + let outcome = TraceClientHost + .prepare_autonomous_envelope_from_messages(TraceClientAutonomousCaptureRequest { + scope: TraceClientScope::user(scope.to_string()), + // The lifecycle event does not identify the product surface + // (REPL/WebUI/channel) behind the turn, so the channel is the + // honest catch-all rather than a guess. + channel: trace::TraceChannel::Other, + messages, + policy: &policy, + max_turns: CAPTURE_MAX_TURNS, + // Reborn thread transcripts carry no structured outcome payload; + // the lifecycle event's terminal status is authoritative. + outcome_override: task_failed.then_some(trace::TaskSuccess::Failure), + }) + .await; + match outcome { + Ok(TraceClientAutonomousCaptureOutcome::Submit(envelope)) => { + let trace_scope = TraceClientScope::user(scope.to_string()); + if let Err(error) = TraceClientHost.queue_envelope_for_scope(&trace_scope, &envelope) { + tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to queue envelope"); + return; + } + if let Err(error) = TraceClientHost + .flush_scope_queue(&trace_scope, CAPTURE_FLUSH_LIMIT) + .await + { + tracing::debug!(%error, %scope_ref, "Reborn trace queue flush failed; worker retries"); + } + } + Ok(TraceClientAutonomousCaptureOutcome::Held { + kind, + reason, + envelope, + }) => { + let submission_id = envelope.submission_id; + // Only manual-review holds (e.g. High residual-PII-risk) are + // retained for the user to authorize. Policy/value gates (low + // score, disallowed tools) are not review-worthy and are dropped + // as before — just logged for diagnostics. + if !matches!(kind, trace::TraceQueueHoldKind::ManualReview) { + tracing::debug!( + %submission_id, + %reason, + %scope_ref, + "Reborn trace capture held by policy gate (dropped)" + ); + return; + } + // Retain: queue with a ManualReview hold sidecar so the flush + // worker skips it until it is authorized. + let trace_scope = TraceClientScope::user(scope.to_string()); + if let Err(error) = + TraceClientHost.queue_held_envelope_for_scope(&trace_scope, &envelope, &reason) + { + tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to retain held envelope"); + return; + } + tracing::debug!( + %submission_id, + %reason, + %scope_ref, + "Reborn trace capture held for manual review (retained)" + ); + } + Ok(TraceClientAutonomousCaptureOutcome::Skipped) => {} + Err(error) => { + tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to build envelope"); + } + } +} + +/// Handle for the periodic queue-flush worker. +pub struct TraceQueueFlushWorkerHandle { + cancel: CancellationToken, + handle: JoinHandle<()>, +} + +impl TraceQueueFlushWorkerHandle { + pub async fn shutdown(self) { + self.cancel.cancel(); + if let Err(error) = self.handle.await { + tracing::debug!(%error, "Reborn trace queue flush worker did not shut down cleanly"); + } + } +} + +/// Periodic queue flush, mirroring v1's 300s worker: retries envelopes whose +/// immediate flush failed (network blips, endpoint downtime) for every scope +/// observed since boot. +pub fn spawn_trace_queue_flush_worker( + observed_scopes: ObservedTraceScopes, +) -> TraceQueueFlushWorkerHandle { + let cancel = CancellationToken::new(); + let worker_cancel = cancel.clone(); + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(TRACE_QUEUE_WORKER_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // The first tick fires immediately; consume it so the first flush + // happens one full interval after boot. + interval.tick().await; + loop { + tokio::select! { + _ = worker_cancel.cancelled() => break, + _ = interval.tick() => {} + } + let scopes: Vec = { + let scopes = match observed_scopes.lock() { + Ok(scopes) => scopes, + Err(poisoned) => poisoned.into_inner(), + }; + scopes.iter().cloned().collect() + }; + if scopes.is_empty() { + continue; + } + if let Err(error) = TraceClientHost + .flush_queue_worker_tick(scopes.clone(), TRACE_QUEUE_WORKER_FLUSH_LIMIT) + .await + { + tracing::debug!(%error, "Reborn trace queue worker tick failed"); + } + // Prune drained scopes so the observed set stays bounded by actual + // pending backlog, not by every caller ever seen on this runtime. A + // scope with no flushable queue entries is dropped; its next turn + // re-adds it via `record_observed_scope`. Scopes that still hold + // pending work (e.g. a flush that hit the per-tick limit, or an + // endpoint that's down) are retained so the next tick retries them. + { + let mut observed = match observed_scopes.lock() { + Ok(observed) => observed, + Err(poisoned) => poisoned.into_inner(), + }; + observed.retain(|scope| trace::trace_scope_has_pending_queue(scope.as_str())); + } + } + }); + TraceQueueFlushWorkerHandle { cancel, handle } +} diff --git a/crates/ironclaw_reborn_traces/src/lib.rs b/crates/ironclaw_reborn_traces/src/lib.rs index 17a63d17f32..dfecf69bee6 100644 --- a/crates/ironclaw_reborn_traces/src/lib.rs +++ b/crates/ironclaw_reborn_traces/src/lib.rs @@ -6,6 +6,7 @@ //! `ConversationMessage` type that the legacy monolith's `history` module now //! re-exports for backward compatibility. +pub mod capture; pub mod client; pub mod contribution; pub mod conversation_message; diff --git a/crates/ironclaw_runner/Cargo.toml b/crates/ironclaw_runner/Cargo.toml index 0ed3e03d0cf..e4993029c2f 100644 --- a/crates/ironclaw_runner/Cargo.toml +++ b/crates/ironclaw_runner/Cargo.toml @@ -49,6 +49,10 @@ ironclaw_safety = { path = "../ironclaw_safety", version = "0.2.2" } ironclaw_filesystem = { path = "../ironclaw_filesystem", version = "0.1.0" } ironclaw_threads = { path = "../ironclaw_threads", version = "0.1.0" } ironclaw_loop_contracts = { path = "../ironclaw_loop_contracts", version = "0.1.0" } +# `trace_capture` is the turn-runner observer half of autonomous Trace Commons +# capture (PROPOSAL §6.10.1, WS6): it adapts a terminal turn's transcript and +# hands it to `ironclaw_reborn_traces::capture`, which owns the pipeline. +ironclaw_reborn_traces = { path = "../ironclaw_reborn_traces", version = "0.1.0" } ironclaw_turns = { path = "../ironclaw_turns", version = "0.1.0" } libsql = { version = "0.9", default-features = false, features = ["core", "replication", "remote", "tls"] } parking_lot = "0.12" @@ -57,6 +61,8 @@ serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } +# Message ids for the captured-transcript adaptation in `trace_capture`. +uuid = { version = "1", features = ["v4"] } tracing = "0.1" [dev-dependencies] diff --git a/crates/ironclaw_runner/src/lib.rs b/crates/ironclaw_runner/src/lib.rs index 5baac230017..59086c3ade0 100644 --- a/crates/ironclaw_runner/src/lib.rs +++ b/crates/ironclaw_runner/src/lib.rs @@ -45,6 +45,7 @@ pub mod runtime; pub mod steering_reconcile; pub mod subagent; pub mod text_loop_driver; +pub mod trace_capture; pub mod turn_run_executor; pub mod turn_runner; pub mod turn_scheduler; diff --git a/crates/ironclaw_reborn_composition/src/observability/trace_capture.rs b/crates/ironclaw_runner/src/trace_capture.rs similarity index 80% rename from crates/ironclaw_reborn_composition/src/observability/trace_capture.rs rename to crates/ironclaw_runner/src/trace_capture.rs index 3c14507699c..0a64de37675 100644 --- a/crates/ironclaw_reborn_composition/src/observability/trace_capture.rs +++ b/crates/ironclaw_runner/src/trace_capture.rs @@ -1,64 +1,42 @@ -//! Autonomous Trace Commons turn-end capture for the Reborn runtime. +//! Turn-runner observer seam for autonomous Trace Commons capture. //! -//! Mirrors the v1 binary's turn-end capture (`src/agent/thread_ops.rs:: -//! spawn_autonomous_trace_contribution`) and periodic queue flush -//! (`src/agent/agent_loop.rs::spawn_trace_queue_flush_worker`): every terminal -//! turn lifecycle event spawns a detached best-effort task that reads the -//! owner's standing contribution policy, captures the recent thread -//! transcript, redacts and scores it locally, and queues + flushes eligible -//! envelopes. Non-enrolled users pay one policy-file read per turn and -//! nothing else. +//! Every terminal turn lifecycle event spawns a detached best-effort task that +//! reads the owner's thread transcript, adapts it into the neutral +//! [`ConversationMessage`] shape, and hands it to +//! [`ironclaw_reborn_traces::capture`], which owns the consent policy, +//! envelope build, queue and flush. +//! +//! This module is the *observer* half of that split (PROPOSAL §6.10.1, WS6: +//! "trace capture ... -> `trace_commons` + the turn-runner observer seam"). It +//! holds exactly what needs turn and thread vocabulary — the [`TurnEventSink`] +//! implementation, the history-read port, and the record-to-message +//! adaptation — and nothing about what Trace Commons then does with the +//! transcript. Neither half can hold the other: `ironclaw_reborn_traces` is a +//! `substrates` crate and `ironclaw_turns` is `kernel`. //! //! Capture must never block or fail the turn lifecycle path: the sink is //! subscribed best-effort and all work happens on a spawned task whose //! errors are logged at `debug!` only (`info!`/`warn!` corrupt the REPL). -//! -//! Credit-notice delivery (v1 broadcasts via `ChannelManager`) is -//! intentionally not wired here yet: the composition layer has no outbound -//! notification surface. The notice outbox still accumulates on disk and is -//! delivered when the same scope runs under the v1 binary; a Reborn-native -//! delivery path is a follow-up. -use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use ironclaw_reborn_traces::ConversationMessage; -use ironclaw_reborn_traces::client::{ - TraceClientAutonomousCaptureOutcome, TraceClientAutonomousCaptureRequest, TraceClientHost, - TraceClientScope, +use ironclaw_reborn_traces::capture::{ + CAPTURE_MESSAGE_LIMIT, ObservedTraceScopes, capture_conversation_trace, record_observed_scope, }; -use ironclaw_reborn_traces::contribution::{self as trace, resolve_effective_capture_policy}; +use ironclaw_reborn_traces::contribution as trace; use ironclaw_threads::{ ContextWindow, LoadContextWindowRequest, MessageKind, MessageStatus, SessionThreadError, SessionThreadService, ThreadHistoryRequest, ThreadMessageId, ThreadMessageRecord, ThreadScope, }; use ironclaw_turns::{TurnError, TurnEventKind, TurnEventSink, TurnLifecycleEvent}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -/// Recent-transcript bound, mirroring v1 (last 24 messages, max 5 turns). -const CAPTURE_MESSAGE_LIMIT: usize = 24; -const CAPTURE_MAX_TURNS: usize = 5; -/// Immediate flush limit after queueing one envelope (v1 parity). -const CAPTURE_FLUSH_LIMIT: usize = 10; -/// Periodic queue-flush cadence and per-scope limit (v1 parity). -const TRACE_QUEUE_WORKER_INTERVAL: Duration = Duration::from_secs(300); -const TRACE_QUEUE_WORKER_FLUSH_LIMIT: usize = 25; - -/// Scopes whose queues the periodic worker flushes. Seeded with the runtime -/// owner and extended with every scope seen at capture time. Queued items for -/// scopes not seen since boot only flush on that scope's next turn — the -/// composition layer has no user directory to enumerate (v1 lists active -/// users from its database). -pub(crate) type ObservedTraceScopes = Arc>>; /// Narrow history-read seam so tests don't have to fake the full /// [`SessionThreadService`] surface. #[async_trait] -pub(crate) trait TraceCaptureHistorySource: Send + Sync { +pub trait TraceCaptureHistorySource: Send + Sync { async fn thread_history_messages( &self, request: ThreadHistoryRequest, @@ -124,13 +102,13 @@ fn context_window_to_records(window: ContextWindow) -> Vec .collect() } -pub(crate) struct TraceCaptureTurnEventSink { +pub struct TraceCaptureTurnEventSink { history: Arc, observed_scopes: ObservedTraceScopes, } impl TraceCaptureTurnEventSink { - pub(crate) fn new( + pub fn new( thread_service: Arc, observed_scopes: ObservedTraceScopes, ) -> Self { @@ -179,114 +157,23 @@ impl TurnEventSink for TraceCaptureTurnEventSink { } } -fn record_observed_scope(observed_scopes: &ObservedTraceScopes, scope: &str) { - let mut scopes = match observed_scopes.lock() { - Ok(scopes) => scopes, - Err(poisoned) => poisoned.into_inner(), - }; - scopes.insert(scope.to_string()); -} - -/// One turn's best-effort capture. Errors never propagate — every exit is a +/// One turn's best-effort capture: read the transcript, adapt it, and hand it +/// to the Trace Commons pipeline. Errors never propagate — every exit is a /// `debug!` line keyed by the pseudonymous contributor ref, never raw content. -pub(crate) async fn capture_turn_trace( +pub async fn capture_turn_trace( history: Arc, event: TurnLifecycleEvent, scope: String, ) { let scope_ref = trace::local_pseudonymous_contributor_id(&scope); - // Gate on the EFFECTIVE enrollment (personal-invite OR admin-provisioned - // instance), mirroring the flush gate: an instance-only-enrolled user has no - // enabled per-user policy, so a per-user-only check would drop their turns - // before queueing — leaving the instance-aware flush nothing to submit. The - // resolver returns the governing (and always-enabled) policy, or None when - // the scope is enrolled in neither. - let policy = match resolve_effective_capture_policy(Some(scope.as_str())) { - Ok(Some(policy)) => policy, - Ok(None) => return, - Err(error) => { - tracing::debug!(%error, %scope_ref, "Reborn trace capture could not resolve policy"); - return; - } - }; - let Some(messages) = load_capture_messages(&history, &event, &scope_ref).await else { return; }; - if messages.is_empty() { - return; - } - + // The transcript carries no structured outcome; the lifecycle event's + // terminal status is authoritative, and it is the one thing the pipeline + // cannot derive for itself. let turn_failed = matches!(event.kind, TurnEventKind::Failed); - let outcome = TraceClientHost - .prepare_autonomous_envelope_from_messages(TraceClientAutonomousCaptureRequest { - scope: TraceClientScope::user(scope.clone()), - // The lifecycle event does not identify the product surface - // (REPL/WebUI/channel) behind the turn, so the channel is the - // honest catch-all rather than a guess. - channel: trace::TraceChannel::Other, - messages: &messages, - policy: &policy, - max_turns: CAPTURE_MAX_TURNS, - // Reborn thread transcripts carry no structured outcome payload; - // the lifecycle event's terminal status is authoritative. - outcome_override: turn_failed.then_some(trace::TaskSuccess::Failure), - }) - .await; - match outcome { - Ok(TraceClientAutonomousCaptureOutcome::Submit(envelope)) => { - let trace_scope = TraceClientScope::user(scope.clone()); - if let Err(error) = TraceClientHost.queue_envelope_for_scope(&trace_scope, &envelope) { - tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to queue envelope"); - return; - } - if let Err(error) = TraceClientHost - .flush_scope_queue(&trace_scope, CAPTURE_FLUSH_LIMIT) - .await - { - tracing::debug!(%error, %scope_ref, "Reborn trace queue flush failed; worker retries"); - } - } - Ok(TraceClientAutonomousCaptureOutcome::Held { - kind, - reason, - envelope, - }) => { - let submission_id = envelope.submission_id; - // Only manual-review holds (e.g. High residual-PII-risk) are - // retained for the user to authorize. Policy/value gates (low - // score, disallowed tools) are not review-worthy and are dropped - // as before — just logged for diagnostics. - if !matches!(kind, trace::TraceQueueHoldKind::ManualReview) { - tracing::debug!( - %submission_id, - %reason, - %scope_ref, - "Reborn trace capture held by policy gate (dropped)" - ); - return; - } - // Retain: queue with a ManualReview hold sidecar so the flush - // worker skips it until it is authorized. - let trace_scope = TraceClientScope::user(scope.clone()); - if let Err(error) = - TraceClientHost.queue_held_envelope_for_scope(&trace_scope, &envelope, &reason) - { - tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to retain held envelope"); - return; - } - tracing::debug!( - %submission_id, - %reason, - %scope_ref, - "Reborn trace capture held for manual review (retained)" - ); - } - Ok(TraceClientAutonomousCaptureOutcome::Skipped) => {} - Err(error) => { - tracing::debug!(%error, %scope_ref, "Reborn trace capture failed to build envelope"); - } - } + capture_conversation_trace(&scope, &messages, turn_failed).await; } async fn load_capture_messages( @@ -440,75 +327,12 @@ fn tool_call_capture_json( serde_json::Value::Object(entry) } -pub(crate) struct TraceQueueFlushWorkerHandle { - cancel: CancellationToken, - handle: JoinHandle<()>, -} - -impl TraceQueueFlushWorkerHandle { - pub(crate) async fn shutdown(self) { - self.cancel.cancel(); - if let Err(error) = self.handle.await { - tracing::debug!(%error, "Reborn trace queue flush worker did not shut down cleanly"); - } - } -} - -/// Periodic queue flush, mirroring v1's 300s worker: retries envelopes whose -/// immediate flush failed (network blips, endpoint downtime) for every scope -/// observed since boot. -pub(crate) fn spawn_trace_queue_flush_worker( - observed_scopes: ObservedTraceScopes, -) -> TraceQueueFlushWorkerHandle { - let cancel = CancellationToken::new(); - let worker_cancel = cancel.clone(); - let handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(TRACE_QUEUE_WORKER_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // The first tick fires immediately; consume it so the first flush - // happens one full interval after boot. - interval.tick().await; - loop { - tokio::select! { - _ = worker_cancel.cancelled() => break, - _ = interval.tick() => {} - } - let scopes: Vec = { - let scopes = match observed_scopes.lock() { - Ok(scopes) => scopes, - Err(poisoned) => poisoned.into_inner(), - }; - scopes.iter().cloned().collect() - }; - if scopes.is_empty() { - continue; - } - if let Err(error) = TraceClientHost - .flush_queue_worker_tick(scopes.clone(), TRACE_QUEUE_WORKER_FLUSH_LIMIT) - .await - { - tracing::debug!(%error, "Reborn trace queue worker tick failed"); - } - // Prune drained scopes so the observed set stays bounded by actual - // pending backlog, not by every caller ever seen on this runtime. A - // scope with no flushable queue entries is dropped; its next turn - // re-adds it via `record_observed_scope`. Scopes that still hold - // pending work (e.g. a flush that hit the per-tick limit, or an - // endpoint that's down) are retained so the next tick retries them. - { - let mut observed = match observed_scopes.lock() { - Ok(observed) => observed, - Err(poisoned) => poisoned.into_inner(), - }; - observed.retain(|scope| trace::trace_scope_has_pending_queue(scope.as_str())); - } - } - }); - TraceQueueFlushWorkerHandle { cancel, handle } -} - #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use std::sync::Mutex; + use std::time::Duration; + use ironclaw_host_api::ids::{CapabilityId, UserId}; use ironclaw_threads::{ProviderToolCallReferenceEnvelope, ThreadMessageId}; use ironclaw_turns::{EventCursor, TurnRunId, TurnScope, TurnStatus}; diff --git a/crates/ironclaw_skills/AGENTS.md b/crates/ironclaw_skills/AGENTS.md index bfbb0964741..49c164366ab 100644 --- a/crates/ironclaw_skills/AGENTS.md +++ b/crates/ironclaw_skills/AGENTS.md @@ -11,10 +11,17 @@ ## What This Crate Owns -- Skill metadata parsing (`parser`), validation (`validation`), deterministic gating/scoring/selection (`gating`, `selector`), registry operations (`registry`), catalog lookup (`catalog`), pure learning distillation/refinement logic (`learning`), and trust-aware v1 skill type definitions (`types`). -- V2 engine skill types (`v2`): `V2SkillMetadata`, `CodeSnippet`, `SkillMetrics`, `SkillRevision`/`SkillRepairRecord` — serialized into `MemoryDoc.metadata` by the engine crate. +- Skill metadata parsing (`parser`), validation (`validation`), deterministic scoring/selection (`selector`), filesystem management and its mount-scoped port (`management`, `scoped_management`), installed-skill records (`install_metadata`), pure learning distillation/refinement logic (`learning`), and the skill type definitions (`types`). - Crate-local public API, tests, and fixtures needed to prove that ownership. +> ✎ **Corrected 2026-08-04 (WS6 domain-internal cleanups).** This section previously +> claimed modules `gating`, `registry` and `catalog`, and a `v2` module exporting +> `V2SkillMetadata` / `CodeSnippet` / `SkillMetrics` / `SkillRevision` / +> `SkillRepairRecord` "serialized into `MemoryDoc.metadata` by the engine crate". +> **None of those modules or symbols exists** — `rg` over `crates/` matched only +> this file — and `ironclaw_engine` was deleted. The module list above is the real +> `src/` contents. + ## Do Not Move In Here - Concrete prompt execution, LLM/runtime adapters, tool authorization, extension runtime dispatch, credential handling, channel UI, or ClawHub server behavior. @@ -31,5 +38,5 @@ - Skill selection must stay deterministic: no ambient time, network, or filesystem effects in scoring. - Skill learning must stay pure: `learning` owns prompts, parsing, and the `SkillInferencePort` abstraction only; composition owns concrete inference adapters, scoped writes, and notifications. -- Installed skills are lower-trust than user/workspace skills; preserve tool-ceiling attenuation. -- Add caller-level tests when parser or gating changes affect prompt assembly or tool exposure. +- Installed skills are lower-trust than user/workspace skills. What that trust gates is **content exposure** (prompt body vs. safe description only), decided by `ironclaw_loop_contracts::skill_context::SkillTrustLevel` — not tool access, which `ironclaw_authorization` / `ironclaw_capabilities` own. Preserve the `Installed < Trusted` ordering `SkillTrust` derives. +- Add caller-level tests when parser or selection changes affect prompt assembly or skill-content exposure. diff --git a/crates/ironclaw_skills/src/lib.rs b/crates/ironclaw_skills/src/lib.rs index 764d3069f4b..b7800e5095b 100644 --- a/crates/ironclaw_skills/src/lib.rs +++ b/crates/ironclaw_skills/src/lib.rs @@ -1,18 +1,41 @@ -//! Skill types, parsing, selection, and management for IronClaw. +//! Skill types, parsing, selection, learning, and management for IronClaw. //! //! Skills are SKILL.md files (YAML frontmatter + markdown prompt) that extend the -//! agent's behavior through prompt-level instructions. This crate provides the core -//! types, SKILL.md parser, and filesystem management. +//! agent's behavior through prompt-level instructions. This is a `substrates`-layer +//! domain crate: pure skill logic over `ironclaw_filesystem` + +//! `ironclaw_host_api`, with no runtime, loop, or product dependency. //! -//! # Trust Model +//! # Modules //! -//! Skills have two trust states that determine their authority: -//! - **Trusted**: User-placed skills (local/workspace) with full tool access -//! - **Installed**: Registry/external skills, restricted to read-only tools +//! - [`types`] — manifests, activation criteria, trust levels, loaded skills. +//! - [`parser`](self) (private; re-exported) — the SKILL.md parser +//! ([`parse_skill_md`]) for the OpenClaw skill format. +//! - [`selector`](self) (private; re-exported) — the *deterministic* prefilter for +//! two-phase selection: no LLM involvement and no skill content in context, so +//! a skill cannot influence its own selection. +//! - [`management`] / [`scoped_management`] — install / list / read / remove / +//! search / update, over the raw filesystem and over a mount-scoped port. +//! - [`install_metadata`] — the on-disk record written for an installed skill. +//! - [`learning`] — distilling a reusable SKILL.md out of a completed run's +//! transcript. Pure domain logic: inference sits behind `SkillInferencePort`, +//! and the result is validated with the same parser install uses. +//! - [`validation`] — name validation, path-pattern checks, content escaping. //! -//! In v1, trust-based tool filtering happens via `src/skills/attenuation.rs`. -//! In v2, the Python orchestrator handles trust labels and the policy engine -//! controls tool access via capability leases. +//! # Trust model +//! +//! [`SkillTrust`] has two states, and the ordering (`Installed < Trusted`) is +//! load-bearing: +//! +//! - **Trusted** — user-placed skills (local / workspace). +//! - **Installed** — registry / external skills. +//! +//! **What trust gates is content exposure, not tool access.** The consuming side +//! is `ironclaw_loop_contracts::skill_context::SkillTrustLevel` (which mirrors +//! this enum deliberately, rather than depending on this crate), and it decides +//! whether the model sees a skill's prompt body or only its safe description. +//! Tool authority is a separate, unrelated mechanism owned by +//! `ironclaw_authorization` / `ironclaw_capabilities`; nothing in this crate +//! filters tools. pub mod install_metadata; pub mod learning; diff --git a/crates/ironclaw_skills/src/types.rs b/crates/ironclaw_skills/src/types.rs index 9084c3557c7..ef925ba7cd9 100644 --- a/crates/ironclaw_skills/src/types.rs +++ b/crates/ironclaw_skills/src/types.rs @@ -34,18 +34,24 @@ const MIN_KEYWORD_TAG_LENGTH: usize = 3; /// Maximum file size for SKILL.md (64 KiB). pub const MAX_PROMPT_FILE_SIZE: u64 = 64 * 1024; -/// Trust state for a skill, determining its authority ceiling. +/// Trust state for a skill. +/// +/// What it gates is **content exposure**, not tool access: the consuming side is +/// `ironclaw_loop_contracts::skill_context::SkillTrustLevel`, which decides +/// whether the model sees a skill's prompt body or only its safe description. +/// Tool authority is owned by `ironclaw_authorization` / `ironclaw_capabilities` +/// and has nothing to do with this enum. /// /// SAFETY: Variant ordering matters. `Ord` is derived from discriminant values /// and the security model relies on `Installed < Trusted`. Do NOT reorder /// variants or change discriminant values without auditing all `min()` / -/// comparison call-sites in attenuation code. +/// comparison call-sites that take a trust ceiling. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SkillTrust { - /// Registry/external skill. Read-only tools only. + /// Registry/external skill. Lower trust: safe description only. Installed = 0, - /// User-placed skill (local or workspace). Full trust, all tools available. + /// User-placed skill (local or workspace). Full trust: prompt body visible. Trusted = 1, } diff --git a/crates/ironclaw_triggers/src/fire_access.rs b/crates/ironclaw_triggers/src/fire_access.rs new file mode 100644 index 00000000000..7de94cfc466 --- /dev/null +++ b/crates/ironclaw_triggers/src/fire_access.rs @@ -0,0 +1,336 @@ +//! Fire-time trigger access: the check contract, and the checkers that are +//! pure trigger-scope policy. +//! +//! Trigger-fire authorization is not a persisted parallel access table (it +//! replaced `ironclaw_runner::local_trigger_access`, arch-simplification §4.4). +//! It is a decision about *this crate's own noun* — may the user who created a +//! persisted trigger still fire it for the exact tenant/agent/project scope +//! stored on it — so the contract and the scope comparison live beside the +//! trigger record and the worker that consults them, not in the assembly root +//! (CHECKLIST WS6, PROPOSAL §6.10.1: "approval/authorization/trigger-fire +//! policy → … `triggers`"). +//! +//! Two things deliberately stay in `ironclaw_reborn_composition`: +//! +//! - **`TriggerFireAccessPolicy`/`TriggerFireAccessGrant`** — the deployment +//! config value the `serve`/`run` edge resolves and the build turns into a +//! checker. §6.10.1's Keeps list names "deployment config-as-data" as +//! composition's charter, and this is that. +//! - **The identity-directory checker** — resolving tenant membership at fire +//! time is a lookup against a backend composition selects +//! (`RebornUserDirectory`); an adapter over a chosen backend is assembly, and +//! moving it here would buy this crate a dependency on the identity crate to +//! hold one `get_user` call. +//! +//! What is here is the part with no backend at all: the request/decision +//! vocabulary, the exact-scope comparison, and the OR-combinator. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_host_api::{ + Timestamp, + ids::{AgentId, ProjectId, TenantId, UserId}, +}; + +use crate::TriggerId; + +const DENY_REASON: &str = "trigger creator does not have active access for this scope"; + +/// Fire-time access request for a persisted trigger. +/// +/// Checks are exact: `None` for `agent_id` or `project_id` means the trigger +/// has no value for that scope dimension, not that the checker should treat it +/// as a wildcard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TriggerFireAccessCheck { + /// Tenant that owns the persisted trigger. + pub tenant_id: TenantId, + /// User that created the persisted trigger and whose access is evaluated + /// again at fire time. + pub creator_user_id: UserId, + /// Optional agent scope stored on the trigger. + pub agent_id: Option, + /// Optional project scope stored on the trigger. + pub project_id: Option, + /// Trigger being fired. Included so production access checks can audit or + /// apply trigger-specific policy without changing this request shape. + pub trigger_id: TriggerId, + /// Deterministic fire slot being submitted. Included for audit and policy + /// decisions that depend on scheduled fire identity. + pub fire_slot: Timestamp, +} + +/// Result of a fire-time trigger access check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TriggerFireAccessDecision { + /// The trigger creator is still authorized for the exact trigger scope. + Allowed, + /// The trigger creator is not authorized for the exact trigger scope. + Denied { reason: String }, +} + +/// Error returned when the access backend cannot answer the request. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TriggerFireAccessError { + /// The backing access source was unavailable; trigger fire handling should + /// treat this as retryable rather than a permanent denial. + #[error("trigger fire access backend unavailable: {reason}")] + Unavailable { reason: String }, +} + +/// Fire-time trigger access checker. The composition root selects and wires the +/// implementations from its deployment policy. +#[async_trait] +pub trait TriggerFireAccessChecker: Send + Sync { + /// Check whether the persisted trigger creator may fire the trigger for + /// the exact stored tenant/agent/project scope. + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result; +} + +/// Does the fire-time check's exact scope match the granted `(agent, project)` +/// grant? Scope is exact — `None` project means "no project", never a wildcard +/// (matches [`TriggerFireAccessCheck`] semantics). +fn scope_matches( + check: &TriggerFireAccessCheck, + agent: &AgentId, + project: &Option, +) -> bool { + check.agent_id.as_ref() == Some(agent) && &check.project_id == project +} + +/// Deny with this crate's single fire-access denial reason. Public so the +/// composition-owned identity-directory checker renders the same string as the +/// checkers here — the reason is trigger policy, not per-adapter wording. +pub fn trigger_fire_access_denied() -> TriggerFireAccessDecision { + TriggerFireAccessDecision::Denied { + reason: DENY_REASON.to_string(), + } +} + +/// Does this check's scope match the granted `(agent, project)` pair? Exposed +/// for the same reason as [`trigger_fire_access_denied`]: the exact-scope rule +/// is trigger policy and every checker must apply the identical one. +pub fn trigger_fire_scope_matches( + check: &TriggerFireAccessCheck, + agent: &AgentId, + project: &Option, +) -> bool { + scope_matches(check, agent, project) +} + +/// A single configured owner may fire triggers for one exact scope — the +/// env-token `serve` and CLI `run` owner grant. Pure comparison, no I/O. +/// +/// The `tenant_id` bound is load-bearing: the due-trigger repository is global, +/// so a fire-time check that matched only owner + scope could authorize a +/// foreign tenant's trigger whose creator id happened to equal this owner. The +/// former store keyed every row on tenant; this preserves that. +pub struct StaticOwnerTriggerFireChecker { + tenant_id: TenantId, + owner: UserId, + agent: AgentId, + project: Option, +} + +impl StaticOwnerTriggerFireChecker { + pub fn new( + tenant_id: TenantId, + owner: UserId, + agent: AgentId, + project: Option, + ) -> Self { + Self { + tenant_id, + owner, + agent, + project, + } + } +} + +#[async_trait] +impl TriggerFireAccessChecker for StaticOwnerTriggerFireChecker { + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result { + let allowed = request.tenant_id == self.tenant_id + && request.creator_user_id == self.owner + && scope_matches(&request, &self.agent, &self.project); + Ok(if allowed { + TriggerFireAccessDecision::Allowed + } else { + trigger_fire_access_denied() + }) + } +} + +/// OR-combines several checkers: `Allowed` if any grant allows; otherwise +/// `Unavailable` if any grant's backend was unavailable (retryable, so a +/// transient identity-store fault is not a hard denial); otherwise `Denied`. +pub struct CompositeTriggerFireChecker { + checkers: Vec>, +} + +impl CompositeTriggerFireChecker { + pub fn new(checkers: Vec>) -> Self { + Self { checkers } + } +} + +#[async_trait] +impl TriggerFireAccessChecker for CompositeTriggerFireChecker { + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result { + // Split so the last checker takes `request` by move — no redundant + // final clone (the common case is a single StaticOwner + SsoMembership + // pair, so this saves one clone per fire). + let Some((last, rest)) = self.checkers.split_last() else { + return Ok(trigger_fire_access_denied()); + }; + let mut unavailable: Option = None; + for checker in rest { + match checker.check_trigger_fire_access(request.clone()).await { + Ok(TriggerFireAccessDecision::Allowed) => { + return Ok(TriggerFireAccessDecision::Allowed); + } + Ok(TriggerFireAccessDecision::Denied { .. }) => {} + Err(error) => unavailable = Some(error), + } + } + match last.check_trigger_fire_access(request).await { + Ok(TriggerFireAccessDecision::Allowed) => Ok(TriggerFireAccessDecision::Allowed), + Ok(TriggerFireAccessDecision::Denied { .. }) => match unavailable { + Some(error) => Err(error), + None => Ok(trigger_fire_access_denied()), + }, + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(creator: &str, agent: Option<&str>, project: Option<&str>) -> TriggerFireAccessCheck { + TriggerFireAccessCheck { + tenant_id: TenantId::new("tenant").expect("tenant"), + creator_user_id: UserId::new(creator).expect("user"), + agent_id: agent.map(|a| AgentId::new(a).expect("agent")), + project_id: project.map(|p| ProjectId::new(p).expect("project")), + trigger_id: TriggerId::new(), + fire_slot: chrono::Utc::now(), + } + } + + fn static_checker() -> StaticOwnerTriggerFireChecker { + StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + ) + } + + #[tokio::test] + async fn static_owner_allows_exact_owner_and_scope() { + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn static_owner_denies_non_owner() { + let decision = static_checker() + .check_trigger_fire_access(check("intruder", Some("agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn static_owner_denies_scope_mismatch() { + // Right owner, wrong project scope. + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), Some("other"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + // Right owner, missing project where one was granted. + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), None)) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn static_owner_denies_foreign_tenant() { + // The due-trigger repository is global: a foreign tenant's trigger with + // a matching owner id + scope must NOT be authorized (regression guard). + let foreign = TriggerFireAccessCheck { + tenant_id: TenantId::new("other-tenant").expect("tenant"), + creator_user_id: UserId::new("owner").expect("user"), + agent_id: Some(AgentId::new("agent").expect("agent")), + project_id: Some(ProjectId::new("project").expect("project")), + trigger_id: TriggerId::new(), + fire_slot: chrono::Utc::now(), + }; + let decision = static_checker() + .check_trigger_fire_access(foreign) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn composite_allows_if_any_grant_allows() { + // Two static owners; only the second matches the creator. + let checkers: Vec> = vec![ + Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-a").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + )), + Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-b").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + )), + ]; + let composite = CompositeTriggerFireChecker::new(checkers); + let decision = composite + .check_trigger_fire_access(check("owner-b", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn composite_denies_if_no_grant_allows() { + let checkers: Vec> = + vec![Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-a").expect("user"), + AgentId::new("agent").expect("agent"), + None, + ))]; + let composite = CompositeTriggerFireChecker::new(checkers); + let decision = composite + .check_trigger_fire_access(check("stranger", Some("agent"), None)) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } +} diff --git a/crates/ironclaw_triggers/src/lib.rs b/crates/ironclaw_triggers/src/lib.rs index 97d85cc8cf8..23c0a95e960 100644 --- a/crates/ironclaw_triggers/src/lib.rs +++ b/crates/ironclaw_triggers/src/lib.rs @@ -26,6 +26,7 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use ulid::Ulid; mod automation; +mod fire_access; mod in_memory; mod libsql; mod postgres; @@ -37,6 +38,15 @@ mod worker; /// `ironclaw_common`, which must hold no domain vocabulary); `MAX_TRIGGER_NAME_BYTES` /// below is the same bound under this crate's own noun. pub use automation::{AutomationName, AutomationNameError, MAX_AUTOMATION_NAME_BYTES}; +/// Fire-time access: the check contract plus the checkers that are pure +/// trigger-scope policy. The deployment *grant* value and the identity-directory +/// checker stay in the composition root — see `fire_access`'s module doc +/// (CHECKLIST WS6 / PROPOSAL §6.10.1). +pub use fire_access::{ + CompositeTriggerFireChecker, StaticOwnerTriggerFireChecker, TriggerFireAccessCheck, + TriggerFireAccessChecker, TriggerFireAccessDecision, TriggerFireAccessError, + trigger_fire_access_denied, trigger_fire_scope_matches, +}; pub use ironclaw_host_api::outbound::OutboundDeliveryTargetId as TriggerDeliveryTargetId; pub use trusted_submit::{ TRIGGER_TRUSTED_ADAPTER_INSTALLATION_ID, TRIGGER_TRUSTED_ADAPTER_KIND, diff --git a/crates/ironclaw_triggers/tests/repository_contract.rs b/crates/ironclaw_triggers/tests/repository_contract.rs index 7cd843da794..564411f15ca 100644 --- a/crates/ironclaw_triggers/tests/repository_contract.rs +++ b/crates/ironclaw_triggers/tests/repository_contract.rs @@ -1670,6 +1670,28 @@ async fn assert_malformed_row_error( "expected malformed row to report {expected_field}, got {error:?}" ); } +/// Record that the Postgres leg of the parity matrix is being skipped. +/// +/// Skipping is legitimate on a developer machine without Docker, but a skip +/// must never masquerade as a green full-matrix run: this suite is the *only* +/// enforcement of libSQL⇄PostgreSQL behavioural parity for the hand-written +/// trigger SQL (ADR 0003), so a silently-skipped Postgres leg means the parity +/// claim that ADR rests on went unproven while CI reported success. +/// +/// `IRONCLAW_REQUIRE_POSTGRES=1` therefore turns every skip into a HARD +/// failure. This mirrors `crates/ironclaw_hooks/tests/parity_matrix.rs`, which +/// already carries the same switch for the same reason. +fn skip_postgres_or_fail(reason: &str) -> Option { + assert!( + std::env::var("IRONCLAW_REQUIRE_POSTGRES").is_err(), + "IRONCLAW_REQUIRE_POSTGRES is set but the Postgres trigger repository leg \ + cannot run: {reason}. The libSQL⇄PostgreSQL parity this suite proves (ADR \ + 0003) would go unverified, so this is a hard failure rather than a skip." + ); + eprintln!("skipping Postgres trigger repository tests: {reason}"); + None +} + async fn postgres_pool_or_skip() -> Option<( testcontainers_modules::testcontainers::ContainerAsync< testcontainers_modules::postgres::Postgres, @@ -1677,10 +1699,7 @@ async fn postgres_pool_or_skip() -> Option<( deadpool_postgres::Pool, )> { if std::env::var("IRONCLAW_SKIP_POSTGRES_TESTS").is_ok() { - eprintln!( - "skipping Postgres trigger repository tests: IRONCLAW_SKIP_POSTGRES_TESTS is set" - ); - return None; + return skip_postgres_or_fail("IRONCLAW_SKIP_POSTGRES_TESTS is set"); } // Test-only bootstrap: production composition must pass a constructed pool @@ -1695,8 +1714,7 @@ async fn postgres_pool_or_skip() -> Option<( .build() .expect("Postgres pool must build"); if let Err(error) = pool.get().await { - eprintln!("skipping Postgres trigger repository tests: database unavailable ({error})"); - return None; + return skip_postgres_or_fail(&format!("database unavailable ({error})")); } Some((container, pool)) } @@ -1717,28 +1735,19 @@ async fn start_postgres_container() -> Option<( let container = match image.start().await { Ok(container) => container, Err(error) => { - eprintln!( - "skipping Postgres trigger repository tests: docker/testcontainers unavailable ({error})" - ); - return None; + return skip_postgres_or_fail(&format!("docker/testcontainers unavailable ({error})")); } }; let host = match container.get_host().await { Ok(host) => host, Err(error) => { - eprintln!( - "skipping Postgres trigger repository tests: could not resolve container host ({error})" - ); - return None; + return skip_postgres_or_fail(&format!("could not resolve container host ({error})")); } }; let port = match container.get_host_port_ipv4(5432).await { Ok(port) => port, Err(error) => { - eprintln!( - "skipping Postgres trigger repository tests: could not resolve container port ({error})" - ); - return None; + return skip_postgres_or_fail(&format!("could not resolve container port ({error})")); } }; Some(( diff --git a/crates/ironclaw_webui/CLAUDE.md b/crates/ironclaw_webui/CLAUDE.md index a89a17c33e5..88bc452ee37 100644 --- a/crates/ironclaw_webui/CLAUDE.md +++ b/crates/ironclaw_webui/CLAUDE.md @@ -97,6 +97,70 @@ turning the `webui_v2_routes()` descriptors into tower layers. | `UserDirectory` trait | Host-supplied mapping from `(provider, OAuthUserProfile)` to `UserId` | | `EmailUserDirectory` | Standalone default impl (verified email → `UserId`); gated on `test-support` | +## `handlers.rs` module-charter map + +`src/webui_v2/handlers.rs` is **4,593 lines** and carries a live +`// arch-exempt: large_file` waiver naming plan #5985 (the WebUI route split). +This map is **not** that split and does not discharge that waiver — PROPOSAL +§6.4.15 calls this shape "module-charter work, **not a split**", and §6.9.1 +asks for a "module-charter map … the audited ≥11 sub-owners". It says which +concern a change belongs to *while the file is still one file*, so the eventual +split has a decided seam list instead of an argument. + +**This table is enforced.** `tests/handlers_module_charter.rs` asserts every +top-level item in `handlers.rs` and its `handlers/` submodules appears in +exactly one row, that every name in a row still exists, and that no name is +claimed twice — so a new handler fails until it is given an owner and a deleted +one fails until its entry goes. + +**Owners are conceptual, not positional.** A concern may hold more than one +region of the file (`threads` holds two, split by `admin-users`), because +making the regions contiguous would mean moving code, which is the split this +row explicitly is not. When the split does land, each row below is one +candidate module. + +| Sub-owner | Owns | Never contains | Items | +|---|---|---|---| +| `session` | The session-bootstrap response and the feature flags it carries | A durable read — bootstrap must stay cheap and non-blocking | `GLOBAL_AUTO_APPROVE_FEATURE_TIMEOUT`, `WebUiV2SessionResponse`, `WebUiV2Features`, `get_session`, `global_auto_approve_enabled` | +| `threads` | Thread lifecycle, message send, and timeline/thread reads | Run control (that is `runs`) or transport (that is `streaming`) | `create_thread`, `delete_thread`, `send_message`, `get_timeline`, `TimelineQuery`, `list_threads`, `ListThreadsQuery` | +| `admin-users` | Admin user CRUD, role/status, and per-user secrets; parsing `{user_id}`/`{handle}` into domain types at the edge | Authorization logic — the service enforces admin authorization and last-admin protection | `parse_admin_user_id`, `parse_admin_secret_handle`, `read_admin_user_secret`, `admin_list_users`, `admin_create_user`, `admin_get_user`, `admin_update_user`, `admin_delete_user`, `admin_set_user_status`, `admin_set_user_role`, `admin_list_user_secrets`, `admin_put_user_secret`, `admin_delete_user_secret` | +| `workspace-fs` | Project-file and mount-catalog reads, and the workspace path-scoping rules that keep a served path inside its projection | Attachment download (that is `attachments`) | `PROJECT_FS_ROOT`, `ProjectFsQuery`, `list_project_files`, `stat_project_file`, `read_project_file`, `project_fs_download_response`, `FsBrowseQuery`, `list_fs_mounts`, `browse_fs_dir`, `stat_fs_path`, `read_fs_file`, `require_fs_browse_path`, `workspace_scoped_projection_required`, `workspace_projection_for`, `workspace_served_path`, `strip_workspace_prefix`, `project_fs_list_path`, `require_project_fs_path` | +| `projects` | Project CRUD and project membership | Project *files* — those are `workspace-fs` | `ListProjectsQuery`, `list_projects`, `create_project`, `get_project`, `update_project`, `delete_project`, `list_project_members`, `add_project_member`, `update_project_member`, `remove_project_member`, `read_project_member` | +| `attachments` | Attachment download and the filename sanitizing that download depends on | A filesystem path rule — that is `workspace-fs` | `MAX_DOWNLOAD_FILENAME_BYTES`, `sanitized_download_filename`, `get_attachment` | +| `streaming` | Both live transports and everything that shapes a frame: SSE poll/keepalive tuning, capacity and concurrency rejection, cursor tokens, the envelope→event mapping, and the WebSocket drain loop | A product decision — a stream carries what the surface already produced | `SSE_POLL_INTERVAL`, `SSE_IDLE_POLL_MAX_INTERVAL`, `SSE_KEEPALIVE_INTERVAL`, `LAST_EVENT_ID_HEADER`, `sse_poll_interval_for_idle_polls`, `stream_events`, `sse_capacity_rejected`, `sse_concurrency_exhausted`, `StreamEventsQuery`, `stream_connection_id`, `SseErrorPayload`, `webchat_sse_event_from_envelope`, `sse_error_event`, `sse_keep_alive_event`, `build_sse_stream`, `parse_cursor_token`, `cursor_token`, `stream_events_ws`, `ws_drain_loop`, `ws_send_with_timeout` | +| `runs` | Run control: cancel, retry, and gate resolution | Anything that reads a run — that is `threads` or `streaming` | `cancel_run`, `CancelRunPath`, `resolve_gate`, `ResolveGatePath`, `retry_run`, `RetryRunPath` | +| `commands` | The product command surface: listing and executing | A command *constant* — those are `ironclaw_product`'s frozen inventory | `list_commands`, `ExecuteCommandBody`, `execute_command` | +| `automations` | Automation listing and lifecycle (pause/resume/rename/delete) | Trigger evaluation — that is the triggers domain | `list_automations`, `pause_automation`, `resume_automation`, `rename_automation`, `delete_automation`, `ListAutomationsQuery` | +| `traces` | Trace credits, account traces, the account login link, and hold authorization | Trace *content* — that is `ironclaw_reborn_traces` | `trace_credits`, `trace_account_traces`, `trace_account_login_link`, `authorize_trace_hold` | +| `outbound` | Outbound notification preferences, delivery targets, and the capability-failure→HTTP classification they introduced | Delivery itself — the host owns the coordinator | `get_outbound_preferences`, `set_outbound_preferences`, `CapabilityFailureHttpClass`, `capability_failure_http_class`, `capability_failure_bad_request`, `capability_resolution_succeeded`, `parse_thread_id_for_response`, `outbound_preferences_forbidden`, `outbound_preferences_unavailable`, `list_outbound_delivery_targets`, `outbound_preferences_activity_id` | +| `skills` | Skill discovery, install/update/remove, content reads, and auto-activation | Skill *selection* — that is `ironclaw_skills` | `list_skills`, `search_skills`, `install_skill`, `get_skill_content`, `update_skill`, `remove_skill`, `set_skill_auto_activate`, `set_auto_activate_learned`, `skill_mutation_succeeded`, `skill_mutation_forbidden`, `skill_mutation_unavailable`, `SkillPath`, `SearchSkillsBody`, `InstallSkillBody`, `UpdateSkillBody`, `SetSkillAutoActivateBody` | +| `extensions` | Extension listing, registry browse, install/import/remove, hosted-MCP registration, the setup handshake, and the lifecycle response projections | Admin *configuration* of an installed extension — that is `admin-config` | `list_extensions`, `list_extension_registry`, `install_extension`, `register_hosted_mcp_extension`, `import_extension`, `ironhub_deliver_install`, `remove_extension`, `extension_lifecycle_mutation_succeeded`, `extension_install_succeeded`, `membership_is_visible`, `membership_landed_pending_setup`, `ensure_extension_inventory_readback`, `extension_lifecycle_forbidden`, `extension_lifecycle_unavailable`, `extension_action_completed`, `get_extension_setup`, `setup_extension`, `public_lifecycle_json`, `extension_lifecycle_activity_id`, `ExtensionPackagePath`, `InstallExtensionBody`, `RegisterHostedMcpBody`, `RegisterHostedMcpResponse`, `bounded_hosted_mcp_name`, `RemoveExtensionBody`, `extension_package_ref_for_request` | +| `admin-config` | Per-extension admin configuration: read, replace, idempotency, and its failure projections | Extension lifecycle — that is `extensions` | `ADMIN_CONFIGURATION_IDEMPOTENCY_KEY_MAX_BYTES`, `require_operator_webui_config`, `ExtensionAdminConfigurationPath`, `ExtensionAdminConfigurationValue`, `ReplaceExtensionAdminConfigurationBody`, `ReplaceExtensionAdminConfigurationInput`, `list_extension_admin_configuration`, `replace_extension_admin_configuration`, `query_extension_admin_configuration`, `select_extension_admin_configuration_group`, `admin_configuration_activity_id`, `admin_configuration_conflict`, `admin_configuration_unavailable`, `admin_configuration_forbidden`, `admin_configuration_done_failure`, `admin_configuration_blocked` | +| `dispatch` | The shared `ProductSurface` call shapes every other owner goes through: invoke/query/page helpers, the generic activity-id derivation, and idempotency/client-action-id validation | A route-specific decision — those belong to the owner that made them | `CLIENT_ACTION_ID_MAX_BYTES`, `product_surface_input`, `invoke_product_capability`, `invoke_product_capability_with_activity_id`, `invoke_product_command`, `product_capability_activity_id`, `product_surface_activity_id`, `query_product_view`, `query_product_page`, `decode_product_outbound_events`, `validate_idempotency_key`, `parse_client_action_id` | +| `operator` | The operator console: first-run setup, tool settings, operator config keys, diagnostics, status, logs, and service lifecycle | LLM provider administration — that is `llm-admin` | `SETTINGS_TOOLS_AUTO_APPROVE_KEY`, `SETTINGS_TOOL_CONFIG_PREFIX`, `SETTINGS_TOOL_CAPABILITY_ID_MAX_BYTES`, `get_operator_setup`, `query_operator_setup_response`, `run_operator_setup`, `list_settings_tools`, `SettingsToolsAutoApproveRequest`, `set_settings_tools_auto_approve`, `SettingsToolPermissionPath`, `SettingsToolPermissionRequest`, `set_settings_tool_permission`, `validate_settings_tool_capability_id`, `validate_settings_tool_config_response`, `list_operator_config`, `OperatorConfigKeyPath`, `OPERATOR_CONFIG_KEY_MAX_BYTES`, `OPERATOR_CONFIG_RESERVED_VALIDATE_KEY`, `validate_operator_config_key`, `operator_config_key_error`, `query_operator_config_key_response`, `get_operator_config_key`, `set_operator_config_key`, `reject_reserved_operator_config_key`, `validate_operator_config`, `get_operator_diagnostics`, `get_operator_status`, `query_operator_logs`, `query_logs`, `run_operator_service_lifecycle` | +| `llm-admin` | LLM provider administration and the provider login flows: config snapshot, upsert/delete, active-model selection, connection test, model listing, NEAR AI and Codex login | Anything that *calls* a model | `LlmProviderPath`, `get_llm_config`, `query_llm_config_snapshot`, `upsert_llm_provider`, `delete_llm_provider`, `set_active_llm`, `test_llm_connection`, `list_llm_models`, `start_nearai_login`, `complete_nearai_wallet_login`, `start_codex_login`, `llm_provider_upsert_activity_id` | +| `run-artifact` | Run and thread artifact reads — already its own file, the one seam plan #5985 has taken so far | Anything not artifact-shaped | `handlers/run_artifact.rs::RunArtifactPath`, `handlers/run_artifact.rs::ThreadArtifactPath`, `handlers/run_artifact.rs::query_single`, `handlers/run_artifact.rs::get_run_artifact`, `handlers/run_artifact.rs::get_thread_artifact` | + +Three placement calls worth stating, because each is an item whose *name* +suggests one owner and whose *use* is another: + +- **`*_activity_id` helpers split three ways.** `product_capability_activity_id` + and `product_surface_activity_id` are `dispatch` (they are the generic + derivation every owner reaches). `extension_lifecycle_activity_id`, + `llm_provider_upsert_activity_id`, `outbound_preferences_activity_id` and + `admin_configuration_activity_id` are charged to the concern whose request + shape they read, because each one knows that concern's fields. +- **`capability_failure_http_class` is `outbound`, not `dispatch`**, even though + the name reads generic: it is the classification the outbound-preferences + routes introduced and its callers are all in that owner. If a second concern + starts calling it, it moves to `dispatch` — which is the trigger, stated in + advance rather than argued later. +- **`get_attachment` is `attachments`, not `workspace-fs`.** Both serve bytes, + but attachment identity is a thread-scoped ref, not a mount path, and the + path-scoping rules in `workspace-fs` do not apply to it. Keeping them apart + is what stops a future path-scoping fix from being assumed to cover + attachment downloads. + ## WebChat v2 route surface (folded from `ironclaw_webui_v2`) Handlers consume only `ironclaw_product_contracts::surface::ProductSurface`. The bearer diff --git a/crates/ironclaw_webui/tests/handlers_module_charter.rs b/crates/ironclaw_webui/tests/handlers_module_charter.rs new file mode 100644 index 00000000000..8f1fe54ee76 --- /dev/null +++ b/crates/ironclaw_webui/tests/handlers_module_charter.rs @@ -0,0 +1,295 @@ +//! The `handlers.rs` module-charter map in `CLAUDE.md` is a contract, not a +//! comment. +//! +//! `src/webui_v2/handlers.rs` is the largest file in this crate and carries a +//! live `// arch-exempt: large_file` waiver naming plan #5985. The map is **not** +//! that split and does not discharge that waiver — PROPOSAL §6.4.15 calls this +//! shape "module-charter work, **not a split**", and §6.9.1 asks for a +//! "module-charter map … the audited ≥11 sub-owners". Its job is to say which +//! concern a change belongs to *while the file is still one file*, so the +//! eventual split inherits a decided seam list instead of an argument. +//! +//! A map nobody checks rots faster than the file it describes: handlers get +//! added without an owner, and rows survive the handlers they name. This gate +//! pins both directions, at **item** granularity rather than line ranges — +//! owners here are conceptual, not positional (a concern may hold more than one +//! region, and `threads` does), and a line-range gate would demand exactly the +//! code movement this row is not. +//! +//! It deliberately checks coverage and existence, not prose. Whether +//! `get_attachment` is really `attachments` rather than `workspace-fs` is a +//! review question; whether every item has exactly one owner is a mechanical +//! one, and that is what is enforced. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn handlers_dir() -> PathBuf { + crate_root().join("src/webui_v2") +} + +/// Every top-level item declared in a Rust source file, in declaration order. +/// +/// "Top-level" is column zero: items nested inside a function, an `impl`, or a +/// `mod` block are that item's business, not the charter's. +fn top_level_items(source: &str) -> Vec { + let mut out = Vec::new(); + for line in source.lines() { + if line.starts_with(' ') || line.starts_with('\t') || line.is_empty() { + continue; + } + let rest = line + .strip_prefix("pub(crate) ") + .or_else(|| line.strip_prefix("pub ")) + .unwrap_or(line); + let rest = rest.strip_prefix("unsafe ").unwrap_or(rest); + let rest = rest.strip_prefix("async ").unwrap_or(rest); + let Some((keyword, tail)) = rest.split_once(' ') else { + continue; + }; + if !matches!( + keyword, + "fn" | "struct" | "enum" | "trait" | "type" | "const" | "static" + ) { + continue; + } + let name: String = tail + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + out.push(name); + } + } + out +} + +/// Every chartable item, keyed the way `CLAUDE.md` names it: bare for +/// `handlers.rs`, `handlers/.rs::` for a submodule. +fn charted_surface() -> Vec { + let dir = handlers_dir(); + let main = std::fs::read_to_string(dir.join("handlers.rs")).expect("read handlers.rs"); + let mut out = top_level_items(&main); + + let submodules = dir.join("handlers"); + let mut paths: Vec = std::fs::read_dir(&submodules) + .expect("read handlers/ submodule directory") + .map(|entry| entry.expect("dir entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + paths.sort(); + for path in paths { + let file = path + .file_name() + .expect("submodule file name") + .to_string_lossy() + .to_string(); + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + for name in top_level_items(&source) { + out.push(format!("handlers/{file}::{name}")); + } + } + out +} + +/// Parse the `## \`handlers.rs\` module-charter map` table into +/// `item -> [sub-owner, ...]`. +/// +/// An item listed under two sub-owners keeps both entries so the caller can +/// report the ambiguity rather than silently taking the last one. +fn charter_assignments() -> BTreeMap> { + let doc = std::fs::read_to_string(crate_root().join("CLAUDE.md")).expect("read CLAUDE.md"); + let section = doc + .split("## `handlers.rs` module-charter map") + .nth(1) + .expect("CLAUDE.md must contain a '## `handlers.rs` module-charter map' section"); + // Stop at the next top-level heading so neighbouring tables are not read — + // this file sits immediately above the 92-row frozen route table. + let section = section.split("\n## ").next().unwrap_or(section); + parse_charter_table(section) +} + +fn parse_charter_table(section: &str) -> BTreeMap> { + let mut assignments: BTreeMap> = BTreeMap::new(); + let mut saw_row = false; + for line in section.lines() { + let line = line.trim(); + if !line.starts_with('|') { + continue; + } + let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect(); + // Header and separator carry no data. The separator is matched after + // stripping alignment colons: a table written `|:---|:---|` yields + // `:---`, which would otherwise parse as a data row, be inserted as an + // assigned item, and set `saw_row` — leaving the zero-rows shape guard + // quiet while the gate reports `:---` instead of diagnosing anything. + let separator_cell = cells[0].trim_matches(':'); + if cells.len() < 4 || cells[0] == "Sub-owner" || separator_cell.starts_with("---") { + continue; + } + let owner = cells[0].trim_matches('`').to_string(); + for item in cells[3] + .split(',') + .map(|entry| entry.trim().trim_matches('`').trim()) + .filter(|entry| !entry.is_empty()) + { + assignments + .entry(item.to_string()) + .or_default() + .push(owner.clone()); + } + saw_row = true; + } + assert!( + saw_row, + "the '## `handlers.rs` module-charter map' section parsed to zero table \ + rows — the table shape changed and this gate silently stopped checking \ + anything" + ); + assignments +} + +#[test] +fn every_handler_item_has_exactly_one_sub_owner() { + let items = charted_surface(); + let assignments = charter_assignments(); + + assert!( + items.len() > 150, + "expected to walk the whole handler surface; found only {} top-level \ + items — the walk is broken and this gate would pass vacuously", + items.len() + ); + assert!( + items.iter().any(|item| item.starts_with("handlers/")), + "no submodule item was collected — the `handlers/` walk is broken and \ + the `run-artifact` row would go unchecked" + ); + + let unassigned: Vec<&String> = items + .iter() + .filter(|item| !assignments.contains_key(*item)) + .collect(); + assert!( + unassigned.is_empty(), + "{} handler item(s) have no sub-owner in CLAUDE.md's '## `handlers.rs` \ + module-charter map'.\nAdd each to the row of the concern it belongs to \ + (see PROPOSAL §6.9.1). A helper belongs to the concern whose request \ + shape it reads, not to `dispatch`, unless more than one concern calls \ + it:\n{}", + unassigned.len(), + unassigned + .iter() + .map(|item| format!(" {item}")) + .collect::>() + .join("\n") + ); + + let stale: Vec<&String> = assignments + .keys() + .filter(|item| !items.contains(*item)) + .collect(); + assert!( + stale.is_empty(), + "{} charter entr(y/ies) name an item that no longer exists — delete \ + them so the map only shrinks:\n{}", + stale.len(), + stale + .iter() + .map(|item| format!(" {item}")) + .collect::>() + .join("\n") + ); + + let duplicated: Vec = assignments + .iter() + .filter(|(_, owners)| owners.len() > 1) + .map(|(item, owners)| format!(" {item} -> {}", owners.join(", "))) + .collect(); + assert!( + duplicated.is_empty(), + "{} item(s) are claimed by more than one sub-owner. An item has exactly \ + one owner; if it genuinely serves two concerns it belongs to \ + `dispatch`:\n{}", + duplicated.len(), + duplicated.join("\n") + ); +} + +/// §6.9.1 asks for "the audited **≥11** sub-owners". Pin the floor so a future +/// collapse into three vague buckets fails rather than passing coverage. +#[test] +fn the_map_keeps_at_least_the_audited_sub_owner_count() { + let owners: std::collections::BTreeSet = + charter_assignments().into_values().flatten().collect(); + assert!( + owners.len() >= 11, + "the map has collapsed to {} sub-owner(s); PROPOSAL §6.9.1 asks for at \ + least 11. Merging concerns here does not make the file smaller, it \ + only makes the eventual #5985 split re-litigate the seams: {:?}", + owners.len(), + owners + ); +} + +/// The `large_file` waiver **stays**. It names a live pending plan (#5985), and +/// a charter map is explicitly not that split. +/// +/// Without this, the natural next move for someone reading the charter is to +/// delete the waiver as "handled" — which would silently drop the file out of +/// the ARCH-SPRAWL tracking `scripts/pre-commit-safety.sh` enforces. +#[test] +fn the_large_file_waiver_survives_the_charter_map() { + let handlers = + std::fs::read_to_string(handlers_dir().join("handlers.rs")).expect("read handlers.rs"); + assert!( + handlers.contains("// arch-exempt: large_file"), + "the `// arch-exempt: large_file` waiver was removed from handlers.rs. \ + The module-charter map does not discharge it — the waiver names plan \ + #5985 (the WebUI route split), which has not landed. Restore it, or \ + land the split and delete both." + ); + assert!( + handlers.contains("plan #5985"), + "the waiver no longer names plan #5985. `scripts/pre-commit-safety.sh` \ + requires `// arch-exempt: , , plan #NNNN`, and the \ + plan number is the only thing that makes the waiver revocable." + ); +} + +/// An **aligned** separator row (`|:---|`) must not parse as data. +/// +/// The regression this pins: matching the separator with +/// `cells[0].starts_with("---")` sees `:---` and lets the row through. `:---` +/// then becomes both a sub-owner and an assigned item, and `saw_row` goes true, +/// so the zero-rows shape guard stays quiet. +#[test] +fn an_aligned_separator_row_is_not_parsed_as_data() { + for (label, separator) in [ + ("unaligned", "|---|---|---|---|"), + ("left-aligned", "|:---|:---|:---|:---|"), + ("centred", "|:---:|:---:|:---:|:---:|"), + ] { + let table = format!( + "\n| Sub-owner | Owns | Never contains | Items |\n{separator}\n\ + | `runs` | run control | reads | `cancel_run` |\n" + ); + let parsed = parse_charter_table(&table); + assert_eq!( + parsed.keys().collect::>(), + vec!["cancel_run"], + "{label}: only the data row may be parsed; a separator must never \ + contribute an item (got {parsed:?})" + ); + assert_eq!( + parsed.get("cancel_run").map(Vec::as_slice), + Some(["runs".to_string()].as_slice()), + "{label}: the owner must come from the data row, not the separator" + ); + } +} diff --git a/docs/adr/0003-triggers-keeps-hand-written-sql.md b/docs/adr/0003-triggers-keeps-hand-written-sql.md new file mode 100644 index 00000000000..f8079e42cb0 --- /dev/null +++ b/docs/adr/0003-triggers-keeps-hand-written-sql.md @@ -0,0 +1,261 @@ +# ADR 0003: `ironclaw_triggers` keeps its hand-written libSQL/PostgreSQL SQL + +**Status:** Accepted 2026-08-04 (delegated authority — target-architecture WS6 +`triggers` SQL ADR-or-converge row; PROPOSAL §12.12 D-L) +**Issue / rows:** CHECKLIST WS6 "Domain-internal cleanups" clause (f); +PROPOSAL §6.4.3, §11.2.6, §12 item 10 +**Measured at:** `89080c5160` + +## Context + +PROPOSAL §11.2.6 sets the persistence idiom for the `domains/` family: +`ScopedFilesystem` is the floor, every domain crate is backend-neutral, and +*"a crate that instead needs a hand-written SQL backend is a deliberate, narrow +design choice that must be justified by an ADR"* +(`docs/reborn/target-architecture/families/domains.md:49`). Two crates are +outside that floor today: `ironclaw_triggers` and `ironclaw_hooks` (ADR 0004). +The restructure row gave each the same binary choice — converge onto the +`RootFilesystem` mount catalog, or write the ADR. + +`ironclaw_triggers` carries **3,372 lines** of hand-written SQL across two +drivers: `src/libsql.rs` (1,869) and `src/postgres.rs` (1,503). (PROPOSAL +§6.4.3 records "3,347"; the figure has drifted +25 and is corrected here.) +Both implement the same 21-method `TriggerRepository` trait +(`src/lib.rs:1036`), alongside an in-memory reference (`src/in_memory.rs`). +Counted by executed-statement site, that is **46 distinct SQL statements on +libSQL and 40 on PostgreSQL**, of which **26 / 25** are on the runtime path. + +The question this ADR answers is not "is hand-written SQL nice" — it is +whether the crate's *claim/queue semantics* survive the move to the fabric. + +## What the SQL actually does + +The load-bearing statements are not CRUD. They are the concurrency control for +a distributed work queue, and the contract they implement is explicit: +`docs/reborn/contracts/triggers.md:202-204` requires the worker to enforce +`max_concurrent_fires_per_trigger = 1` *"through an atomic repository +claim/lease operation that covers read, eligibility check, active-fire check, +and claim write."* + +**1. The claim, as a single-statement compare-and-swap** (`src/libsql.rs:754`, +inside `BEGIN IMMEDIATE` opened at `:752`): + +```sql +UPDATE trigger_records + SET active_fire_slot = ?4, active_run_ref = NULL + WHERE tenant_id = ?1 AND trigger_id = ?2 AND state = ?3 + AND next_run_at = ?4 AND ?4 <= ?5 + AND active_fire_slot IS NULL AND active_run_ref IS NULL + RETURNING <21 columns> +``` + +Five predicates and the winner's write are one indivisible statement. Split +into read-then-write, two pollers both observe `active_fire_slot IS NULL`, both +decide they are eligible, and both claim. A lost race here is not a retry — it +is **two agent turns and two threads for one scheduled fire**, each minting its +own trusted-inbound request through this crate's sealed-mint path. +`BEGIN IMMEDIATE` (rather than the default deferred) is what makes the claim +either win or fail immediately instead of upgrading a read transaction to a +write transaction and rolling back at COMMIT. + +**2. The same invariant by the opposite mechanism on PostgreSQL** +(`src/postgres.rs:515` → `:1071`, then `:536`): `SELECT … FOR UPDATE` takes a +pessimistic row lock, eligibility is evaluated in Rust on the locked row, and +the `UPDATE … RETURNING` is unconditional because the lock already excludes +concurrent writers. `FOR UPDATE` is precisely the primitive a document/CAS API +cannot express: it lets a reader **serialize other readers of the same row**, +rather than merely detect after the fact that its version was superseded. +(There is no `SKIP LOCKED` anywhere in the crate — verified zero occurrences.) + +**3. Lease release proves ownership in the predicate** (`src/libsql.rs:904`, +PostgreSQL peer `src/postgres.rs:702`): +`… WHERE active_fire_slot = ?4 AND active_run_ref IS NULL AND next_run_at <= ?4`. +The clause releases the lease **only if this caller still owns it and no run +has been attached**. As read-then-write, a slow failure handler can clear a +lease a *newer* fire already took — the classic lease ABA, silently +double-firing the next slot. + +**4. Derived state is computed inside the transaction that writes it** +(`src/libsql.rs:1126`, `src/postgres.rs:886`, both carrying the comment *"Fetch +the record inside the transaction to compute next state atomically"*). The next +fire time comes from the cron/timezone schedule in Rust; the read of `schedule` +and the write of the derived `next_run_at` must be one unit, or a concurrent +`upsert_trigger` (a user editing the cron expression) is silently overwritten +by a scheduler advancing a slot computed from the old expression. + +**5. Multi-row invariants span two tables in one transaction.** Every claim, +failure, and settlement pairs its `trigger_records` write with an +`upsert_run_history` / `complete_run_history` on `trigger_run_history` +(`src/libsql.rs:1713`, `:1758`), keyed `PRIMARY KEY (tenant_id, trigger_id, +fire_slot)` and merged with **per-column** `ON CONFLICT` precedence — the +insert path takes `excluded.run_id` but keeps a known `thread_id`, the +completion path does the reverse. Two concurrent settlement writers (a +submitter recording acceptance, a failure handler recording an error) must +converge on one row per fire slot; a read-then-write merge loses whichever +column the second writer did not know about. History pruning +(`src/libsql.rs:1793`) runs in that same transaction, so a crash cannot leave +history unbounded and two concurrent settlements cannot compute different +"keep sets" and delete each other's rows. + +## Decision + +**`ironclaw_triggers` keeps its hand-written libSQL and PostgreSQL repositories. +The crate is a permanent, documented exception to the `ScopedFilesystem` floor, +and stays on the §11.2.6 shrink-only driver allowlist.** + +The convergence the row offered is not available at an acceptable cost, for +three separate reasons, any one of which is sufficient: + +1. **The fabric's contract does not express these operations.** `RootFilesystem` + offers virtual paths, mounts, and per-document compare-and-swap. That covers + optimistic single-document replacement. It does not express a + multi-predicate CAS whose predicate spans columns the writer is also + setting; it does not express `FOR UPDATE` (serializing *other readers*); and + it does not express a transaction spanning two record families with + per-column merge precedence. Rebuilding claim/lease on per-document CAS + would mean re-deriving the queue's concurrency control on a weaker + primitive — the exact class of change most likely to reintroduce a + double-fire that only appears under production concurrency. +2. **Both backends are live deployment shapes, so "converge on one" is a + product decision this restructure has no mandate to make.** Unlike the hooks + backends (ADR 0004), the trigger repositories *are* wired: composition + selects `LibSqlTriggerRepository` or `PostgresTriggerRepository` by profile + at `crates/ironclaw_reborn_composition/src/backend_store_assembly.rs:89` + and `:99`, and again in the production path at + `src/factory/production_backend_assembly.rs:1330` and `:1373`. Deleting + either drops a shipped deployment shape. +3. **Convergence would require deleting an architecture boundary rule, not just + rewriting persistence.** `ironclaw_triggers` is *mechanically forbidden* a + dependency on `ironclaw_filesystem` — it is in the `forbidden` list of the + crate's `BoundaryRule` at + `crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs:3968`. + Adopting `ScopedFilesystem` means legalizing a new substrate→substrate edge + *on top of* the persistence rewrite. + +Rejected alternatives: **converge on libSQL** or **on PostgreSQL** — each drops +a deployment shape (see 2). **Re-extract per-backend crates** — reverses the +fold that produced today's single conformance suite, and adds crates to a tree +PROPOSAL §2 is deleting. **Route through the mount catalog** — reason 1. + +### The exception's boundaries + +The crate does **not** get ambient database authority. It owns its SQL and its +transactions and takes connection admission from the substrate that owns the +pool: production libSQL composition hands the *same* `Arc` to +both the root filesystem and the trigger repository +(`production_backend_assembly.rs:1328`/`:1330`), so trigger writes queue on the +one write-admission lane §11.2.6 and #6863 require. `crates/ironclaw_triggers/AGENTS.md` +already forbids the crate database URL/path/env parsing and handle +construction; that stays. + +The exception is registered where it is enforced: `ironclaw_triggers` is on +`DRIVER_LINKED_CRATES` in +`crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs:33`. +That list is asserted as a **bidirectional set equality** — a crate gaining the +driver fails, and a crate that sheds it also fails until the entry is removed — +so the allowlist can only ratchet down. This ADR is the argued justification the +const's doc comment asks for; it does not widen the list. + +## Parity between the backends + +`.claude/rules/database.md` requires that *"when a domain explicitly supports +multiple durable backends, keep behavioral parity for ordering, uniqueness, +timestamps, indexes, transactions, and error classification. Put adversarial +parity cases in a shared conformance suite instead of copying tests per +implementation."* + +A shared suite exists and is **not** thin — +`crates/ironclaw_triggers/tests/repository_contract.rs`, 4,710 lines, **51 +tests** built from **31 shared `assert_*` helpers** that each backend drives: + +- Two aggregate drivers run the same 11 helpers in the same order against each + durable backend (`libsql_repository_contract_parity:1224`, + `postgres_repository_contract_parity:1356`), and + `assert_durable_fire_claim_contract:3238` bundles six more. +- **Every one of the 21 `TriggerRepository` methods is exercised by a shared + helper** — no method is covered for one backend only. +- **The claim/queue atomicity paths are covered specifically, and only against + the durable backends**: `assert_durable_claim_is_atomic:2989` races two + `claim_due_fire` calls with `tokio::join!` and asserts exactly one `Claimed` + and exactly one `AlreadyActive`; `assert_mark_fire_accepted_is_idempotent_under_concurrency:3068` + and `assert_mark_fire_replayed_is_idempotent_under_concurrency:3136` do the + same for settlement. +- Backend-asymmetric cases are correctly *not* shared — notably + `libsql_filesystem_and_trigger_writes_share_one_runtime_lane:1152`, which + pins the shared write-admission invariant above. PROPOSAL §12 item 6 sanctions + this asymmetry: parity means "same observable contract", not "same connection + machinery". + +**One real gap was found and half-closed with this ADR — read the second half.** +Every PostgreSQL leg began +`let Some((_container, pool)) = postgres_pool_or_skip().await else { return; }`, +and each of the five skip paths returned `None` after an `eprintln!`. On a +runner without Docker the entire PostgreSQL half of the parity matrix therefore +**skipped silently and reported green** — so the parity claim this ADR rests on +was only true where Docker happened to exist. The suite now honours +`IRONCLAW_REQUIRE_POSTGRES=1`, which turns every skip into a hard failure naming +the reason, following the switch `crates/ironclaw_hooks/tests/parity_matrix.rs` +already carries for the same hazard. + +⚠ **The switch exists; no CI lane sets it for this crate yet.** The only lane +that exports `IRONCLAW_REQUIRE_POSTGRES=1` is `hooks-parity` in +`.github/workflows/platform-and-compat.yml:168`, and it runs `-p ironclaw_hooks` +targets only. It is also not a drop-in: that lane serves PostgreSQL from a +workflow *service container* via `DATABASE_URL`, whereas this suite starts its +own through `testcontainers`. So today the mechanism is opt-in and the honest +statement of coverage is *"parity is proven wherever the suite runs with Docker, +and can no longer silently claim otherwise when asked to be strict."* Making it +unconditional means giving triggers a lane that guarantees a Docker daemon — +worth doing, deliberately out of scope for a decision PR, and the first thing to +build if this ADR's parity argument is ever load-bearing for a release. + +**Known shape deviation, recorded rather than fixed.** The suite is 31 private +helpers inside one integration-test binary, not a `pub mod contract` behind the +`test-support` feature the way `ironclaw_hooks::predicate_state::contract` is. +It is therefore not runnable by an out-of-crate backend. That costs nothing +today — both durable backends live in this crate — but a third backend, or a +future fabric-routed one, would have to refactor the suite before it could opt +in. Whoever adds one should convert the helpers to an exported contract module +first, and should not copy per-implementation tests instead. + +## Revisit condition + +Reopen this decision when **any** of the following becomes true: + +1. **`RootFilesystem` grows a multi-document transaction with predicate-scoped + conditional writes** (something that can express "claim this row iff these + five columns still hold, and write the history row in the same unit"). The + fabric gaining that capability removes reason 1 outright, and this ADR + should be re-argued rather than assumed. +2. **The queue's concurrency requirement drops** — if `max_concurrent_fires_per_trigger` + stops being 1, or claiming stops being the mechanism (e.g. fires move to a + real broker with its own at-most-once delivery), the atomicity argument no + longer applies. +3. **One of the two backends is retired as a product decision.** That collapses + this to a single-driver crate and makes convergence cheap enough to + re-evaluate on its own merits. +4. **A third durable backend is proposed.** Do not add one under this ADR: the + exception is for the two shapes that ship today. A third means either + converging first, or exporting the conformance suite as described above. + +Note the open follow-up in `docs/reborn/contracts/triggers.md:472-474` — trigger +count quotas *"must be enforced through an atomic repository/database policy +when they are added"* — which would add SQL under this decision rather than +challenge it. + +## Consequences + +- Two of the workspace's ~64 crates hold a database driver by charter rather + than by accident, and both now have an ADR a reviewer can cite (this one and + ADR 0004). The §11.2.6 allowlist stops being a list of unexplained entries. +- The parity suite is load-bearing, not incidental: it is the *only* thing + keeping two hand-written drivers behaving identically, and with + `IRONCLAW_REQUIRE_POSTGRES=1` a CI lane can no longer report parity it did not + prove. CI lanes that intend to cover PostgreSQL must set it. +- Any future change to claim/lease SQL must land in both drivers and in a shared + `assert_*` helper. A change made in one driver only is the failure mode this + decision accepts responsibility for. +- The crate keeps taking connection admission from the substrate runtime. A + refactor that gave it its own pool would reintroduce the competing-writer + defect #6863 fixed, and is out of bounds under this ADR as much as under + §11.2.6. diff --git a/docs/adr/0004-hooks-keeps-its-predicate-state-backends.md b/docs/adr/0004-hooks-keeps-its-predicate-state-backends.md new file mode 100644 index 00000000000..e7c00f02200 --- /dev/null +++ b/docs/adr/0004-hooks-keeps-its-predicate-state-backends.md @@ -0,0 +1,211 @@ +# ADR 0004: `ironclaw_hooks` keeps its libSQL/PostgreSQL predicate-state backends + +**Status:** Accepted 2026-08-04 (delegated authority — target-architecture WS4 +`hooks` ADR-or-converge row; PROPOSAL §12.12 D-M) +**Issue / rows:** CHECKLIST WS4 "`hooks`: ADR-or-converge decision on its +libSQL/Postgres predicate backends"; **#6945** (the coverage gap the row's note +attaches); PROPOSAL §6.7.4, §11.2.6, §12 item 10 +**Measured at:** `89080c5160` + +## Context + +`ironclaw_hooks` is the second crate outside the `ScopedFilesystem` floor +PROPOSAL §11.2.6 sets for durable persistence (the first is `ironclaw_triggers` +— ADR 0003). Its exception is a hook **predicate-state** store: the sliding-window +counters behind rate and value caps, keyed `(hook_id, tenant_id, capability)`. + +`PredicateStateBackend` (`src/predicate_state.rs:370`) has three +implementations: + +| Implementation | Path | Lines | +|---|---|---| +| `InMemoryPredicateStateBackend` | `src/predicate_state.rs:497` (impl `:586`) | — | +| `LibSqlPredicateStateBackend` | `src/libsql_backend/backend.rs:127` (impl `:363`) | 906 (module) | +| `PostgresPredicateStateBackend` | `src/postgres_backend/backend.rs:124` (impl `:612`) | 897 (module) | + +These are not two parallel designs — they are two drivers behind one trait, +folded in from the former `ironclaw_hooks_{libsql,postgres}` crates +(`Cargo.toml:19-22`), which is why the durable code is **1,803 lines** inside +this crate rather than two crates outside it. + +### The measurement that decides the framing + +The obvious argument — *"both backends are live deployment shapes, composition +picks one per profile"* — is **false for hooks**, and stating it would have put +an untrue claim in an ADR. Composition hard-codes the in-memory backend: + +```rust +// crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs:322-328 +// In-memory predicate-state backend for v1. Swappable: a durable +// Postgres/libSQL backend (#3933) drops in here without touching the rest +// of the wiring. +let backend: Arc = Arc::new(InMemoryPredicateStateBackend::new()); +let evaluator = Arc::new(PredicateEvaluator::with_state_backend(Arc::clone(&backend))); +evaluator.warn_in_memory_backend_active_in_production(); +``` + +That is the **only** `with_state_backend` call site outside the owning crate. +A workspace search for `LibSqlPredicateStateBackend` / `PostgresPredicateStateBackend` +outside `crates/ironclaw_hooks/` returns **zero** hits. There is no profile +switch, no config key, and no env var selecting a durable hooks backend — and +`warn_in_memory_backend_active_in_production()` exists precisely because that +is the state. + +So the honest question is not "which shipped shape do we drop" (ADR 0003's +question) but **"do two unwired, fully-implemented durable backends earn their +1,803 lines?"** + +## Decision + +**`ironclaw_hooks` keeps both durable predicate-state backends. They stay +in-crate, behind the one `PredicateStateBackend` trait, on the §11.2.6 +shrink-only driver allowlist — as a *staged* implementation awaiting its +composition switch, not as a live deployment shape.** + +Three measurements carry it: + +1. **They close a correctness gap the in-memory backend structurally cannot.** + `InMemoryPredicateStateBackend`'s replay dedup is process-local + (`src/predicate_state.rs:357`), so it cannot defend against multi-host + replay at all. `tests/multi_host_adversarial.rs` (783 lines) exists to + exercise exactly the cross-host properties only the durable backends + provide. Rate and value caps are a security control; the moment IronClaw + runs more than one host against one tenant, an in-memory counter is + bypassable by landing on another host. +2. **They are proven interchangeable, so they are not drifting while they + wait.** `tests/parity_matrix.rs` feeds one deterministic scripted sequence + to *every* backend and cross-asserts identical logs, plus an independent + hand-computed oracle so a bug shared by two backends still fails. This is + the difference between "unwired code" and "rotting code". +3. **The swap is one line** (`factory.rs:325`). Deleting the backends converts + a one-line change into re-deriving 1,803 lines plus both migration sets when + multi-host lands. Deletion is the expensive option here, not the cheap one. + +Rejected alternatives. **Delete both and re-derive later** — reason 3, and it +would delete the only implementations of a security-relevant property (1). +**Delete one, keep the other** — the two exist because the workspace ships both +substrates; keeping only one guarantees the other is written under time +pressure later, without the parity suite that currently keeps them honest. +**Re-extract per-backend crates** — reverses the fold that produced the single +conformance suite, and adds crates to a tree PROPOSAL §2 is deleting. +**Move behind `ironclaw_filesystem`'s mount catalog** — the predicate store is +counter state with read-modify-write and windowed eviction semantics, not a file +tree; the catalog's contract does not express it. + +### Consequence stated plainly + +This ADR keeps code that production does not execute. That is a real cost and +the reason the decision is written down rather than assumed. It is bounded by +being *staged*, not speculative: the consumer (`#3933`, multi-host counters) is +named, the switch point is one line, and the parity suite is what stops the +staging from decaying. If the multi-host requirement is ever formally dropped, +this ADR should be revisited and the backends deleted — see the revisit +condition. + +## Parity between the backends + +Parity is enforced by a shared conformance suite, in the shape +`.claude/rules/database.md` prescribes — and this crate is the workspace's +reference implementation of that pattern: + +- `predicate_state::contract` (`src/predicate_state.rs:957`) is a single + trait-level suite of **12 cases**, gated `#[cfg(any(test, feature = "test-support"))]` + (`:956`) *so an out-of-crate backend can depend on `ironclaw_hooks` with + `test-support` and run the same suite against its impl*. The case list is + generated from one canonical inventory macro + (`predicate_backend_contract_cases!`, `:1485`), so there is no second + hand-maintained list to drift. +- Both drivers run it: `tests/predicate_state_libsql_contract.rs` and + `tests/predicate_state_postgres_contract.rs`. +- `tests/parity_matrix.rs` cross-asserts all three backends against each other + *and* against an independent oracle; `tests/multi_host_adversarial.rs` + (behind `integration`) covers cross-host replay. +- `parity_matrix.rs` already honours `IRONCLAW_REQUIRE_POSTGRES=1` to turn a + missing PostgreSQL into a hard failure *"so a skip cannot masquerade as a + green full-matrix run"*. ADR 0003 adopts the same switch for triggers. + +No extension was required here; the suite is the house pattern. + +## #6945 — the coverage gap this row carried, now closed + +The CHECKLIST row attached a warning: `crates/ironclaw_hooks/CLAUDE.md` claimed +cross-run hook isolation was regression-tested, naming +`crates/ironclaw_runner/tests/hooks_integration.rs` and two tests **that never +existed**. #6944 corrected the false claim; #6945 tracks the gap it was hiding. +A guardrail that does not exist reads, from the guidance, exactly like one that +does — so the ADR ships with the test. + +**The semantic.** `RebornLoopDriverHostFactory` offers hook seams with two +deliberately different lifetimes. `with_hook_dispatcher_builder_factory` +(`crates/ironclaw_runner/src/loop_driver_host.rs:1289`) invokes its closure once +per `build_text_only_host*` call — i.e. once per run — so dispatcher-owned state +(slot poisoning, registry mutations, the run-scoped milestone sink) is scoped to +one run. The legacy `with_hook_dispatcher` adapter (`:1390`) deliberately does +the opposite, cloning one `Arc` into every build. Production +wires the isolating seam (composition's factory → `runtime.rs:769-771`), so the +property holds today — but nothing failed if someone swapped it. + +**The test.** `poisoned_hook_slot_does_not_leak_into_the_next_run` in +`tests/integration/hooks.rs`, at the tier and through the caller #6945 names. +A hook commits a gate-sink protocol violation, so run 1 fails closed *and* +poisons its slot; a poisoned slot is skipped for the rest of that dispatcher's +life. Two turns on one harness are two host builds, so: + +- per-run dispatcher (production): run 2 gets a clean slot — the hook fires a + second time and the fail-closed deny is re-applied. **2 fires, 0 egress.** +- shared dispatcher (legacy adapter): run 2 skips the poisoned hook, the gate + goes quiet, and the capability reaches the wire. **1 fire, 1 egress.** + +Both assertions flip, which is what makes the test red-able rather than +decorative — verified by temporarily pointing `runtime.rs` at the legacy adapter +and watching it fail on the fire count. + +**Deliberately not asserted: predicate counter state.** It is keyed +`(hook_id, tenant_id, capability)` and shared across runs *by design* — the +`PredicateEvaluator` is built once per tenant by composition and `Arc`-cloned +into the per-run closure. Asserting isolation for it would pin a rate-cap +bypass, and #6945 says so explicitly. This is also why the two halves of this +document belong together: the counters this ADR keeps a durable backend for are +exactly the state the isolation test must leave alone. + +**What remains pinned only at the dispatcher tier.** #6945's second property — +that the legacy adapter *shares* state on purpose — is not separately tested at +the integration tier, because the harness exposes only the isolating seam and +adding the legacy one would mean widening a production struct for a deprecated +path. It is not unpinned: `with_hook_dispatcher` is a one-line delegation to +`with_hook_dispatcher_factory(move || Arc::clone(&dispatcher))`, and +`poisoned_during_dispatch_skips_subsequent_invocations` +(`src/dispatch/mod.rs:3596`) pins that one dispatcher instance keeps its poison. +The cross-*build* half was the gap, and that is what the new test covers. + +## Revisit condition + +Reopen when **any** of the following becomes true: + +1. **A durable backend is wired.** When `factory.rs:325` stops constructing + `InMemoryPredicateStateBackend` unconditionally, this ADR's framing changes + from "staged" to ADR 0003's "live deployment shapes", and the + `warn_in_memory_backend_active_in_production` guard should go with it. +2. **Multi-host is formally dropped from the roadmap.** Reason 1 for keeping + them evaporates, and the right move becomes deletion — 1,803 lines and two + driver dependencies, recoverable from history. +3. **The backends stop being provably interchangeable** — a divergence the + parity matrix cannot express, or a case that has to be skipped for one + driver. Staged code that is no longer proven honest is just dead code. +4. **A third backend is proposed.** The exported `predicate_state::contract` + makes that cheap; use it rather than adding per-implementation tests. + +## Consequences + +- `ironclaw_hooks` stays on `DRIVER_LINKED_CRATES` + (`crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs:37`), + which is asserted as bidirectional set equality and can only ratchet down. + This ADR is the justification that entry's doc comment asks for; it does not + widen the list. +- The crate carries unconditional `libsql`, `deadpool-postgres` and + `tokio-postgres` dependencies (`Cargo.toml:40-51`) for code production does + not run. Anyone shrinking the workspace's driver cone should read revisit + condition 2 before assuming this is an oversight. +- `crates/ironclaw_hooks/CLAUDE.md`'s cross-run isolation section now names a + test that exists. The correction #6944 made was to stop claiming coverage; + this closes the loop by supplying it. diff --git a/docs/plans/composition-pubuse.snapshot b/docs/plans/composition-pubuse.snapshot index a6fbe8237a3..6073ace24bb 100644 --- a/docs/plans/composition-pubuse.snapshot +++ b/docs/plans/composition-pubuse.snapshot @@ -1,6 +1,4 @@ -pub use admin_token::AdminApiTokenMinter; pub use automation::conversation_turn_submitter::conversation_turn_submitter; -pub use automation::trigger_poller::PostSubmitDeliveryHook; pub use error::RebornBuildError; #[cfg(feature = "test-support")] pub use factory::AttachmentTestSupport; @@ -17,83 +15,36 @@ pub use google_oauth_secret_store::{GoogleOauthSecretStore, GoogleOauthSecretSto pub use input::{ ChannelExtensionBinding, OAuthClientConfig, RebornHostBindings, RebornRuntimeProcessBinding, }; -pub use ironclaw_auth::OAuthRedirectUri; -#[cfg(any(test, feature = "test-support"))] -pub use ironclaw_auth::{ - AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountId, CredentialAccountLabel, - CredentialAccountStatus, CredentialOwnership, Timestamp, -}; -pub use ironclaw_auth::{CredentialAccount, CredentialAccountSelectionRequest}; -pub use ironclaw_host_api::{ - action::{NetworkScheme, NetworkTargetPattern}, - capability::{RuntimeCredentialRequirement, RuntimeCredentialRequirementSource}, - dispatch::RuntimeDispatchErrorKind, - error::HostApiError, - http::RuntimeCredentialTarget, - ids::{CapabilityId, SecretHandle}, -}; -pub use ironclaw_host_api::{ - capability::RuntimeCredentialAccountSetup, - decision::RuntimeCredentialAuthRequirement, - ids::{ExtensionId, VendorId}, -}; -pub use ironclaw_host_runtime::{ - FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, - FirstPartyCapabilityRequest, FirstPartyCapabilityResult, ProductAuthProviderRuntimePorts, -}; -pub use ironclaw_product::RebornChannelConnectStrategy; -pub use ironclaw_product::{ - LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleProductPayload, - LifecycleProductResponse, LifecycleSearchExtensionSummary, -}; -pub use ironclaw_product_contracts::account_setup::{ - ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, -}; -pub use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; -pub use ironclaw_runner::failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; +pub use ironclaw_product::LifecycleProductResponse; pub use ironclaw_runner::runtime::DEFAULT_TURN_RUNNER_WORKER_COUNT; -pub use ironclaw_runtime_policy::{ - ResolveRequest as RuntimePolicyResolveRequest, resolve as resolve_runtime_policy, -}; pub use ironclaw_skills::{ - ManagedSkillSource as RebornSkillSource, SkillSummary as RebornSkillSummary, - skill_summary_json as reborn_skill_summary_json, + SkillSummary as RebornSkillSummary, skill_summary_json as reborn_skill_summary_json, }; -pub use ironclaw_triggers::TriggerId; pub use ironclaw_turns::TurnStatus; pub use llm_admin::openai_compat_serve::build_openai_compat_route_mount; pub use memory_binding::{memory_binding_diagnostics, resolve_memory_binding_policy}; pub use memory_provider_factory::{ Mem0ConnectionConfig, MemoryLifecycleConsumers, MemoryProviderDeps, ResolvedMemoryProvider, - create_provider, memory_lifecycle_consumers, resolve_memory_provider, + memory_lifecycle_consumers, resolve_memory_provider, }; pub use operator_secret_store::RuntimeOperatorSecretValueStore; pub use deployment::{ - RebornRuntimeProfileError, RebornRuntimeProfileOptions, hosted_single_tenant_runtime_policy, + RebornRuntimeProfileOptions, hosted_single_tenant_runtime_policy, hosted_single_tenant_volume_runtime_policy, local_runtime_build_input, local_runtime_build_input_with_options, standalone_runtime_policy, standalone_unrestricted_runtime_policy, }; #[cfg(any(test, feature = "test-support"))] -pub use deployment::{local_filesystem_build_input, local_filesystem_build_input_with_profile}; -pub use ironclaw_extension_host::provider_identity::ProviderIdentityActorResolver; -pub use ironclaw_host_api::user_identity::{ - RebornIdentityProviderId, RebornIdentityProviderUserId, RebornUserIdentityBinding, - RebornUserIdentityBindingDeleteStore, RebornUserIdentityBindingError, - RebornUserIdentityBindingStore, RebornUserIdentityLookup, RebornUserIdentityLookupError, - installation_scoped_provider_user_id, -}; +pub use deployment::local_filesystem_build_input; pub use ironhub_link_serve::{ IRONHUB_REGISTER_PATH, IronhubRegisterRouteState, ironhub_register_route_mount, }; pub use observability::budget::build_default_budget_accountant; -pub use observability::budget_events::{BudgetEventObserver, TracingBudgetEventObserver}; +pub use observability::budget_events::BudgetEventObserver; pub use observability::hooks::{ - HOOKS_ENABLED_ENV, HOOKS_THIRD_PARTY_ENABLED_ENV, HookDispatcherBuilderFactory, - HookProjectionRegistry, HooksActivationConfig, MAX_INSTALLED_EXTENSIONS_CONSIDERED, - MAX_TOTAL_HOOKS_PER_TENANT, ThirdPartyDiscoveryInput, build_hook_dispatcher_builder_factory, - build_hook_dispatcher_builder_factory_for_tenant, build_hook_projection_registry, - tenant_extension_root, + HookDispatcherBuilderFactory, HookProjectionRegistry, HooksActivationConfig, + MAX_INSTALLED_EXTENSIONS_CONSIDERED, ThirdPartyDiscoveryInput, + build_hook_dispatcher_builder_factory, build_hook_projection_registry, }; pub use observability::trajectory_observer::RebornTrajectoryObserver; pub use production_runtime_policy::RebornProductionRuntimePolicy; @@ -115,18 +66,16 @@ pub use runtime::RebornTurnDriveOutcome; pub use runtime::{ AssistantReply, ConversationId, RebornRuntime, RebornRuntimeError, RebornSkillActivation, RebornSkillActivationMode, RebornSkillActivationSource, RebornSkillAsset, RebornSkillBundle, - RebornSkillExecutionPlan, RebornSkillExecutionResult, blocked_auth_flow_canceller, - build_reborn_runtime, build_runtime, product_auth_challenge_provider, + RebornSkillExecutionPlan, RebornSkillExecutionResult, build_reborn_runtime, build_runtime, + product_auth_challenge_provider, }; pub use runtime_input::{ - DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL, DEFAULT_TURN_RUNNER_POLL_INTERVAL, KeepaliveSweepSettings, PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, TriggerFireAccessError, TriggerFireAccessGrant, TriggerFireAccessPolicy, TriggerPollerSettings, TurnRunnerSettings, }; -pub use runtime_input::{RebornProviderFactory, ResolvedRebornLlm}; pub use ironclaw_reborn_identity::{ - ExternalSubjectId, IdentityKeyError, ProviderInstanceId, ProviderKind, RebornIdentityError, - RebornIdentityResolver, ResolveExternalIdentity, SurfaceKind, + ExternalSubjectId, ProviderKind, RebornIdentityError, RebornIdentityResolver, + ResolveExternalIdentity, SurfaceKind, }; diff --git a/docs/reborn/contracts/extensions.md b/docs/reborn/contracts/extensions.md index a8c56ad89e8..bc5b1931bf3 100644 --- a/docs/reborn/contracts/extensions.md +++ b/docs/reborn/contracts/extensions.md @@ -11,10 +11,13 @@ `ironclaw_extensions` owns extension package metadata, manifest validation, filesystem discovery, and capability declaration registration. It also owns package manifests and caller-membership installation records. Caller membership is the only installation-lifecycle authority; runtime -publication and administrator configuration are separate host concerns. Domain -crates such as `ironclaw_product_adapter_registry` project their own host API -sections from that generic state rather than owning a second installation -store. +publication and administrator configuration are separate host concerns. Host +API sections are projected from that generic state rather than by a second +installation store: the built-in contracts live in +`ironclaw_extensions::host_api` (`capability_provider`, `product_adapter`), and +each one's declared section *schema* is the neutral vocabulary crate's +(`ironclaw_extension_contracts::product_adapter_section` for +`[product_adapter.*]`). It answers: @@ -489,10 +492,10 @@ Rules: Tests: `crates/ironclaw_extensions/tests/manifest_v2_contract.rs` (capability surface projection block) and -`crates/ironclaw_product_adapter_registry/tests/manifest_ingestion.rs` +`crates/ironclaw_extensions/tests/product_adapter_manifest_ingestion.rs` (channel-surface projection through the real product-adapter contract). Run: `cargo test -p ironclaw_extensions --test manifest_v2_contract` and -`cargo test -p ironclaw_product_adapter_registry --test manifest_ingestion`. +`cargo test -p ironclaw_extensions --test product_adapter_manifest_ingestion`. --- diff --git a/docs/reborn/target-architecture/CHECKLIST.md b/docs/reborn/target-architecture/CHECKLIST.md index 23ff8e22724..9debee19e63 100644 --- a/docs/reborn/target-architecture/CHECKLIST.md +++ b/docs/reborn/target-architecture/CHECKLIST.md @@ -56,6 +56,13 @@ Conventions: every code item lands with its tests and its guidance updates in th - [ ] ⚠ Move failure-summary data tables (`runner::failure_summary`) into `host_api::failure`; sever `product→runner` and `product→loop_host` (prompt constant becomes a product asset). **Two of the three clauses landed with the WS1.6/WS1.7 PR (#6982); the `loop_host` sever did not, and cannot here.** The row calls these "the two single-symbol product edges"; measured on this base, **neither is single-symbol**: - **`product → runner` — SEVERED.** It was two modules, not one symbol: `projection/turn_events.rs` imported `failure_categories::CHECKPOINT_REJECTED_CATEGORY` *and* four items from `failure_summary`. All the *data* moved to `ironclaw_host_api::failure::{categories, summary}` and `ironclaw_product`'s manifest no longer names `ironclaw_runner` under `[dependencies]` (the dev-dep stays and is now documented: product's harnesses legitimately build a full turn stack). What could **not** move, because `ironclaw_host_api` may hold no internal dependency: `checkpoint_rejection_host_explanation` (typed on `agent_loop`'s `CheckpointKind` and `loop_contracts`' `LoopSafeSummary`) and every classifier (`host_stage_unavailable_category`, `MODEL_CREDITS_EXHAUSTED_REASON_KIND`). Both runner modules are now **private**. One disposition worth review: the envelope's *reader* moved and now revalidates its cause with `SafeSummary::new` instead of `LoopSafeSummary::new`. That is behavior-preserving, not a tightening, and the claim is pinned rather than asserted — `validate_loop_safe_summary` delegates to `SafeSummary::new` with exactly one bypass, the fixed `INPUT_ENCODE_HUMAN_SUMMARY` literal, which independently satisfies the canonical rule (`loop_input_encode_sentinel_needs_no_bypass_here`). Splitting writer from reader also split the checkpoint-stage vocabulary across two crates, so the pre-existing round-trip (which covered only `BeforeModel`) gained a sibling driving **all four** `CheckpointKind`s writer→reader across the boundary. Un-masking: 10 table tests moved `runner` → `host_api` with identical names and no content edits (runner 467 → 458, host_api 248 → 260; the deltas are the 10 moved plus 1 new round-trip in runner and 2 new pins in host_api). - **`product → loop_host` — NOT severed; the prompt clause is done.** `FAILURE_EXPLANATION_SYSTEM_PROMPT` and its `prompts/failure_explanation.md` asset are now product-owned (prompt *content* is out of charter for the loop tier, §6.1.4/§6.7.2), and the constant is gone from `loop_host`. But the edge has **three** production import sites, not one, and the other two are real behavior: `project_create_capability.rs` (six `SyntheticCapability*` symbols — the #6691 arrival that PROPOSAL §2.3 already flags for a second hop to `identity::projects` in Wave 4) and, **not previously recorded anywhere**, `scoped_fs/attachment_reader.rs` (renamed from `attachment_landing.rs` when the lander moved), which consumes the `LoopAttachmentReadPort`/`LoopAttachmentReadError` port pair. Severing needs those two owners moved, which is WS5's product narrowing and WS6's project re-shed — not a Wave 1 data move. The box stays open on that clause. + + ✎ **Re-measured 2026-08-04 (WS6) — "three production import sites" is wrong on this tree, and the miscount hides a whole seam.** The edge is **five production files across three seams**, and the third is recorded nowhere in this document: + 1. **Input-queue enqueue seam — unrecorded, and the largest.** `reborn_services.rs:68` (`HostInputEnqueuePort`, `RejectingInputEnqueue`), `steering.rs:24` (`EnqueueQueuedMessageRequest`, `HostInputEnqueuePort`, `HostInputQueueError`), `inbound_turn.rs:25,27` (`HostInputEnqueuePort`, `RejectingInputEnqueue`), plus three test files. All five symbols come from one owner, `ironclaw_loop_host/src/input_queue.rs`. `RebornServices` *holds* an `Arc` and steering *calls* it, so this is not a data move: severing needs a port inversion — declare the port on the contracts side and let `loop_host` adapt, the shape #7159 used for the conversations coordinator handle. + 2. **Synthetic-capability seam** — `project_create_capability.rs` (the six `SyntheticCapability*` symbols), as recorded. + 3. **Attachment-read seam** — `scoped_fs/attachment_reader.rs` (`LoopAttachmentReadPort`/`LoopAttachmentReadError`), as recorded. + + **So the manifest dep cannot be dropped by any wave that moves only the two recorded sites**, and WS6 did not attempt it. Two further findings for whoever takes the sever: §6.4.11's destination for the project-create capability (`identity::projects`) is **not reachable** — `ironclaw_projects` is `layer = "substrates"` and `ironclaw_loop_host` is `loops`, so that move is upward and matrix-illegal. The reachable destination is `ironclaw_first_party_extension_ports` (`layer = "loops"`, already depends on `loop_host`, and already hosts the exact sibling `skill_activation_capability.rs`) — but only *after* `ProjectService` leaves `ironclaw_product`, because `fpep → product` is upward too. That makes seam 2 a two-step, not a file move. Seam 3 is already documented as immovable to `ironclaw_attachments` by `reborn_conversations_threads_attachments.rs`. All three are design changes; none is a Wave-6 eviction. - **Neither edge was ever a `LAYER_MATRIX_EXCEPTION`,** so this row cannot move the count: `products → kernel` and `products → loops` are both matrix-legal. Its value is dependency-graph narrowing and prompt-content placement, not exception reduction — worth stating because the wave milestone is written in exceptions. - Enumerating gates touched, all shrink-only: the composition pub-use snapshot lost exactly one line (`docs/plans/composition-pubuse.snapshot` 127 → 126) because the `reborn_failure_summary_for_category` re-export is **deleted** rather than re-sourced — its sole consumer, the CLI, already depends on `ironclaw_host_api` directly, so the facade hop bought nothing; and the extension-specificity `PATH_TERM_COLLISIONS` list lost its now-stale `ironclaw_common/src/platform.rs` carve-out, which the gate itself demanded (it fails on carve-outs that match nothing — the property it was built with). The product-side category-coverage scan's cross-crate `include_str!` was **repointed**, not added, so the §11.2.7 inventory is unchanged at 19. - [ ] New crates registered in CI lane selectors / coverage jobs in their creation PRs (loop_contracts, extension_contracts, product_contracts, extension_manager, sandbox — the known new-crate selector trap). *`loop_contracts` done with the WS1.2 PR (#6975): root `members`, `[package.metadata.ironclaw] layer`, a `boundary_rules()` entry, `scripts/ci/classify-test-scope.sh`'s shared arm (the one both `libsql_runtime` and `memory_mem0` missed — a diff touching only those crates still classifies `has_reborn_tests=false` today, which is the trap in its live form), and `scripts/ci/reborn-crate-test-buckets.sh`'s `agent-runtime` bucket. Verified rather than assumed: `discover-reborn-package-crates.sh` picks it up through the shipped-binary closure (`cargo tree -p ironclaw`) and needs no allowlist entry; both CI self-tests pass and the bash/python crate inventories agree at 64.* *`extension_contracts` done the same way with the WS1.3 PR (#6977): root `members`, `layer = "contracts"`, a `boundary_rules()` entry plus the §11.2.3 allowlist, `classify-test-scope.sh`'s shared arm, and `reborn-crate-test-buckets.sh`'s `extension-operator` bucket (beside `extension_host`/`extensions`, not the contracts crates' `agent-runtime` — the bucket groups by what a change to it can break). Verified rather than assumed: `discover-reborn-package-crates.sh` resolves it through the shipped-binary closure, `test-classify-test-scope.sh` and `test-reborn-crate-test-buckets.sh` both pass, and the bash/python inventories agree at 65. It also joined the `untrusted_ingress_paths_cannot_submit_host_trusted_inbound` scan roots and the extension-specificity allowlist, so neither guard lost reach over code that left `host_api`.* *`product_contracts` done the same way with the WS1.4 PR (#6980): root `members`, `layer = "contracts"`, a `boundary_rules()` entry plus the §11.2.3 allowlist (`host_api` + `extension_contracts` — the one-way street §6.1.3 grants), the shared framework/driver deny roster, `classify-test-scope.sh`'s shared arm, and `reborn-crate-test-buckets.sh`'s `product-workflow` bucket (beside `ironclaw_product` — the bucket groups by what a change to it can break). Verified rather than assumed: `discover-reborn-package-crates.sh` resolves it through the shipped-binary closure, both CI self-tests pass, the bash/python inventories agree at **66**, and all **10** exact-test selectors in `scripts/reborn-e2e-rust.sh` were executed and each matched exactly one test. It also joined the `untrusted_ingress_paths_cannot_submit_host_trusted_inbound` scan roots; the extension-specificity allowlist's four `outbound.rs` entries were **repointed** (to `extension_contracts/src/auth_prompt.rs`) rather than added, so the shrink-only baseline is untouched; and the `reborn_service_method_freeze_ratchet` path constant was repointed to the trait's new home — it failed loudly on the missing file, which is the property that gate was built with.* *`extension_manager` done the same way with the WS2.4 PR: root `members`, `layer = "products"`, a `boundary_rules()` entry, `classify-test-scope.sh`'s **reborn** arm (not the shared one — the manager is a leaf product crate like `extension_host`, so a change to it should light the reborn lane, not every lane), `reborn-crate-test-buckets.sh`'s `extension-operator` bucket beside `extension_host`, and both self-tests. Two things were **verified rather than assumed, and one of them was live**: the classify trap was reproduced first — `printf 'crates/ironclaw_extension_manager/src/lib.rs' | bash scripts/ci/classify-test-scope.sh` returned `has_reborn_tests=false` before the fix and `true` after — and `discover-reborn-package-crates.sh` resolves the crate through the shipped-binary closure with no allowlist entry (`cargo tree -p ironclaw -e normal,build`). Bash and Python inventories agree at **67**; all **10** exact-test selectors in `scripts/reborn-e2e-rust.sh` were executed and each matched exactly one test. It also joined the `untrusted_ingress_paths_cannot_submit_host_trusted_inbound` scan roots. Two registries needed a **repoint rather than an add**: the CLI exact-dep allowlist (13 → 14, the `extension`/`ironhub` command surface) and `coverage-floor.toml`, whose `ironclaw_extension_host` covered-line numerator is structurally unreachable after a split — recaptured from this PR's own merged artifact in the same change (19,907/23,467 = 84.83%), with the manager ratcheted from birth (4,602/5,440 = 84.60%), closing the one-release gap the `ironclaw_turns`/WS1.2 precedent had to leave open.* @@ -131,7 +138,7 @@ Conventions: every code item lands with its tests and its guidance updates in th - [x] Kill the cross-crate `include_str!` reach-ins (gmail/github/nearai-mcp manifests): catalog/manifest data flows from package inventory via the binary; verify with the new §11.2.7 scan. **Landed with the WS2 closeout PR (2026-08-03) for the three named packages; `REPORT_ONLY` stays `true` and the row's own deferral note below still governs the rest.** Five dispositions, two of them corrections to the note below: 1. **nearai-mcp got its inventory module, and it is deliberately not an inventory *entry*.** `packages/nearai.rs` now owns the manifest and three asset embeds that `available_extensions.rs` held, so the last of twelve package directories has a module. It is **not** in `PACKAGES`: every entry there is a config-free `fn() -> PackageBundle`, and NEAR AI's shipped `[mcp].server` is a placeholder the host rewrites from LLM-admin bootstrap config, which no such builder can produce. The embeds live with the inventory; the patch stays with the endpoint authority. `PackageBundle::manifest_toml` was already a `Cow`, so the patched manifest is representable — the seam existed, nobody had used it. 2. **The row's "via the binary" clause is only half-executed, and the deferred half is a behaviour change.** Sourcing the bytes from the inventory required `ironclaw_extension_support` to become a normal (not `test-support`-gated) dependency of `ironclaw_extension_host`. Routing them *through* the binary instead — the CLI adding nearai to `bundled_first_party_bundles()` and `from_first_party_assets_with_nearai_mcp_config` finding it among the supplied bundles — is reachable, needs no new type, and was **not** done here because it puts `"nearai"` into `first_party_reserved_extension_ids`, which is a user-visible behaviour change that needs its own pinned test and does not belong in a move-shaped slice (principle 2). It is the same seam change `strays` item 2 already scopes for `bundled_skills`, and the two should land together. - 3. **The measured numbers, and why the scan cannot be flipped on them.** Escaping sites **133 -> 128**; cross-crate **19 -> 17** (measured on `0f897e9366`). The two cross-crate kills are `extension_manager`'s telegram/slack manifest reach-ins, now routed through `bundled_packages()`; the nearai, github and gmail sites were all *repo-root asset*-classified, exactly as the note below warned, so repairing them moves the larger number and not the gated one. The **17 survivors belong to three owners this row does not**: `ironclaw_extension_support` -> slack/telegram (5) — the inventory reading its own colocated packages, which happen to carry adapter crates, and which cannot be inverted because `extension_support` is `loops` and the adapter crates are `products`; `ironclaw_host_runtime` -> memory-native/mem0 (7) — the memory-provider lane, and note **five of those seven are `first_party_tools/schemas.rs`, which the note below does not mention at all**; and four test-only doc reach-ins in `operator` (1, into the CLI) and `product` (3, into `loop_contracts`/`host_api`/`agent_loop`). Flipping `REPORT_ONLY` needs all three, and none is this row's — **filed as #7093**, which also records that survivor group A may not be fixable as stated (the obvious inversion is an upward `loops → products` edge) and that the scan's printed baselines are now stale enough to mislead. + 3. **The measured numbers, and why the scan cannot be flipped on them.** Escaping sites **133 -> 128**; cross-crate **19 -> 17** (measured on `0f897e9366`). The two cross-crate kills are `extension_manager`'s telegram/slack manifest reach-ins, now routed through `bundled_packages()`; the nearai, github and gmail sites were all *repo-root asset*-classified, exactly as the note below warned, so repairing them moves the larger number and not the gated one. The **17 survivors belong to three owners this row does not**: `ironclaw_extension_support` -> slack/telegram (5) — the inventory reading its own colocated packages, which happen to carry adapter crates, and which cannot be inverted because `extension_support` is `loops` and the adapter crates are `products` *(✎ 2026-08-04: `extension_support` is now `runtimes` — WS3 closeout; the conclusion is unchanged and in fact stronger, since the inversion is now three rungs upward rather than one)*; `ironclaw_host_runtime` -> memory-native/mem0 (7) — the memory-provider lane, and note **five of those seven are `first_party_tools/schemas.rs`, which the note below does not mention at all**; and four test-only doc reach-ins in `operator` (1, into the CLI) and `product` (3, into `loop_contracts`/`host_api`/`agent_loop`). Flipping `REPORT_ONLY` needs all three, and none is this row's — **filed as #7093**, which also records that survivor group A may not be fixable as stated (the obvious inversion is an upward `loops → products` edge) and that the scan's printed baselines are now stale enough to mislead. 4. **The github and gmail fixtures are now inline, and that is the right end state, not a shortcut.** Each test needed exactly one property of the shipped manifest — a v3 manifest asserting first-party trust; a no-channel manifest carrying an `[admin_configuration]` group. Borrowing a 200-line product manifest to assert one field coupled the test to a file it does not own and gave the manifest's author a test they did not know they had. 5. **The specificity allowlist shrank by one, and it had to.** `("crates/ironclaw_extension_host/src/available_extensions.rs", "nearai-mcp")` no longer matches anything once the embed moves; the allowlist is shrink-only **and** staleness-checked, so leaving it is a red test, not a harmless leftover. The sibling `nearai_mcp`/`nearaimcp` entries stay — the endpoint patch and the `nearai_mcp` fork are still there by §6.8.2. ✎ **Deferred by WS2.6, which moved the packages underneath it — read this before measuring the scan.** The colocation *reclassifies* most of these sites without repairing any of them, and the number will look like progress. `reborn_cross_crate_include_scan.rs` classifies a site by whether the target lands inside another **cargo package** root; a data-only package (`packages/github/`, `gmail/`, `nearai-mcp/`) has no `Cargo.toml`, so `extension_host`'s six reach-ins into those manifests now resolve to a directory owned by no crate and count as *repo-root assets* instead of cross-crate. The data still flows the same way, across the same boundary, from the same `include_str!`. Two sites moved the other way and are genuinely new: `host_runtime`'s two memory-manifest embeds became cross-crate when the manifests followed their provider packages. **So `REPORT_ONLY` stays `true` and the baselines are untouched** — flipping it on a reclassified count would pin the wrong thing. The real work is unchanged and is one slice: give `nearai-mcp` a `packages/nearai.rs` inventory module like `notion.rs` (it is the only *production* reach-in, and the one asset directory of twelve with no module), replace the two test-only manifest fixtures (github, gmail) with inline TOML, and route the composition/manager test reach-ins through `bundled_packages()`. **The `nearai_mcp.rs` fork stays**: it is what prevents an `extension_host → operator` upward edge once extension_host drops to `loops`, and the fix does not touch it — only where the manifest *text* comes from. Note also that the 19/62 baselines this scan carries were measured at `ae0989c37` and are already stale by three: #7018's `extension_manager` added four cross-crate sites, so the pre-move count was 22/65. - [ ] Re-layer: `ironclaw_extensions` → substrates (renamed `ironclaw_extension_registry`, see WS6); `ironclaw_extension_host` → loops; re-charter the registry honestly (registry pure ∣ records stateful). ✎ **Half landed with the WS2 closeout PR (2026-08-03): `ironclaw_extensions` is `substrates`. The box stays open for `ironclaw_extension_host` → loops and the honest re-charter.** Four findings: @@ -144,6 +151,9 @@ Conventions: every code item lands with its tests and its guidance updates in th - **The binding constraint is not those files — it is a four-port residue that is already frozen, measured, and enforced.** `reborn_extension_host_port_inversion.rs` carries `PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS`: `AuthChallengeProvider`, `ChannelConnectionService`, `ConversationBindingService`, `ProductActorUserResolver`. Each is blocked by a **contract-purity fact, not a preference** — `ironclaw_product_contracts` may name only `ironclaw_host_api` and `ironclaw_extension_contracts`, and each of those four has `ironclaw_auth` or `ironclaw_conversations` vocabulary in its signature (`AuthProductError`/`AuthProviderId`/`CredentialAccountLabel`/`OAuthAuthorizationUrl`; `AuthFlowStatus`/`CredentialAccountStatus` inside `ChannelAuthAccountState`; `ExternalActorBindingEpoch` inside `ResolvedProductActorUser`) or product-declared binding DTOs. **Inverting them is a change to the auth and conversations vocabularies, not to the extension host** — which is why the residue has its own shrink-only ratchet and its own removes-in slice, and why no amount of work inside `extension_host` closes it. - **The flip is mechanically gated, deliberately.** `the_extension_host_manifest_names_product_only_while_a_residue_needs_it` asserts the manifest edge exists **exactly while** the residue is non-empty. Flipping the layer line today does not yield a legal crate; it yields one that does not compile. **Do not attempt the flip before the residue reaches zero.** Order: narrow the auth/conversations vocabulary out of the four port signatures → invert them into `product_contracts` → build D-A's factory port for the concrete product-stack construction in `channel_host.rs` → delete the manifest edge and flip the layer line in one change. - Carried forward on #7145 (successor to #7092) with this measurement attached, so the next slot sizes it from the residue rather than from the file count — the same mistake D-A made one level up by sizing the crate from one file. + - ✎ **2026-08-04 (WS2.5) — the four-port residue is now ONE, and the reason the previous three fell is that "narrow the vocabulary out" was not the only legal move.** Measured and executed on `89080c516`. The clause two bullets up says the blockers are "a change to the auth and conversations vocabularies, not to the extension host", and that half is right; what it did not say is *which* change. **Two of the three needed none at all.** `AuthChallengeProvider` and `ChannelConnectionService` were declared **in `ironclaw_auth`**, beside the vocabulary that blocked them — `AuthProductError`/`AuthProviderId`/`CredentialAccountLabel`/`OAuthAuthorizationUrl` for the first, and `ChannelAuthAccountState`, which is literally the argument pair of `project_auth_account_state`, for the second. That costs **zero type weakening**, which narrowing to reach `ironclaw_product_contracts` would not have: `CredentialAccountLabel` and `OAuthAuthorizationUrl` would have had to become `String`. The residue freeze clears when a trait stops being **product**-declared, whichever legal home it lands in (`.claude/rules/type-placement.md` §2/§3; `families/contracts.md:46` — "a domain's store interface belongs in the domain, not in the vocabulary crate that describes it"). The new edge `ironclaw_auth -> ironclaw_product_contracts` is `substrates -> contracts`, precedented by `ironclaw_attachments`, which carries it for its landing ports with the same written rationale. **The third did move to contracts**, and cheaply: `ProductActorUserResolver`'s only blocker was `ExternalActorBindingEpoch`, which belonged in `ironclaw_extension_contracts::external` beside the `ExternalActorRef` whose binding it versions — one type, **zero new crate edges** (conversations already depends on extension_contracts), and the field was **moved, not deleted**, exactly as #7145 step 2 requires. `WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE` **4 -> 1** in the same change; the survivor is `ConversationBindingService`, whose DTOs move with the §12.11 D-A factory port. + - ✎ **2026-08-04 (WS2.5) — the reference ledger's *vocabulary* class is empty; what remains is two classes and neither is vocabulary.** `EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT` **9 -> 5** on this branch (the three `adapter-registry` rows are #7174's, untouched here; batch assembly unions to 2). Four rows fell: `channel_connection.rs` and `product_lifecycle.rs` (the latter also via a new two-method read port, `ExtensionAccountSetupReader` in `product_contracts::account_setup` — the *registry* stays product-owned mutable state, which that module's own charter requires, and `None` is provably the empty registry), `provider_identity.rs`, and `run_delivery_ports.rs`. The last of those also discharges **#7145 step 6**, the two product free functions: `auth_prompt_view_for_blocked_auth` moved to `ironclaw_auth::product_prompt` with the challenge family, and `projection::approval_prompt_context_view` split at the boundary that decides it — the store read stays with the store (`ironclaw_approvals`, in the extension host), while the gate-ref parse, the lookup scope and the request→view projection moved to `ironclaw_product_contracts::approval_prompt`, **collapsing product's two copies of that projection into one**. The scope derivation's equivalence with `ApprovalInteractionScope` is pinned in product rather than left to review. + - ⚠ **Still open after WS2.5, and stated as scope rather than as effort: §12.11 D-A's factory port is UNSTARTED.** It owns the two `assembly` ledger rows (`channel_host.rs`, `channel_triggered_delivery.rs`) and, through them, the last residue row (`ConversationBindingService`) and the last `EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR` row. WS2.5 deliberately did not attempt it: it is a bundle-shaped inversion across four crates whose port shape D-A's own confidence note still calls open, and shipping it unverified beside a 48-file vocabulary move would have traded a measured change for an unmeasurable one. The flip's remaining scope is therefore exactly: the D-A factory port, plus #7174's adapter-registry class, then the manifest edge and the layer line in one change. 4. **The honest re-charter is untouched and still carries #6930's stated gap** — the fourth durable record class (registered package definitions) is written durably but nothing enumerates it at boot, so closing it needs a new method on `ExtensionInstallationStorePort` and every implementation. That is a port-and-conformance change; it is cheaper before the WS6 rename, not after, and it is now the *only* thing left on this row besides the layer flip. ✎ **Amended 2026-08-01 (Wave 1 truth audit) — the honest re-charter has to carry #6930's own stated gap, not just its new record class.** #6979 already amended PROPOSAL §6.8.1 and `families/extensions.md` to add the **fourth durable record class** (registered package definitions — rows that persist with zero installations, under their own `PackageDefinitionRetention` policy). What neither carries is that the class is **not yet durable end-to-end**: #6930's "Known gaps, stated rather than closed" records that *a hosted MCP registered but never installed does not survive a restart* — `registered-definitions/{id}.json` is written durably, but **nothing enumerates it at boot**, so the rebuilt catalog omits it until the user re-registers (recovery is idempotent: the admission CAS returns `ExactExisting` for a byte-identical record). Closing it needs **a new enumeration method on `ExtensionInstallationStorePort` and every implementation** — which is precisely a port-and-conformance change this re-charter row owns, and precisely the kind of change that gets more expensive after the crate is re-layered and renamed. Two siblings from the same PR worth carrying into the same planning: `crates/ironclaw_extensions/src/installations.rs` is **5,487 lines** with `arch-exempt: large_file` justifications that predate the CAS-admission logic added on top of them (that logic arguably wants its own module, which the split should decide rather than inherit), and **no hosted-MCP test asserts that removal revokes publication** — the multi-user removal test checked list/search state only. - [x] Colocate packages under `crates/extensions/packages/`: rename+move `first_party_extensions` → `extensions/ironclaw_extension_support/` (crate `ironclaw_extension_support`, WS6 rename list); every package (slack, telegram, github, gmail, google-*, web-access, notion-mcp, nearai-mcp, memory-native, mem0, …) gets its own `packages//` directory with manifest + assets + excluded `wasm-src` beside it; slack/telegram carry their adapter crates; update `scripts/build-wasm-extensions.sh`, `include_bytes!` paths, CI selectors. **Landed with the WS2.6 PR.** `crates/extensions/` exists — `ironclaw_extension_support/` (the crate, renamed here rather than in WS6) beside `packages/`, which holds **14** self-contained package directories: the twelve extension packages, plus `memory-native/` and `mem0/` from the row below. `git mv` throughout; `git diff -M` reports the moves as renames. Five dispositions, two of them defects this slice created and caught: 1. **The rename landed early, on purpose.** WS6's rename row lists `ironclaw_first_party_extensions`→`ironclaw_extension_support`, but this row names it too, and doing the directory move without the rename would have meant touching all 253 occurrences twice. WS6's entry is discharged for this crate; the other three renames on that list are untouched. Type names were **not** renamed (`FirstPartyToolLatencyFields` and the three `FirstPartyWeb*` aliases survive) — PLAN operating principle 2, no semantic change in a move PR. @@ -174,7 +184,12 @@ its verify row; the ones it does not own are named there with their real owners. See the retraction on that row. --> -- [~] Move `host_runtime/first_party_tools/**` (http, shell, time, json, echo, schemas, outbound-delivery, memory tools, trigger management, skill management/url-install, trace_commons, spawn-subagent stub) into `extensions/ironclaw_extension_support/` via the existing `FirstPartyHandlerRegistrar` pattern; host_runtime keeps only the registrar port. ✎ **Re-scoped 2026-08-03 with the first family — "host_runtime keeps only the registrar port" was too strong.** What moves is each tool's *executor*; its `FirstPartyCapabilityHandler`, its `CapabilityManifest`, and its registry wiring stay host-side, because `extension_support`'s `BoundaryRule` forbids both `ironclaw_host_runtime` and `ironclaw_extensions` and WS3 keeps that rule rather than widening it (full reasoning + the per-family remainder in PROPOSAL §6.8.4's 2026-08-03 amendment). **Family 1 landed:** skill management / url-install → `extension_support::skills::{url_install, resolve_install_input}`; `ironclaw_skills` became a host_runtime dev-dep and its exception is deleted (verify row below is now ≤ 7 — see the consolidated baseline note on that row). **Not reachable by this row:** the memory-tool family (a port inversion, not a relocation — see the memory-provider residue in `reborn_dependency_boundaries.rs`) and `host_runtime → ironclaw_extensions` (needs the manifest vocabulary in `extension_contracts`, a WS1 row). `host_runtime → ironclaw_extension_support` clears only when the last executor family lands, since `first_party_tools/mod.rs` holds it via `extension_support::coding`. ✎ **Progress 2026-08-04 — one family of six; this row stays `[~]` deliberately.** PLAN's Wave 3 block mandates *one tool family per PR*, and only family 1 (skill management / url-install) has moved. Verified by listing `crates/ironclaw_host_runtime/src/first_party_tools/` on the merged tree: `http`, `shell`, `shell_core`, `time`, `json`, `echo`, `schemas`, `outbound_delivery`, `reply_attachment`, `memory`, `trigger_management`, `trace_commons`, `spawn_subagent`, `model_visible_output`, `http_output` and the host-side `skill_management` declaration all remain. **Ticking this row would be false.** The remaining five families are the row's outstanding work, and `host_runtime → ironclaw_extension_support` clears only with the last of them, since `first_party_tools/mod.rs` holds that edge through `extension_support::coding`. +- [~] Move `host_runtime/first_party_tools/**` (http, shell, time, json, echo, schemas, outbound-delivery, memory tools, trigger management, skill management/url-install, trace_commons, spawn-subagent stub) into `extensions/ironclaw_extension_support/` via the existing `FirstPartyHandlerRegistrar` pattern; host_runtime keeps only the registrar port. ✎ **Re-scoped 2026-08-03 with the first family — "host_runtime keeps only the registrar port" was too strong.** What moves is each tool's *executor*; its `FirstPartyCapabilityHandler`, its `CapabilityManifest`, and its registry wiring stay host-side, because `extension_support`'s `BoundaryRule` forbids both `ironclaw_host_runtime` and `ironclaw_extensions` and WS3 keeps that rule rather than widening it (full reasoning + the per-family remainder in PROPOSAL §6.8.4's 2026-08-03 amendment). **Family 1 landed:** skill management / url-install → `extension_support::skills::{url_install, resolve_install_input}`; `ironclaw_skills` became a host_runtime dev-dep and its exception is deleted (verify row below is now ≤ 7 — see the consolidated baseline note on that row). **Not reachable by this row:** the memory-tool family (a port inversion, not a relocation — see the memory-provider residue in `reborn_dependency_boundaries.rs`) and `host_runtime → ironclaw_extensions` (needs the manifest vocabulary in `extension_contracts`, a WS1 row). `host_runtime → ironclaw_extension_support` clears only when the last executor family lands, since `first_party_tools/mod.rs` holds it via `extension_support::coding`. ✎ **Progress 2026-08-04 — one family of six; this row stays `[~]` deliberately.** PLAN's Wave 3 block mandates *one tool family per PR*, and only family 1 (skill management / url-install) has moved. Verified by listing `crates/ironclaw_host_runtime/src/first_party_tools/` on the merged tree: `http`, `shell`, `shell_core`, `time`, `json`, `echo`, `schemas`, `outbound_delivery`, `reply_attachment`, `memory`, `trigger_management`, `trace_commons`, `spawn_subagent`, `model_visible_output`, `http_output` and the host-side `skill_management` declaration all remain. **Ticking this row would be false.** The remaining five families are the row's outstanding work, and `host_runtime → ironclaw_extension_support` clears only with the last of them, since `first_party_tools/mod.rs` holds that edge through `extension_support::coding`. ✎ **Amended 2026-08-04 (WS3 closeout) — the row stays `[~]`, but its *exception* clause is refuted and the exception is now gone by a different mechanism. Read this before planning family 2.** + - **The edge was never gated on the executor families, and the sentence directly above is the claim being withdrawn.** Measured on this tree: `grep -rl ironclaw_extension_support crates/ironclaw_host_runtime/src` returns **three** files, and only **two** are edges — `first_party_tools/mod.rs:29` (`extension_support::coding`) and `first_party_tools/skill_management.rs:18` (`extension_support::skills`); `latency.rs:10` is a doc comment. Both belong to families whose executors have **already** moved (coding before this row existed, skills as family 1). The five families still awaiting a move keep their executors *in* `host_runtime` and therefore hold **no edge at all** — moving them could never have cleared this exception, and moving all five still would not have. "Clears only with the last of them" was exactly backwards. + - **Under this row's own re-scoped seam the edge is structural, not transitional.** The 2026-08-03 amendment above (and PROPOSAL §8.2's matching note, and `families/extensions.md`) says the executor moves and the `FirstPartyCapabilityHandler` + `CapabilityManifest` + registry wiring stay host-side. That makes the **kernel a designed consumer** of `extension_support`. A design cannot simultaneously route the kernel into a crate and declare that crate two rungs above the kernel; one of the two had to give, and the seam is the half that was deliberately chosen and recorded in three places. + - **Shedding the two adapters upward anyway was priced, and it is the §6.5.9 binder refutation again.** A registrar-side handler (the `reborn_cli/src/first_party/{gsuite,web_access}.rs` shape) needs ~8 kernel private→`pub` widenings: `mod post_edit_check` is private in `lib.rs:57` and neither `run_post_edit_check` nor `PostEditCheckSeenLines` is re-exported; `first_party_capability_manifest` (`:509`), `resource_profile` (`:831`) and `first_party_origin_gate_matrix` (`:539`) are module-private; `bounded_input_size` (`:735`), `bounded_output_bytes` (`:751`) and `FIRST_PARTY_MAX_OUTPUT_BYTES` (`:145`) are `pub(super)`. And unlike gsuite/web-access these are **builtin** capabilities, not bundled packages: they are registered by `builtin_first_party_base_registry()` and declared by `builtin_first_party_package()`, which **145 references across 31 files** reach (`ironclaw_host_runtime`, `ironclaw_extension_manager`, `ironclaw_reborn_composition`, `ironclaw_architecture`, and the root integration tree) — including three sites in the root integration harness (`tests/integration/support/harness/assembly.rs`). Relocating that registration changes *which hosts have* `read_file`/`write_file`/`list_dir`/`glob`/`grep`/`apply_patch`, i.e. a semantic change PLAN principle 2 forbids sharing with a move. Paying a kernel API widening to relocate an adapter whose encapsulation already holds is precisely what §6.5.9's binder half was refuted for. + - **Executed instead: `ironclaw_extension_support` re-layered `loops` → `runtimes`, and `LAYER_MATRIX_EXCEPTIONS` is now EMPTY (1 → 0, baseline lowered in the same change).** `runtimes` is the *least* demotion that legalizes a kernel consumer and is the layer this crate's §8.2 row already describes in posture (mediated services by injection, kernel ✗, invoked only through capability dispatch — the `lanes/` cell verbatim). Checked both directions through `cargo metadata`, not grep: all seven normal dependencies (`auth`, `extractors`, `filesystem`, `observability`, `safety`, `skills` = `substrates`; `host_api` = `contracts`) and every domain the crate's charter reserves (`memory`, `traces`, `triggers` = `substrates`) fit the narrower row; all five consumers (`host_runtime` kernel; `extension_host`, `extension_manager` products; `reborn_composition`, `ironclaw` app) are `kernel` or above, so the move forbids no existing edge. **Zero same-layer edges created** — no `runtimes` crate is a dependency or a consumer — where `substrates` (the demotion its two family siblings took) would have hidden six of its seven dependencies from the matrix. The reach the demotion buys is frozen by a `DowngradePin` in `reborn_same_layer_edge_inventory.rs`, sabotage-tested by deleting a consumer and confirming the gate names it. Fourth time the register has moved by a re-layer, and PLAN's Wave 2 note states the rule ("a re-layer *downward* is the cheap kind … expect the exception register to move"). + - **What is still owed here, unchanged:** the five executor families. This row is executor consolidation and keeps its `[~]`; only its exception clause is discharged. The next slice starts at family 2 with nothing new blocking it — and it no longer buys an exception deletion, so it should be justified on consolidation grounds alone. - [x] Create `lanes/ironclaw_sandbox` by merging `process_sandbox` (plan contract) + `host_runtime/sandbox_process/**` (Docker/broker/credential-firewall/CA) + the `scripts` Docker backend; delete `ironclaw_scripts` and `ironclaw_process_sandbox`; route all process spawning through the transport seam (fixing scripts' direct `std::process` bypass). No production behavior change (all pieces currently unwired/test-only — re-verify at land time). ✎ **Landed 2026-08-03 (WS3 sandbox+mcp PR) — and this row's behavior claim is REFUTED as written; the re-verification it asked for is what caught it.** `crates/ironclaw_sandbox` exists (flat, pending the WS7 family move), `ironclaw_process_sandbox` and `ironclaw_scripts` are deleted, and `bollard`/`rcgen` are now declared by exactly one crate in the workspace (`host_runtime`'s manifest also shed `x509-parser` and `time`). ✎ **Ticked 2026-08-04 (WS3/WS4 consolidation), verified on the merged tree rather than on the PR title:** `crates/ironclaw_sandbox/` exists; `crates/ironclaw_scripts/` and `crates/ironclaw_process_sandbox/` are both absent; and `bollard`/`rcgen` are declared by **exactly one** manifest in the workspace (`crates/ironclaw_sandbox/Cargo.toml`), which is the stronger form of the verify row's own `rg` clause. The two clauses the landing amendment above records as NOT done (the `std::process` bypass, and the crate sitting at `crates/ironclaw_sandbox` rather than `crates/lanes/`) are unchanged and are WS7's `git mv`, not this row's. **The refutation: "all pieces currently unwired/test-only" is false, and PROPOSAL §6.6.4's identical sentence is false with it.** Three production call paths cross the merged crate, all measured at base `9ad57098c9`: (1) `host_runtime/src/production.rs:1581` compares `PROCESS_SANDBOX_CAPABILITY_ID` and parses `SandboxProcessPlan` → `ValidatedSandboxProcessPlan` on the **spawn path**, rejecting bad plans as model-visible tool errors; (2) `host_runtime/src/services/process_executor.rs:184` routes such requests away from the dispatch executor; (3) `host_runtime/src/process_output.rs:496` derives the scoped saved-output directory from `RebornSandboxScopeKey::from_scope`, a production saved-command-output path through what the row called test-only. What **is** accurate is the narrower claim: there is no production *execution backend* — `with_script_runtime` and `RebornScopedSandboxCommandTransport::new` have zero production callers, and the `#[allow(dead_code)] // consumed by W6` markers hold. Anyone planning W6 should read "plan validation is live, execution is not", not "unwired". @@ -211,6 +226,7 @@ owners. See the retraction on that row. --> - **What stays in `host_runtime`, and why that is the whole point:** the four `discover_extensions_*` fns, which *bind* the defaults to a `RootFilesystem`. That binding is host-runtime's job; enumerating the vocabulary never was. `src/extension_contracts.rs` goes **151 → 99** lines and now carries a module doc saying where the defaults went; `crates/ironclaw_host_runtime/AGENTS.md` says the same, plus "do not re-add either one — or a `pub use` shim for them". - **Zero-cost, measured:** no crate gained a dependency — all five consumer crates (`ironclaw_extension_host`, `ironclaw_extension_manager`, `ironclaw_host_runtime`, `ironclaw_reborn_composition`, root `ironclaw_reborn_integration_tests`) already depended on both destinations — so `LAYER_MATRIX_EXCEPTIONS` is **unchanged at 4** (recomputed as `len(merged list)`, anchored on the `= &[` of the *value*, not the `&[LayerMatrixException]` type annotation). `cargo test -p ironclaw_architecture` green. - **Binding half — struck, not deferred, and the refutation re-verified on this tree.** `rg -t rust 'RuntimeLaneExecutor'` and `'RuntimeLaneRequest'` outside `crates/ironclaw_host_runtime/` both return **0** hits; the declarations are `pub(super) struct RuntimeLaneExecutor` (`src/services/runtime_adapters.rs:252`) and `pub(crate) struct RuntimeLaneRequest` (`:52`). Shedding `extension_tool_binder.rs` to `extension_host` therefore *requires* widening both to `pub`, which contradicts §6.5.9's own **Keeps** clause (*"the closed `RuntimeLaneExecutor` + lane adapters"*) — the row as written would have paid a boundary regression to move 230 lines whose narrow handle (`Arc`) already delivers the encapsulation the shed was meant to buy. There is nothing left for a follow-up slice to collect, so no issue is filed: re-opening this needs a *design* reason, not a move. + - ✎ **Re-verified on the merged tree 2026-08-04 (WS3 closeout), against the code rather than against this row's own prose:** `default_host_port_catalog` is defined at `crates/ironclaw_host_api/src/host_port.rs:244` and `default_host_api_contract_registry` at `crates/ironclaw_extensions/src/host_api/mod.rs:17`; neither name is defined anywhere in `crates/ironclaw_host_runtime/src` and no `pub use` shim reintroduces either, so a re-addition is still a compile error rather than a second import path. `src/extension_contracts.rs` measures **99** lines, matching the 151 → 99 this row claims. The remaining workspace hit for the old spelling is the test *name* the row predicted (`crates/ironclaw_host_runtime/tests/host_api_contract_composition.rs:71`), which correctly needed no edit. - [x] `mcp` drops the registry dep (consume `extension_contracts`); confirm the estimate/usage vocabulary it needs lives in `host_api::resource`. ✎ **Annotated 2026-07-31 (#6930) — the flip target is unchanged; the payload under it grew.** Re-verified at `2e6522580`: the import list is byte-identical — `use ironclaw_extensions::{ExtensionPackage, ExtensionRuntime, HostedMcpDiscoveredTool, HostedMcpDiscoveredToolAnnotations};` (`crates/ironclaw_mcp/src/lib.rs:20-22`) — so the four DTOs this row hands to `extension_contracts` are still exactly four, and Wave 1's `mcp → extensions` exception annotations stand as written. What changed is their **shape** and their **company**: `ExtensionPackage` swapped `root: VirtualPath` for `root_binding: PackageRootBinding` (`crates/ironclaw_extensions/src/package.rs:20`, enum at `resolved.rs:39`) and `HostedMcpDiscoveredToolAnnotations` gained three fields (`hosted_mcp_discovery.rs:27,31,32`), so the carve-out moves more surface than the name count suggests; and the lane picked up a **second** hosted-MCP vocabulary source, `host_api::hosted_mcp::McpAuthChallenge` (`lib.rs:27`). Plan the flip for two contracts modules, not one — and note that `host_api::hosted_mcp` is itself mutually bound to `package_lifecycle` (PROPOSAL §6.1.1), so where it lands is decided by the WS1 `extension_contracts`/`product_contracts` slots, not here. ✎ **Executed in part 2026-08-03 (WS3 sandbox+mcp PR): the registry half is DONE and the row's own framing of the blocker was wrong; the `resources` half is REFUTED and stays, with corrected evidence on its exception.** ✎ **Ticked 2026-08-04 (WS3/WS4 consolidation), verified on the merged tree:** `ironclaw_extensions` appears in `crates/ironclaw_mcp/Cargo.toml` **only** under `[dev-dependencies]` (the lane's manifest-parsing test), production `ironclaw_extensions::` references in `crates/ironclaw_mcp/src/` are **0**, and the layer matrix measures normal dependencies only. The row's second clause — confirm the estimate/usage vocabulary lives in `host_api::resource` — is confirmed AND its implication refuted: the vocabulary is there and is already imported from there, but that is not what holds the `→ resources` edge. `ResourceGovernor` and the `ResourceError` denial cone do, which is a kernel carve-out rather than a vocabulary move; both surviving `→ resources` rows now carry that evidence and point at **#7067**. ✎ **Closed 2026-08-04 (#7067) — the `resources` clause is executed, by inversion rather than by the relocation this row originally implied.** Neither the governor nor its denial cone moved. `ironclaw_host_api::resource` gained a **port**, `RuntimeResourceBudget`: `reserve` / `reconcile` / `release`, typed only on shapes that crate already owned (`ResourceScope`, `ResourceEstimate`, `ResourceUsage`, `ResourceReservation`, `ResourceReceipt`, `ResourceReservationId`) plus a narrow classified error (`RuntimeResourceError` + `RuntimeResourceErrorKind`). `ironclaw_resources` implements it over **any** `ResourceGovernor` (`GovernorRuntimeBudget`, a borrow adapter) and owns the `ResourceError → RuntimeResourceError` projection, which is subtractive by design (`.claude/rules/type-placement.md` §3): the classification survives whole — `LimitExceeded` and `RequiresApproval` stay distinct — while account, limit, dimension and threshold *values* stop in the kernel. Trait justification is §2, dependency inversion: declared below, implemented above, single impl **by design**. Behavior-free at the effect level: the same authority calls in the same order, and the rendered reason the lane forwards as `model_visible_cause` is byte-identical (the projection carries the authority's own `to_string()`; the one error a lane raises itself, `ReservationMismatch`, keeps the authority's exact wording, pinned by a host_api unit test). Both lanes dropped `ironclaw_resources` from `[dependencies]`; it stays a **dev**-dependency in each — the layer matrix measures normal dependencies only (`is_normal_dependency`), and keeping the real governor in the lane suites is what makes the new denial assertions mean something instead of asserting against a lane-local fake. Two lane-seam regressions added per lane (`{mcp,script}_runtime_surfaces_approval_pause_distinctly_from_a_hard_denial`, `{mcp,script}_runtime_reuses_a_matching_prepared_reservation_and_rejects_a_mismatched_one`) and the existing budget-denial tests extended to assert the classification **and** the preserved wording. The prepared-reservation path had **no** lane-seam coverage before this slice — that gap is now closed. **Register 4 → 2, baseline lowered in the same change.** The row's *other* clause — `mcp` consuming `extension_contracts` — was already ticked on 2026-08-04 and is untouched here. **A prior wave recorded this row as structurally blocked** — the reasoning being that the flip needs `ExtensionPackage`/`ExtensionRuntime`/`HostedMcpDiscoveredTool*` in `extension_contracts` and §6.1.2 forbids that crate absorbing registry DTOs. Re-verified against current `main`, that is half right, and the half it gets wrong is the half that matters: **the lane never needed `ExtensionPackage`.** Measured at `9ad57098c9`, `ironclaw_mcp` reads exactly three things off it — `package.id`, `package.capabilities`, and `package.manifest.runtime` (`lib.rs:1850-1907`) — holds it by reference, and never constructs it. `ironclaw_scripts` reads the same three. So the flip is not "move the package"; it is **narrow the lane's input to what it consumes**, which is what the exception's own removal text ("extension runtime descriptors move to a neutral contract") always said. @@ -238,7 +254,8 @@ owners. See the retraction on that row. --> - ~~**The row understates what is wired.**~~ ⛔ **SUPERSEDED — this bullet is the retracted text itself, kept as history. Do not act on it; read the retraction above first.** It asserted the threat in the very words the bullet above withdraws, so the two contradicted each other in the same row. Struck 2026-08-04. The *wiring* half of it is accurate and still worth knowing — `default_policy_http_egress` (`crates/ironclaw_network/src/test_rewrite.rs:235`) returns `PolicyNetworkHttpEgress>` built via `RewriteNetworkTransport::from_env(..)`, it has exactly **one** caller (`crates/ironclaw_reborn_composition/src/factory/runtime_lane_assembly.rs:14`, *"the ONE construction seam for host HTTP egress"*), and `mod test_rewrite;` (`crates/ironclaw_network/src/lib.rs:14`) is ungated with no `[features]` table on the crate. What does **not** follow is the conclusion drawn from it: *"every production binary … honours `IRONCLAW_REBORN_TEST_HTTP_REWRITE_MAP` at runtime … can redirect all vendor egress, credentialed calls included"*. Compiling the seam is not honouring it — `from_env_value` returns `HostRewriteMapError::UnavailableInRelease` whenever `!cfg!(debug_assertions)`, so a release binary with the variable set **refuses to boot**. Fail-closed, and fail-closed before this PR. - **Why it cannot ride along here.** That env var is not vestigial — it is the mechanism the whole E2E suite uses to point vendor traffic at fakes, and it does so by launching the **production binary**: `tests/e2e/conftest.py` (6 sites), `tests/e2e/scenarios/test_reborn_slack_channel_e2e.py`, `test_reborn_qa_trace_full_path.py`, `tests/e2e/mock_llm.py`, `scripts/live_canary/common.py`, and two `tests/e2e/CLAUDE.md` fixture rows. Gating the seam behind `test-support` therefore requires the feature to be **forwarded** through `ironclaw_reborn_composition` to `ironclaw_reborn_cli` and **enabled on every E2E and live-canary build command plus their CI lanes** — otherwise vendor redirection silently stops working and the failure surfaces as unrelated E2E flake, not as a build error. None of that is verifiable in this environment (the E2E suite needs Docker and Playwright), and shipping it unverified inside an already-large consolidation is the wrong trade. - **The plan, sized.** ✎ **Re-framed 2026-08-04 with the retraction above: this is hygiene, not a vulnerability fix.** The steps below are unchanged and still worth doing — a dev-only seam should not be compiled into production at all, and `.claude/rules/cargo-features.md` names exactly this shape (a dev-only seam, which the rule requires be called `test-support`). But the *urgency* the earlier framing implied was borrowed from the withdrawn threat: release builds already refuse the variable, so nothing here is load-bearing for security and this must not be scheduled as though a hole were open. Step (6) is the one that changes character — it is no longer "prove the hole is closed" but "pin the fail-closed behaviour that already holds", which is still the right regression to write. (1) Add `[features] test-support = []` to `crates/ironclaw_network/Cargo.toml`; (2) `#[cfg(feature = "test-support")] mod test_rewrite;` and gate the four re-exports at `lib.rs:26-29`; (3) split `default_host_http_egress` in `runtime_lane_assembly.rs` into a cfg'd pair — the default arm returning `PolicyNetworkHttpEgress` built directly, the `test-support` arm keeping today's rewrite wrapper; (4) forward the feature `composition/test-support → network/test-support` and `cli/test-support → composition/test-support`; (5) add `--features test-support` to the E2E and live-canary build commands and the workflows that invoke them. It clears `.claude/rules/cargo-features.md`'s bar on two counts — a dev-only seam (which the rule requires be named `test-support`) and a privilege boundary. (6) The regression test is the one that matters: assert the production arm's transport type does **not** consult the env var, i.e. sabotage-test it by setting `IRONCLAW_REBORN_TEST_HTTP_REWRITE_MAP` and proving the default build ignores it. - - **This row is NOT ticked.** Its condition is unmet and the honest state is open. + - ~~**This row is NOT ticked.** Its condition is unmet and the honest state is open.~~ ⛔ **SUPERSEDED — this line belongs to the pre-landing text above and was left standing when the row was ticked. Struck 2026-08-04 (WS3 closeout); the row is `[x]` and the condition is met.** + - ✎ **Re-verified on the merged tree 2026-08-04 (WS3 closeout), at the two line numbers the task names.** `crates/ironclaw_network/src/lib.rs:14` reads `#[cfg(any(debug_assertions, feature = "test-support"))] mod test_rewrite;` and `:26` gates the four re-exports (`HostRewriteMap`, `HostRewriteMapError`, `RewriteNetworkTransport`, `TEST_HTTP_REWRITE_MAP_ENV`, `default_policy_http_egress`) under the same `cfg` — so the seam is compile-time excluded from a release-profile build, not merely refused at runtime. The crate carries `[features] test-support = []` with the manifest comment `.claude/rules/cargo-features.md` requires, naming both bars it clears (dev-only seam; privilege boundary). Nothing further is owed on this row. - [x] Tighten direct `secrets` consumers: remove the `webui` and `operator` edges via `product_contracts` ports; keep `auth` by charter; add the boundary rule. **(security-sensitive — PROPOSAL §12.1b; port replacements land first)** ✎ **Landed 2026-08-03 (WS3 secrets-tightening PR). The row names two edges; measured against `0f897e9366` there was one, and the crate it does *not* name is the one still open.** - **The `webui` edge does not exist and never did.** `ironclaw_secrets` has been a `[dev-dependencies]` entry of `ironclaw_webui` since the commit that introduced it — #6619 (`e074a39c16`), which added it at line 77 under a `[dev-dependencies]` header at line 66 — and `git log -G"ironclaw_secrets" -- crates/ironclaw_webui/Cargo.toml` returns that commit and nothing else. Both `src` hits are inside `#[cfg(test)]` modules (`product_auth/oauth_start_tests.rs:23`, `product_auth/mod.rs:1736`), and **`ironclaw_webui`'s `boundary_rules()` entry already forbids `ironclaw_secrets`** — it has since before this row. So the webui half needed no code and no rule; it was already closed. PROPOSAL §12.1b's audit line ("audited: webui session/keys, operator key store") is stale on its first item and is corrected there. @@ -267,10 +284,15 @@ owners. See the retraction on that row. --> - ✅ `rg "bollard|rcgen"` → nothing in `ironclaw_host_runtime`'s manifest, and stronger than the row asks: **exactly one** crate in the workspace declares either (`ironclaw_sandbox`), which also shed `x509-parser` and `time` from the kernel. **Explicitly NOT closed by Wave 3, and correctly so — each carries its own later owner in its `removes_in` field, which is where the row should have looked:** `host_runtime → ironclaw_extension_support` (its `removes_in` reads `W7`, which is a retired July-train milestone label and **not** a wave assignment — see the retraction above; its real owner is this checklist's own `first_party_tools` row, and it clears only when the last first-party tool executor family lands, since `first_party_tools/mod.rs` holds the edge via `extension_support::coding`), and the two surviving lane edges `mcp → ironclaw_resources` and `sandbox → ironclaw_resources` (both `issue #7067`; they need the narrow reserve/reconcile/release port, a design change owed its own slice — not a Wave 3 move). ✎ **2026-08-04: those two are now closed** by #7067, which built that port (`host_api::resource::RuntimeResourceBudget`, implemented in the kernel as `ironclaw_resources::GovernorRuntimeBudget`) as its own slice, exactly as this bullet said it must be. The register drops 4 → 2; what remains of this list is `host_runtime → ironclaw_extension_support`. `conversations → turns` is `WS5` and was never in this row's list. **Ticked on the corrected condition, not the written one.** + ✎ **Re-verified and completed 2026-08-04 (WS3 closeout). Every clause of this row now holds on the written condition too, and the residue the bullet above names is gone.** + - **`bollard`/`rcgen` — re-verified the way this row demands, through `cargo metadata --no-deps` rather than a literal path.** The row's own `rg` target (`crates/kernel/ironclaw_host_runtime/Cargo.toml`) does not exist on this tree — the family move is WS7 — which is exactly why it says to resolve the manifest via metadata. Result, scanning **every** dependency kind of **every** workspace package: `bollard` and `rcgen` are declared by exactly one crate, `ironclaw_sandbox` (both `normal`); `ironclaw_host_runtime` declares neither under any kind. A path-grep would have reported "nothing" for the wrong reason. + - **`host_runtime → ironclaw_extension_support` — CLOSED, and it was the last entry in the register.** The "Explicitly NOT closed by Wave 3" paragraph above is discharged in full: its remaining edge fell here, by a `loops` → `runtimes` re-layer of `ironclaw_extension_support` rather than by the `first_party_tools` shed its `removes_in` named. The reasoning, the refutation of the shed-as-written, and the both-directions measurement are on that row; the short form is that WS3's own executor/adapter seam makes the kernel a *designed* consumer of that crate, so the edge was structural and the layer declaration was the wrong half. + - **`LAYER_MATRIX_EXCEPTIONS` is now `&[]` and `WS0_LAYER_MATRIX_EXCEPTION_BASELINE` is `0`** — PROPOSAL §11.2.2's end state and **WS12's empty-register gate condition, reached**. Note what that does and does not mean: the §11.2.2 ratchet is a ceiling, so an empty list makes it an equality in effect (any new entry is red on the next commit, and re-arming needs an owner-approved baseline raise in the same PR, per the test's own message). It does **not** mean the restructure's remaining rows are done — WS12 verifies more than this one number, and this row's sibling `first_party_tools` row is still `[~]` with five executor families outstanding. + ## WS4 — Loop tier -- [x] Re-layer `runner` → loops and `hooks` → loops (clears `runner→agent_loop`, `runner→loop_host`, `hooks→wasm_limiter` exceptions). **Landed with the WS3 runner-sheds PR.** `LAYER_MATRIX_EXCEPTIONS` **13 → 10** and `WS0_LAYER_MATRIX_EXCEPTION_BASELINE` moved with it. The row read as if it were gated on the sheds; measured, it was not — both re-layers are strictly *permissive* moves (`kernel`'s allowed set ⊂ `loops`'s, `substrates`'s ⊂ `loops`'s), so they can only break **consumers**, and both crates' complete consumer sets are `ironclaw_reborn_composition` (`app`) and each other. The preconditions the PROPOSAL names were already met on `main`: #6696's supervisor inversion for the runner (§6.7.3) and WS1.2's `loop_contracts` dependency for hooks (§6.7.4). It is two `layer =` lines. **A new guard rides with it** — `reborn_runner_sheds.rs`'s fourth half pins both declarations through `cargo metadata`, because the exception register is shrink-only: reverting a layer would need three deleted entries back, and that has to fail at the declaration rather than as an undeclared-edge message three crates away. -- [x] Re-layer `skills` → substrates (§3.D) with its family move; family⇄layer test updated in the same PR. ✎ **Landed 2026-08-04 (WS3/WS4 consolidation).** A one-line manifest correction, not a code move: `families/domains.md` already listed `ironclaw_skills` under **Layer(s): substrates** and only `crates/ironclaw_skills/Cargo.toml`'s `layer =` still said `loops`, so the family⇄layer disagreement the row names was in the manifest. Verified in both directions before flipping it: the crate's only two normal dependencies are `ironclaw_filesystem` (substrates) and `ironclaw_host_api` (contracts), both at or below substrates; and its six consumers (`extension_host`, `extension_support`, `loop_host`, `first_party_extension_ports`, `extension_manager`, `reborn_composition`) are all loops or above. No exception moves in either direction and the layer-matrix gate passes. +- [x] Re-layer `runner` → loops and `hooks` → loops (clears `runner→agent_loop`, `runner→loop_host`, `hooks→wasm_limiter` exceptions). **Landed with the WS3 runner-sheds PR.** `LAYER_MATRIX_EXCEPTIONS` **13 → 10** and `WS0_LAYER_MATRIX_EXCEPTION_BASELINE` moved with it. The row read as if it were gated on the sheds; measured, it was not — both re-layers are strictly *permissive* moves (`kernel`'s allowed set ⊂ `loops`'s, `substrates`'s ⊂ `loops`'s), so they can only break **consumers**, and both crates' complete consumer sets are `ironclaw_reborn_composition` (`app`) and each other. The preconditions the PROPOSAL names were already met on `main`: #6696's supervisor inversion for the runner (§6.7.3) and WS1.2's `loop_contracts` dependency for hooks (§6.7.4). It is two `layer =` lines. **A new guard rides with it** — `reborn_runner_sheds.rs`'s fourth half pins both declarations through `cargo metadata`, because the exception register is shrink-only: reverting a layer would need three deleted entries back, and that has to fail at the declaration rather than as an undeclared-edge message three crates away. ✎ **Re-verified on this tree 2026-08-04 (WS3 closeout):** `cargo metadata` reports `layer = "loops"` for both `ironclaw_runner` and `ironclaw_hooks`; none of `runner → agent_loop`, `runner → loop_host`, `hooks → wasm_limiter` appears in `LAYER_MATRIX_EXCEPTIONS` (which is now empty outright); and the guard that pins the two declarations, `reborn_loop_tier_crates_declare_the_loops_layer_that_dissolved_their_exceptions`, is green in the unfiltered `reborn_runner_sheds` run (8/8). Tick confirmed on measurement, not on the landing note. +- [x] Re-layer `skills` → substrates (§3.D) with its family move; family⇄layer test updated in the same PR. ✎ **Landed 2026-08-04 (WS3/WS4 consolidation).** A one-line manifest correction, not a code move: `families/domains.md` already listed `ironclaw_skills` under **Layer(s): substrates** and only `crates/ironclaw_skills/Cargo.toml`'s `layer =` still said `loops`, so the family⇄layer disagreement the row names was in the manifest. Verified in both directions before flipping it: the crate's only two normal dependencies are `ironclaw_filesystem` (substrates) and `ironclaw_host_api` (contracts), both at or below substrates; and its six consumers (`extension_host`, `extension_support`, `loop_host`, `first_party_extension_ports`, `extension_manager`, `reborn_composition`) are all loops or above. No exception moves in either direction and the layer-matrix gate passes. ✎ **Re-verified on this tree 2026-08-04 (WS3 closeout), and one clause of the sentence above is now stale in a way worth recording rather than editing away.** `cargo metadata` confirms `ironclaw_skills` declares `layer = "substrates"` and `families/domains.md` agrees, so the row's condition holds. But "its six consumers are all loops or above" no longer describes the tree: **`ironclaw_extension_support` is one of those six and is now `runtimes`** (WS3 closeout — see the `first_party_tools` row), so the true statement is *at or above substrates*, which is what the matrix actually requires and what the re-layer was checked against. The `skills` `DowngradePin` added with the batch already freezes the same six consumers by name, so the change is visible to the gate rather than only to this prose. Two downward re-layers meeting inside one family is exactly the interaction that pin exists to surface. - [~] Runner sheds: `runtime.rs` `build_*` composition functions → composition; model gateway + port adapters → `loop_host`; tool-disclosure policy → loop_host/product per PROPOSAL §6.7.3; delete `production_readiness` (no production caller) or wire it. *(The scheduler shed is already done — #6696 inverted it onto `processes::ProcessSupervisor`. The await-edge shed is the WS9 open item, not this one.)* ✎ **Amended 2026-08-03 (WS3 runner-sheds PR) — two of the four clauses landed, and the other two are deferred with measurements, not skipped.** - **`model gateway + port adapters → loop_host` — DONE.** `model_gateway.rs` (+`prompt_cache_activity`), `model_gateway_error_mapping.rs`, `model_routes.rs`, `loop_driver_host/model_gateway.rs` (→ `thread_resolving_model_gateway.rs`) and `loop_driver_host/port_adapters.rs` (→ `driver_host_port_adapters.rs`) all moved, with their two integration targets (`llm_gateway`, `model_routes`). Two dispositions the row did not predict: **(a) `model_routes.rs` had to travel and is not optional.** It reads as route-*policy* vocabulary with its own runner and composition consumers, so it looks separable — but `model_gateway.rs` names eight of its types, and leaving it behind would make `loop_host → runner` a cycle against the pre-existing `runner → loop_host` edge. **(b) `model_failure_mapping.rs` must NOT travel**, though its name puts it in the cluster: its only callers are `planned_driver.rs` and `text_loop_driver.rs`, which stay, and its test needs runner-private `retry_disposition`. Moving it would create a cross-crate call in the wrong direction for no benefit. The row's "single cluster" framing is what makes both mistakes available; measure the call graph, not the filenames. @@ -286,11 +308,15 @@ owners. See the retraction on that row. --> - **The convergence the row was reaching for is already done.** Parity is enforced by a shared conformance suite, not by discipline: `predicate_state::contract` (`predicate_state.rs:957`, behind the sanctioned `test-support` feature) is the single trait-level suite, and both `tests/predicate_state_libsql_contract.rs` and `tests/predicate_state_postgres_contract.rs` run it against their driver, with `tests/parity_matrix.rs` and `tests/multi_host_adversarial.rs` (behind `integration`) covering cross-backend behaviour. That is the house pattern for multi-backend domains and it is the reason the two backends do not drift. - **Rejected alternatives, with the reason each fails.** *(a) Converge on libSQL* — drops the Postgres deployment shape the production profile selects; a capability regression dressed as a simplification. *(b) Converge on Postgres* — drops the local/dev and single-binary shapes and forces a database daemon on every developer and every `cargo test --features integration` lane. *(c) Re-extract them into per-backend crates* — reverses the fold that produced today's single conformance suite and re-opens the drift this row exists to close; it also adds two crates to a tree whose PROPOSAL §2 is deleting crates. *(d) Move the backends behind `ironclaw_filesystem`'s mount catalog* — the predicate store is counter state with read-modify-write semantics, not a file tree; the catalog's contract does not express it. - **⚠ #6945 is NOT discharged and this decision does not touch it.** The cross-run dispatcher-isolation semantic is still unpinned: `poisoned_during_dispatch_skips_subsequent_invocations` (`src/dispatch/mod.rs`) pins poisoning *within one dispatcher instance* and nothing pins the `RebornLoopDriverHostFactory` seam that actually decides the lifetime. Production remains on the safe seam (`with_hook_dispatcher_builder_factory`), so this is an unpinned property rather than a live bug. **This PR deliberately changes nothing in `ironclaw_hooks`' dispatch path** — the decision above is a recorded architectural call with no code change — so it cannot flip that property; the note's warning is about the re-layer and decorator-chain census, neither of which this row performs. Per #6945's own sketch the test belongs in `tests/integration/`, driven through `build_text_only_host_with_capabilities`, and must not assert isolation for predicate counter state, which is tenant-scoped and shared across runs by design. + ✎ **CLOSED OUT 2026-08-04 (WS6 decision-rows PR; delegated authority — PROPOSAL §12.12 D-M). The decision above stands, its central premise is CORRECTED, and #6945 is now discharged.** + - **⚠ The 2026-08-04 decision text above is factually wrong on one load-bearing point, and the ADR does not repeat it.** It says *"composition chooses PostgreSQL or libSQL by profile through the `RootFilesystem` mount catalog"* and rejects convergence on the grounds that it would *"delete a shipped deployment shape"*. **Neither durable hooks backend is wired at all.** `crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs:325` hard-codes `Arc::new(InMemoryPredicateStateBackend::new())` — the only `with_state_backend` call site outside the owning crate — and a workspace search for `LibSqlPredicateStateBackend`/`PostgresPredicateStateBackend` outside `crates/ironclaw_hooks/` returns **zero** hits. There is no profile switch, no config key, no env var; `warn_in_memory_backend_active_in_production()` exists precisely because that is the state. The rejected alternatives (a) and (b) above are therefore argued from a false premise. **This is the difference between the two ADR-or-converge rows** and the reason they are not one decision: `triggers` really does ship both shapes (ADR 0003), `hooks` ships neither. + - **The decision survives the correction, on different reasoning** — recorded in [`docs/adr/0004-hooks-keeps-its-predicate-state-backends.md`](../../adr/0004-hooks-keeps-its-predicate-state-backends.md). The honest question is not "which shipped shape do we drop" but "do two unwired, fully-implemented backends (**1,803 lines**) earn their keep": they do, because (1) they close a gap in-memory **structurally cannot** — its replay dedup is process-local (`predicate_state.rs:357`), so rate/value caps are bypassable the moment a second host exists, and `tests/multi_host_adversarial.rs` (783 lines) exists for exactly that; (2) they are proven interchangeable rather than rotting — `tests/parity_matrix.rs` cross-asserts all three backends *and* an independent hand-computed oracle, so a bug shared by two backends still fails; (3) the swap is **one line** (`factory.rs:325`), so deletion is the expensive option, not the cheap one. The ADR states plainly that it keeps code production does not execute, and names the revisit conditions — first durable wiring, or multi-host being formally dropped. + - **✅ #6945 DISCHARGED — the regression guard landed with the ADR.** `poisoned_hook_slot_does_not_leak_into_the_next_run`, added to `tests/integration/hooks.rs` (**extending** the existing hooks file, not a new one), at exactly the tier and through the caller #6945 sketched. A `PrivilegedBeforeCapabilityHook` commits a gate-sink protocol violation (`GateSinkState::Unset` → `FailureCategory::Malformed`), which fails closed **and** poisons the slot — protocol violation rather than `panic!` so the same `classify_failure` path is reached without an unwind backtrace in the log. Two `submit_turn` calls on one harness are two `build_text_only_host*` calls: run 2 must get a clean slot, fire the hook **again**, and re-apply the deny. **Red-ability verified, not assumed** — pointing `ironclaw_runner::runtime` at the legacy `with_hook_dispatcher` adapter fails it on the exact assertion (`left: ["…poison:builtin.http"]` vs `right: [… , …]`, 1 fire instead of 2) and the sabotage was reverted (`git diff` on `runtime.rs` empty). The sabotage surfaced a **second** signal worth recording: `hook_deny_blocks_capability_without_wedging_run` also goes red, because the legacy adapter bypasses the security-audit sink the builder-factory path attaches internally — so the seam carries more than poisoning. **Deliberately not asserted: predicate counter state**, per #6945 and `loop_driver_host.rs:1387-1389` — it is tenant-scoped and shared across runs by design, so pinning isolation for it would pin a rate-cap bypass. `crates/ironclaw_hooks/CLAUDE.md` now names a test that exists, with a standing warning about the passage's history. - [x] ~~`wit/` moves to `crates/lanes/wit/`;~~ wasm bindgen path updated; §11.2.7 scan passes. ✎ **Amended and landed 2026-08-03 (Wave 3 `wit/` move). The destination in the struck text was wrong and this row was the *only* place that said it.** `crates/lanes/wit/` puts the WIT files beside `ironclaw_wasm` as a sibling of the crates in the family directory; every other doc site says **inside** the crate — PROPOSAL §6.6.1 ("the directory moves inside the crate … matching the spec's ownership claim and the wit-bindgen default"), the §5 tree ("`wit/` lives inside the crate"), the §12 disposition table row 42, and WS10's own `wit/` row below. Inside-the-crate wins, on §6.6.1's stated reasoning plus one this row could not have known: a family directory holding a non-crate directory is exactly what §11.2.1's no-stray-toplevel/family⇄layer check exists to reject, and the crate-local form is the only one that survives the WS7 `git mv` **without a second path edit anywhere**. **As built: `crates/ironclaw_wasm/wit/{tool,channel}.wit`.** Wave-3 coordinates are deliberate — `crates/lanes/` does not exist until WS7, and because the files now sit inside the crate the family move carries them with zero further changes, which is the whole point of putting them there. The §11.2.7 clause is discharged **fully rather than partially**; see the WS10 row for why "repoint the four `include_str!` sites" would have discharged it only halfway. ## WS5 — Product family -- [ ] `webui`: dep flips to `product_contracts`; the bearer-evidence mint import moves to `host_api`'s sealed home; gains pairing routes; verify its boundary rule updates. **Port-inversion half landed with the WS5 transport PR** (the mint moved with WS1.5); ~~pairing routes and the boundary-rule check are still open~~ ✎ **pairing routes landed 2026-08-02 with the WS2 strays-and-follow-ups PR** (`crates/ironclaw_webui/src/channel_pairing.rs`; the dispositions, including the new `webui → extension_host` edge and why composition stopped handing out the mount, are on the WS2 strays row). The boundary-rule check is still open — webui's `BoundaryRule` did not need an edit (`ironclaw_extension_host` is not on its forbidden list), but nobody has re-derived that list against §6.9.4. `ironclaw_webui`'s production `ironclaw_product::` usage went **228 → 102 symbols across 4 files** ✎ *(**→ 100** by the time the stack merged; "4 files" is exact. Corrected 2026-08-02: the WS5 `attachments widened` row **in the same PR** moved `ProductAttachmentCapabilities`/`product_attachment_capabilities` out to `ironclaw_attachments`, which is the two-symbol difference. The gate that pins it says so in its own comment — "102 when the WS5 transport inversion landed; **100** after the WS5 `attachments widened` row" — so the correction was written down in code and never propagated to the four doc sites that carry the number: this row, sub-finding 2 below, PROPOSAL §6.9.4, and `crates/ironclaw_webui/CLAUDE.md`. Live pin: `WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 100` over a 100-entry exact-match list. **The lesson for a consolidated stack: when two slices in one PR touch the same count, the second one owes the first one's rows an edit.**)*. Three findings the row did not predict: +- [ ] `webui`: dep flips to `product_contracts`; the bearer-evidence mint import moves to `host_api`'s sealed home; gains pairing routes; verify its boundary rule updates. **Port-inversion half landed with the WS5 transport PR** (the mint moved with WS1.5); ~~pairing routes and the boundary-rule check are still open~~ ✎ **pairing routes landed 2026-08-02 with the WS2 strays-and-follow-ups PR** (`crates/ironclaw_webui/src/channel_pairing.rs`; the dispositions, including the new `webui → extension_host` edge and why composition stopped handing out the mount, are on the WS2 strays row). The boundary-rule check is still open — webui's `BoundaryRule` did not need an edit (`ironclaw_extension_host` is not on its forbidden list), but nobody has re-derived that list against §6.9.4. `ironclaw_webui`'s production `ironclaw_product::` usage went **228 → 102 symbols across 4 files** ✎ *(**→ 100** by the time the stack merged; "4 files" is exact. Corrected 2026-08-02: the WS5 `attachments widened` row **in the same PR** moved `ProductAttachmentCapabilities`/`product_attachment_capabilities` out to `ironclaw_attachments`, which is the two-symbol difference. The gate that pins it says so in its own comment — "102 when the WS5 transport inversion landed; **100** after the WS5 `attachments widened` row" — so the correction was written down in code and never propagated to the four doc sites that carry the number: this row, sub-finding 2 below, PROPOSAL §6.9.4, and `crates/ironclaw_webui/CLAUDE.md`. Live pin: `WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 100` over a 100-entry exact-match list. **The lesson for a consolidated stack: when two slices in one PR touch the same count, the second one owes the first one's rows an edit.**)*. Three findings the row did not predict: ✎ **Boundary-rule re-derivation DONE 2026-08-04 (WS6 runtime-and-types PR) — this closes the row's last open clause.** §6.9.4 carries **no forbidden list of its own** (it is an Owns/Changes entry; it says only that the rule "should be re-derived rather than assumed"), so the derivation runs off its parents: §8.2's `product/` row (**app ✗**, "product still ✗ host_runtime/dispatch/lanes"), §8.2's retained named rule "concrete extension crates link only from the binary", and `families/product.md`'s "never touches a lane crate / the extension registry or hosting crates". **Result: zero removals, nine additions, every one a no-op ratchet** — webui's ten normal workspace deps are `host_api`, `product_contracts`, `extension_contracts`, `extension_host`, `host_ingress`, `auth`, `attachments`, `common`, `product`, `reborn_openai_compat`, and none of the nine appears in any dependency kind. Added: `ironclaw_reborn_composition` (§8.2 app ✗ — and the edge runs the *other* way: the rule's own comment says webui receives its handles *from* composition; it was on 15 other rules and on every other products-layer rule but `ironclaw_product`'s), `ironclaw_wasm_limiter`, `ironclaw_slack_extension`, `ironclaw_telegram_extension`, `ironclaw_event_projections`, `ironclaw_event_streams`, `ironclaw_extension_support`, `ironclaw_first_party_extension_ports`, `ironclaw_storage`. **`ironclaw_wasm_limiter` is the one that matters**: it is a lane crate that **no `BoundaryRule` in the workspace named**, and the only gate that mentions it checks its *outbound* deps, so an inbound edge onto it was unguarded by anything. The other eight are defense-in-depth over gates that already cover them (the layer matrix for composition, the concrete-extension gate for slack/telegram) or parity with `ironclaw_reborn_openai_compat`, the sibling transport, whose 38-entry list these five closed the gap to. **Deliberately NOT added, and the rule comment says so:** `ironclaw_product` (§12.11 D-B permanent edge) and `ironclaw_extension_host` (§6.9.4's own pairing amendment) — adding either would contradict a ruling this document already made. **One trap recorded in the rule:** four entries on the list (`ironclaw_secrets`, `ironclaw_loop_host`, `ironclaw_threads`, `ironclaw_turns`) are live *dev*-dependencies of `ironclaw_webui`, so the rule must stay normal-deps-only; tightening it to all dependency kinds — the `ironclaw_host_ingress` treatment — would go red on four counts the day it landed. ⚠ **Two adjacent gaps found and NOT closed here**, both filed on this row rather than smuggled in: (a) `crates/ironclaw_webui/src` is still absent from `reborn_product_api_crates_do_not_bind_http_ingress`'s roots — its `KNOWN GAP` comment is still there and §8.2's 2026-08-02 amendment already decided the fix; (b) `families/product.md`'s webui entry still says webui never depends on "hosting crates", which §6.9.4's pairing amendment overrode — the families doc was never updated. 1. **The dep cannot flip, and §6.9.4 undercounts why by 91.** §6.9.4 says webui's only non-DTO product import is the bearer-evidence mint. It is not: **91 of the 102 survivors are the concrete command/view/capability *constants*** (`THREADS_VIEW`, `SUBMIT_TURN_COMMAND`, `EXTENSION_INSTALL_CAPABILITY`, …), which §6.1.3 explicitly keeps in product as "the frozen inventory" while granting contracts only the descriptor *types*. A route handler holds the constant to call the surface, so **`webui → product` survives this row structurally**, exactly as `extension_host → product` survived WS2.1. Moving the inventory would hand the contracts crate product's surface — `reborn_transport_product_boundary.rs` therefore pins the inventory *in product* as well as pinning the moved vocabulary in contracts. ✅ **[decision] RESOLVED 2026-08-02 (delegated authority — PROPOSAL §12.11 D-B).** **§6.1.3's carve-out is upheld — the frozen inventory stays in `ironclaw_product`, the `the_frozen_operation_inventory_stays_in_product` gate stays armed, and §6.9.4 is re-worded: `webui → ironclaw_product` is a charter-sanctioned permanent edge, not a pending flip.** The premise that made the flip look reachable is false: the constants are **not** `&str`, they are values of *generic* descriptor types whose type parameters name the response DTOs (`ProductSurfaceCommandDescriptor`, `ProductView`), so constants and DTOs are one problem, not two. And webui independently names **9** wire DTOs, so no inventory move flips its dep regardless. Making webui contracts-only would require relocating 17 product-local types plus the `ironclaw_threads` record family into the contracts crate — which is exactly what §6.1.3's "Must never contain" forbids and what the gate's own failure message calls "not the fix". **`openai_compat` is split off from this ruling and its flip IS reachable** — it names 3 constants and **zero** DTOs, and one response type (`RebornCreateThreadResponse` → `ironclaw_threads::SessionThreadRecord`) is the entire blocker; §6.9.3 gets a named WS5 owner for it. Four inventory corrections land with this: the constant count is **26 commands / 35 capabilities / 37 views = 98**, not §6.1.3's "27/33/18"; the DTO residue is **9**, not 11 (the two dropped were `ProductAttachmentCapabilities` and a *function*); the foreign crates are **4** (`threads`, `auth`, `common`, `loop_contracts`) — `ironclaw_attachments` is named by zero DTO fields, the `AttachmentRef` at depth 3 is `ironclaw_common`'s; and the bearer-mint import §6.9.4 called "the one non-DTO import" no longer exists at all. 2. **Eleven survivors are contract-purity residue, not inventory** ✎ *(**nine** at merged `main` — the first two named below left with the attachments row in the same PR, exactly as this sentence predicted they would. Corrected 2026-08-02.)* — `ProductAttachmentCapabilities`/`product_attachment_capabilities` (`ironclaw_attachments` budgets — the WS5 `attachments widened` row owns them ✎ *— and took them; they are `AttachmentCapabilities`/`attachment_capabilities()` in `crates/ironclaw_attachments/src/lib.rs:30` now, and the two rows were **deleted** from the transport gate's residue list rather than repointed*), the three `ironclaw_threads`-typed thread/timeline responses, the two `ironclaw_auth`-typed extension responses (through `RebornVendorAuthAccounts`/`RebornAuthAccount`, which stayed with them), the two `ironclaw_threads`-typed artifact exports, `RebornGetRunStateResponse` (`ironclaw_common::RunCost` + `ironclaw_loop_contracts::LoopModelUsage`), and `RebornExecuteProductCommandResponse` (`crate::commands::CommandResultView`). Same mechanical cause as WS2.1's six blocked ports: the contracts allowlist is `host_api` + `extension_contracts` and nothing else. **A second blocker no row had named — the orphan rule.** Once a DTO's home is the contracts crate, an `impl From for ThatDto` has neither side in `ironclaw_product` and cannot be written there at all; the same holds for every inherent method on a moved DTO. Three kernel conversions became free functions (`reborn_cancel_run_response`/`reborn_resume_gate_response`/`reborn_retry_run_response`) and the request-body normalization became the `IntoProductInboundCommand`/`DecodeInboundAttachments` extension traits. Every later DTO move across this boundary — the WS5 `operator` and `product` rows especially — pays the same cost, so budget for it. 3. **The move exposed a live duplicate type name.** `RebornSkillSourceKind` existed twice — product's WebUI catalogue enum `{User, Installed, Workspace, System}` and a composition activation enum `{System, TenantShared, User}` mapped from `ironclaw_skills::SkillSourceKind`. Invisible while both sat outside contracts; §11.2.4's location scan caught it the moment the wire enum moved. Composition's is renamed `RebornSkillActivationSource` (same PR, snapshot updated). This is the class the WS8 type-audit row names for `ExtensionActivationMode`; treat the location scan as a duplicate-name detector, not only a re-export detector. @@ -301,8 +327,16 @@ owners. See the retraction on that row. --> 4. **The vendor rule does not cover the contracts family, and the port is vendor-shaped.** §8.2 sanctions vendor names in `packages/*`, `llm` providers, `operator`, `webui::auth` login providers, and recipes-as-data. Declaring `LlmConfigService` at the boundary moves vendor vocabulary into a **contracts** crate, which that list does not name. The specificity scanner saw the visible tip — `NearAiAuthProvider::{Github, Google}`, whose two allowlist entries were **repointed, so the baseline stays 129** — but the real surface is larger and invisible to it: three vendor-named *methods* (`start_nearai_login`, `complete_nearai_wallet_login`, `start_codex_login`) and six vendor-named DTOs. Generifying them is a design change (NEAR AI SSO, NEAR wallet NEP-413, and OpenAI Codex device-code are three protocols behind one port), so it is **recorded here, not done in a move PR**. See the `[decision]` row below. 5. **The error projection moved with the error, per WS2.2's rule.** `LlmConfigServiceError` → `ProductSurfaceError` is now `impl From<..>` in contracts, defined once; product's call sites use `.map_err(ProductSurfaceError::from)` directly — the one-line `map_llm_config_error` delegate this row originally kept was deleted on review (#7004), since a pass-through that only preserves a spelling hides where the mapping lives. The other projection of the same error — composition's `map_llm_config_error_to_openai` — is a *different* target taxonomy, not a duplicate, and stays. - [ ] ✅ **[decision] RESOLVED 2026-08-02 (delegated authority — PROPOSAL §12.11 D-E).** **Option (b), bounded: §8.2's vendor rule is amended to sanction LLM-vendor administration vocabulary in `ironclaw_product_contracts::operator_llm` and nowhere else in the contracts family.** The decisive reason is **not** the one the WS5 PR gave. Measured: the Rust method and DTO *names* never appear on the wire — the JSON bodies are `{auth_url}`, `{active}`, `{user_code, verification_uri}`, and the frozen surface is the URL paths, the JSON field names and the provider-id strings (`"nearai"`, `"openai_codex"`), none of which a Rust-side rename touches; the SPA ships from the same repo and binary. So "a live WebUI wire contract + i18n blast radius" does not hold and must not be carried forward as the justification. What does hold is that the three flows are **three protocols**, not one shape with three parameters: NEAR AI SSO (2 fields in, `{auth_url}` out, one-time-state store, completed on a separate *public* HTTP route), NEAR wallet NEP-413 (7 fields in, synchronous, completion-half only — there is no start call, because signing must happen in-browser), and OpenAI Codex device-code (0 fields in, 2 out, TTL'd per-caller attempt ledger, completed in a spawned background task). A neutral port collapses to `start_login(provider_id, serde_json::Value) -> LoginChallenge{kind}` — the untyped shape `.claude/rules/types.md` exists to prevent — or to an enum whose variants name the same three vendors, which buys nothing. **Two corrections land with this:** the module is `operator_llm`, not `llm_config` (`product_contracts/src/lib.rs:47`; three crate guides name a module that does not exist, one of them contradicting its own module table); and §12.9's carve-out is disjoint from the violation — the LLM-vendor command-id strings it protects live in `ironclaw_product/src/reborn_services.rs:444/449/454`, **not** in `product_contracts`, while the 3 methods + 6 DTOs that do live there were never covered. **The sanction is bounded and needs a mechanical pin:** the specificity scanner cannot see this surface at all — `nearai` is globally carved by `TERM_COLLISIONS` as the assistant's LLM backend id and `codex` is not a derived term — so "no seventh vendor name" is review discipline, not enforcement. A targeted vendor-name census on `operator_llm.rs` is owed with the amendment; a *fourth* provider login must arrive as a package or behind a shape that adds no vendor-named method or DTO. *Original row text follows.* *(raised by the WS5 operator row; evidence in that PR.)* PROPOSAL §8.2's vendor rule lists exactly where a vendor name may appear in code, and the contracts family is not on it. But the port `ironclaw_operator` implements has three vendor-named methods and six vendor-named DTOs (`NearAi*` ×5, `CodexLoginStart`), and a port must be declared where its implementor can compile against it. Two options: **(a)** narrow the port to a neutral shape (one `start_provider_login(kind, request)` over an open provider-login vocabulary, with the three protocols behind it) — the honest fix, but a design change with a live WebUI wire contract attached; **(b)** amend §8.2 to sanction LLM-vendor admin vocabulary in `product_contracts::operator_llm` specifically, which makes the rule say what the code does. Whichever wins, the two repointed specificity entries (`operator_llm.rs` × `github`/`google`) move or vanish with it. Not urgent — nothing is blocked on it — but it should not drift into "the allowlist grew again". -- [ ] `openai_compat`: rename from `reborn_openai_compat` **[decision — severable]**; dep flips to contracts; stale `storage`/`libsql`/`postgres` feature guidance corrected in all five audited places; collapse the LibSql/Postgres ref-store newtype wrappers onto the generic fabric form (same for product's ledger wrappers). **Dep-flip half landed with the WS5 transport PR:** production `ironclaw_product::` usage **23 → 3 symbols across 7 → 2 files**, and the three survivors are `SUBMIT_TURN_COMMAND` / `CREATE_THREAD_COMMAND` / `CANCEL_RUN_COMMAND` — the frozen inventory again, the same structural reason webui's dep survives. Every DTO it speaks now comes from `ironclaw_product_contracts`; it also took the `+extension_contracts` edge §6.9.3 grants, for the one channel-facing enum it stamps (`ProductTriggerReason`). Rename and ref-store collapse untouched. ✎ *Row correction:* the "stale feature guidance in five audited places" item was already discharged by the WS11.3 drift-hotfix PR (see the WS11 row's "stale feature-gating in `product`/`openai_compat`/`event_store`/`webui`/`llm`"); it is double-counted here and should be struck when this row is next edited. +- [ ] `openai_compat`: rename from `reborn_openai_compat` **[decision — severable]**; dep flips to contracts; stale `storage`/`libsql`/`postgres` feature guidance corrected in all five audited places; collapse the LibSql/Postgres ref-store newtype wrappers onto the generic fabric form (same for product's ledger wrappers). **Dep-flip half landed with the WS5 transport PR:** production `ironclaw_product::` usage **23 → 3 symbols across 7 → 2 files**, and the three survivors are `SUBMIT_TURN_COMMAND` / `CREATE_THREAD_COMMAND` / `CANCEL_RUN_COMMAND` — the frozen inventory again, the same structural reason webui's dep survives. Every DTO it speaks now comes from `ironclaw_product_contracts`; it also took the `+extension_contracts` edge §6.9.3 grants, for the one channel-facing enum it stamps (`ProductTriggerReason`). Rename and ref-store collapse untouched. ✎ *Row correction:* the "stale feature guidance in five audited places" item was already discharged by the WS11.3 drift-hotfix PR (see the WS11 row's "stale feature-gating in `product`/`openai_compat`/`event_store`/`webui`/`llm`"); it is double-counted here and should be struck when this row is next edited. ✎ **Ref-store collapse DONE 2026-08-04 (WS6 runtime-and-types PR); rename still untouched, so the box stays open.** The two wrappers were **byte-identical modulo the concrete filesystem type** and had **zero construction sites anywhere in the repo** — production wires the generic form directly (`composition/src/llm_admin/openai_compat_serve.rs`), and the crate's own contract suite drives `OpenAiCompatRefStore` over `InMemoryBackend`. They were also strictly *less* capable than the generic form (neither exposed `with_cas_retries`). Deleted: 110 lines of `refs_storage.rs`, the two now-unused backend imports, and two `lib.rs` re-exports — zero call sites to change. **The `ironclaw_product` half needed three constructors first, which is why it is not a pure deletion.** `RebornFilesystemIdempotencyLedger` exposed only the *scoped* constructor family while the two wrappers exposed the *root* family; the private core already had both, so `new_root` / `with_root_lease` / `with_virtual_root` are pure delegation. (The third needs a distinct name: `with_root` is already taken by the scoped 4-arg signature — a name collision the row could not have predicted.) 119 lines and two re-exports deleted; the 26 call sites in `product/tests/durable_ledger_contract.rs` keep their two backend lanes as `type` aliases onto the generic form, which is stronger than repointing them one by one because it *proves* both lanes resolve to the same type. **Provenance worth recording:** both wrapper pairs arrived with `7b584e4874` (#5540), which folded the per-backend sub-crates into their parents — they are fossilized crate boundaries, exactly the "backend duplication" PROPOSAL §1000 names, and neither had gained a caller since. ✎ *Also discharged in passing:* the row's struck "stale feature guidance" clause had **three survivors** the WS11.3 hotfix missed — `openai_compat/Cargo.toml`'s `# Force \`storage\` on…` comment above a vestigial self-dependency that enables nothing (both deleted), `tests/ref_store_contract.rs:1`, and the `ironclaw_filesystem` carve-out comment in `reborn_dependency_boundaries.rs`. All three corrected. - [ ] `product` narrows: ports/DTOs out (WS1); `adapter_registry` manifest parsing → `extension_contracts`/`extension_registry` (resolving its guidance-vs-code contradiction); the ~120-symbol `host_api::product_adapter` re-export facade dissolved; slack/telegram token heuristics → packages; `external_tool_catalog` moves in from `turns`; `reborn_services` module-charter map committed (freeze ratchet stays). ✎ **2026-08-04: the `adapter_registry` clause is a prerequisite of the `extension_host -> loops` re-layer (#7145)** — three extension-host production files consume the manifest projection (`available_extensions.rs`, `channel_lifecycle.rs`, `host_api_contracts.rs`, per the `EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT` ledger; a fourth use in `channel_subject_routes.rs` is `#[cfg(test)]`-only and does not block), so moving the parsing to `extension_contracts`/`extension_registry` clears the adapter-registry class of the flip's residue. + ✎ **`adapter_registry` clause DONE 2026-08-04 (WS5 adapter-registry move). Ledger 9 → 6; `EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE` 9 → 6; the `adapter-registry` blocker class is discharged and no ledger row carries it. The rest of this row (facade dissolution, token heuristics, `external_tool_catalog`, the charter map) is untouched — the box stays open.** + - **Where it went, and why the `/` in "`extension_contracts`/`extension_registry`" is a real split rather than a hedge.** The destination was measured against the gates before anything moved. `ironclaw_extension_contracts`' allowed-dep set is exactly `{ironclaw_host_api}` and its own crate doc states the charter in as many words — *"It parses no manifests, stores no installations, routes no ingress"* — so **nothing** typed on the v2 manifest grammar could land there: `ManifestSectionPath`, `ExtensionManifestRecord`, `ExtensionManifestV2`, `HostApiContractRegistry`/`HostApiManifestContract`, `ManifestSource`, `ManifestHash`, `PackageRootBinding`, `ManifestV2Error`, `ExtensionInstallationError`. What *could* is the declared section **schema**, which is precisely what §6.1.2 already owns for `[channel]` (`ChannelDescriptor` + `validate()`, parsed by `ironclaw_extensions::v3`) and `[memory]`. So the split follows the in-repo precedent exactly: **`ironclaw_extension_contracts::product_adapter_section`** holds `PRODUCT_ADAPTER_HOST_API_ID`/`PRODUCT_ADAPTER_SECTION_PREFIX`, `ProductAdapterSectionDeclaration` (the `Deserialize` wire shape, `deny_unknown_fields`), `ProductAdapterSection` (resolved + validated, incl. the host-ingress credential-coherence rules and the RFC 7230 token checks), `HostIngressRoute`, and `ProductAdapterSectionError`; **`ironclaw_extensions::host_api::product_adapter`** holds the `HostApiManifestContract` impl, `register_product_adapter_host_api_contract`, `parse_product_adapter_manifest_record`, `product_adapter_sections`, the raw-`toml::Value` inline-secret guard, and `RegistryError`. It sits beside `host_api/capability_provider.rs`, the sibling built-in contract, and is deliberately **not** re-exported from the crate root (§11.2.4, one import path). + - **The guidance-vs-code contradiction, measured — and the code won on the fact, not on the rule.** `crates/ironclaw_product/CLAUDE.md:129` listed `ironclaw_extensions` under *"Must NOT depend on"*; `crates/ironclaw_product/Cargo.toml:41` declared it; and the **enforced** `ironclaw_product` `BoundaryRule` in `reborn_dependency_boundaries.rs` never named it. So the guidance was aspirational and unpoliced, and the dependency was real — three documents disagreeing with one manifest, with no test on the side of any of them. `adapter_registry.rs` was its **sole** consumer (every `ironclaw_extensions` reference in `crates/ironclaw_product/src` was in that one file), so the move drops the manifest entry outright. The resolution is not "believe the doc": the rule is now **enforced** — `ironclaw_extensions` added to product's `BoundaryRule`, and product's CLAUDE.md rewritten to say it is a copy of that gate rather than a wish. **Generalizable:** a "Must NOT depend on" list in a crate guide is worth nothing until it appears in `boundary_rules()`; when a doc and a manifest disagree, check whether *any* test was ever on either side before deciding which is stale. + - **One type was two types wearing one name.** `ProductAdapterHostApiSection` mixed the resolved section with the `ManifestSectionPath` it was declared at. It is now `ProductAdapterSection` (contracts) plus a registry-side `ProductAdapterHostApiSection { section, resolved }` reached through `.resolved()` — **no per-field delegates**, so the wrapper adds the section path and mirrors nothing (`.claude/rules/type-placement.md`). Two smaller de-duplications fell out of the same reading: `HostIngressRoute`/`RawHostIngressRoute` were the same struct declared twice (collapsed to one `Deserialize` type), and `pub use ironclaw_extensions::ManifestHash` — a re-export shim that existed only because the module lived a crate away — is deleted. + - **`RegistryError` shed four variants, measured dead.** `UnknownManifest`, `UndeclaredCredentialHandle`, `ManifestExtensionMismatch`, `ManifestHashMismatch`: **zero constructors and zero match sites workspace-wide**, and each duplicated a *live* `ExtensionInstallationError` variant that the enum already wraps `#[error(transparent)]`. Harmless at a crate's distance; a mirror inside one crate the moment the module landed in `ironclaw_extensions`. The remaining schema variants moved to `ProductAdapterSectionError` and are reached through a transparent `RegistryError::Section`, so every rendered message is byte-identical. + - **A pin the row did not predict, and it worked.** `reborn_same_layer_edge_inventory.rs`'s `DOWNGRADE_PINS` row for `ironclaw_extensions` (#7094) froze `permitted_consumers`, and `ironclaw_product` was on it. Dropping the dependency made that entry **stale**, and the gate said so by name — *"no longer depends on … delete the stale permitted_consumers entry so the frozen set keeps shrinking."* Deleted in the same change. This is the #7149 consumer-set pin doing exactly the job it was added for, from the shrinking side rather than the widening side, and it is the only gate outside the ledger that noticed the move at all. + - **Vendor allowlist steady at its current size — repointed, not added.** The three `reborn_extension_specificity.rs` entries (`github`/`slack`/`telegram`) belong to the inline-secret guard's token prefixes, which stayed with the raw-TOML parse stage; they moved file to `crates/ironclaw_extensions/src/host_api/product_adapter.rs`. The contracts half carries **no** vendor name: its unit fixtures were rewritten generically (`X-Example-Secret-Token`, `example_bot_token`) rather than carved, the disposition §6.1.3 records for `ProductConversationRouteKey`. Note this is *not* the row's separate "slack/telegram token heuristics → packages" clause — that heuristic is still host-side, now one crate lower, and still owed to the packages. + - **`section()` had zero callers before this move and has one after.** The ingestion suite now pins `product_adapter.inbound`, so the field the wrapper exists for is executable rather than merely stored. - [x] **`conversations -> turns` — its own slice, and the register's only domain exception (row added 2026-08-04; until now no row owned it — the removal condition lived only inside WS1's verify-row explanation, which is exactly how a milestone silently expires, and §8.3's 2026-08-02 amendment explicitly asked for this re-milestone).** Move the inbound submit orchestration to the product tier: `InboundTurnService` is generic over `C: TurnCoordinator`, holds `Arc` and calls `submit_turn(SubmitTurnRequest)`, and `trusted_trigger.rs` classifies `TurnError`/`AdmissionRejectionReason` — turn admission *authority*, not vocabulary, which no contracts crate can dissolve. §6.4.2 already anticipates the end state (conversations' target deps: `filesystem`/`host_api`/`safety`/`triggers` + turn vocabulary via `host_api`, no coordinator). Deliverable: `ironclaw_conversations`' manifest drops `ironclaw_turns`; the `conversations -> turns` entry is deleted from `LAYER_MATRIX_EXCEPTIONS` and `WS0_LAYER_MATRIX_EXCEPTION_BASELINE` lowered in the same change (the entry's `removes_in` names this row). ✎ **Measured 2026-08-04 (WS5 sever slice) — the vocabulary half landed; the orchestration half is BLOCKED on an owner call, because the destination this row names is forbidden by §8.2's own named rule. The box stays open deliberately.** The row was written from §8.3's amendment, which was written from the WS1.2 re-verification — none of the three checked the destination against the gates that police it. Measured against the code, "move it to the product tier" is not a plan this repo can execute. - **The residual is two names and one orchestration, not a diffuse dependency — and the first of those three is now discharged.** Every `ironclaw_turns` name `ironclaw_conversations` reaches for splits cleanly in two. **Ten are `host_api`'s already** and were travelling through a §11.2.4 two-import-path hop: `ironclaw_turns/src/lib.rs` re-exports them from `ironclaw_host_api::turn` under a comment that says so in as many words (*"The turn vocabulary itself is `ironclaw_host_api::turn`'s, not this crate's … A crate that needs only vocabulary must depend on `ironclaw_host_api` directly"*). `AcceptedMessageRef`, `IdempotencyKey`, `ReplyTargetBindingRef`, `SourceBindingRef`, `TurnActor`, `TurnScope`, `RunProfileId`, `RunProfileRequest`, `RunOriginAdapter`, `TurnSurfaceType` are now imported from `ironclaw_host_api::turn` across `traits.rs` / `types.rs` / `memory.rs` / `conversation_state_store.rs` / `inbound.rs` (5 inline absolute paths repointed with them). **Zero manifest change** — the crate already depended on `host_api` — and zero behaviour change: same types, same order, 97/97 tests green. This is the same repoint the WS3 `mcp` row got "for free" on `ResourceReceipt`, and it is a precondition of *every* resolution of the fork below, so it lands regardless of how the fork settles. @@ -346,21 +380,32 @@ owners. See the retraction on that row. --> ## WS6 — Composition, app, and domain evictions -- [ ] Composition behavior evictions (each its own PR). *Partly landed with #6691 (2026-07-30) — see PROPOSAL §6.10.1 for the item-by-item reconciliation.* **Done:** automations panel service and communication-context orchestration → `product`; project service + project-create capability → `product` (⚠ landed in `product`, not `projects`/`identity` as §6.4.11 targets, and dragged in a new `product → loop_host` behavioral edge — re-shedding both is still owed); capability-surface / skill-activation / external-tool / result-read / surface-disclosure / synthetic-capability adapters → `extension_host` / `first_party_extension_ports` / `loop_host`. **Still owed:** approval/authorization/trigger-fire policy → `approvals`/`authorization`/`runtime_policy`+`triggers` + trusted-submit logic → `triggers`/`conversations`; admin-user directory → `product`; trace capture (+ hooks projection) → `traces` + turn-runner observer seam; ~~system-prompt content → owning prompt asset~~ — **done 2026-08-03**: the four assets are `crates/ironclaw_loop_host/prompts/{default_system,tool_disclosure_protocol,self_knowledge,benchmarking_mode}.md`, exported from `system_prompt_assets.rs`; composition consumes the consts and keeps only assembly plus the boot-time seeding of the on-disk `SYSTEM.md` (`std::fs` on a real host path — `ironclaw_loop_host` has zero `std::fs` uses, so the seeding could not travel). Pinned by `reborn_composition_boundaries.rs::composition_root_embeds_no_prompt_content`, which fails on either half — a re-added `include_str!("…​.md")` or a re-added shipped `.md` asset. The runtime storage path `system/prompts/default-system.md` is deliberately unchanged: it is where existing installs' user-edited file lives.; OpenAI-compat + NEAR-login route mounts → `openai_compat`/`operator` factories; project filesystem reader → `identity::projects`; blocked-auth resume fan-out → `product`/`auth`; Google OAuth secret store + NEAR-AI MCP module → package/auth recipes. +- [ ] Composition behavior evictions (each its own PR). *Partly landed with #6691 (2026-07-30) — see PROPOSAL §6.10.1 for the item-by-item reconciliation.* **Done:** automations panel service and communication-context orchestration → `product`; project service + project-create capability → `product` (⚠ landed in `product`, not `projects`/`identity` as §6.4.11 targets, and dragged in a new `product → loop_host` behavioral edge — re-shedding both is still owed); ✎ **Sever re-measured 2026-08-04 on the waves-0-4 batch union (post the WS6 evictions + extension-host residue folds), correcting the earlier "5 files / 3 seams" figure:** the `product → loop_host` surface is **6 production files across 4 distinct seams** — (1) the `HostInputEnqueuePort` input-enqueue seam (`steering.rs:24`, `reborn_services.rs:67`, `inbound_turn.rs:25` — with `RejectingInputEnqueue`, `EnqueueQueuedMessageRequest`, `HostInputQueueError`); (2) `scoped_fs/attachment_reader.rs:23` *implementing* `ironclaw_loop_host::LoopAttachmentReadPort`; (3) `project_create_capability.rs:16` importing the synthetic-capability family (`SyntheticCapability*`, `CapabilityResultWrite`, `DurablePersistence`); (4) `projection/turn_events.rs:871`, a doc-comment-only mention of `FAILURE_EXPLANATION_SYSTEM_PROMPT` (zero code dependency). `products → loops` is a downward edge, so no armed gate fires — the debt is design-rule only. Severing means hoisting or re-homing three port families, which is a design slice, not a mechanical move; carried over past the Waves 0–4 close with this measurement as its scope. capability-surface / skill-activation / external-tool / result-read / surface-disclosure / synthetic-capability adapters → `extension_host` / `first_party_extension_ports` / `loop_host`. **Still owed:** ~~approval/authorization/trigger-fire policy → `approvals`/`authorization`/`runtime_policy`+`triggers`~~ — **done 2026-08-04 (WS6)**, in two commits, and the row's three-way destination list resolved to two owners because the third clause was already satisfied. *Approval/authorization:* `profile_approval_authorization.rs` (1,844) and `runtime_profile_approval_policy.rs` (334) → `ironclaw_approvals` as `profile_gate.rs`/`profile_gate_policy.rs`. §6.5.2 forbids `ironclaw_authorization` from doing "approvals resolution", which is exactly what this module does, so `approvals` is the destination and `authorization` is not. It was a leaf inside composition (zero `crate::` imports in production), so it moved with no churn. **Cost, stated not hidden:** `approvals` takes two new same-layer kernel edges (`→ trust`, `→ runtime_policy`) and `SAME_LAYER_EDGE_BASELINE` rises **72 → 74** — the only growth that number has taken. Neither is avoidable at the destination: the gate *implements* `TrustAwareCapabilityDispatchAuthorizer`, whose signature names `ironclaw_trust::TrustDecision`, and it consumes `MinimalApprovalBypass`, which §4.4 pins to `runtime_policy`. *Trigger-fire:* the check contract (`TriggerFireAccessCheck`/`Decision`/`Error`/`Checker`, previously in `runtime_input.rs`), `StaticOwnerTriggerFireChecker`, and `CompositeTriggerFireChecker` → `ironclaw_triggers::fire_access`, **zero new edges**. Two pieces deliberately stayed: `TriggerFireAccessPolicy`/`Grant` (the deployment grant — §6.10.1's Keeps list names config-as-data as composition's charter) and `IdentityMembershipTriggerFireChecker` (a lookup against a backend composition *selects*; moving it would buy `triggers` a dependency on the identity crate for one `get_user`). The deny reason and exact-scope rule are exported once and called by the composition-side checker rather than restated, so the two halves cannot drift. *`runtime_policy` needed nothing:* `production_runtime_policy.rs` is a smart constructor over `crate::RebornCompositionError`, and `builtin_capability_policy.rs` is **pinned in place** by `reborn_composition_boundaries.rs`, which hard-asserts `mod builtin_capability_policy;` stays at composition's crate root. Composition production LOC **45,127 → 42,688** on its own branch; ✎ on the batch union with the WS6 service-cluster eviction the joint figure is **40,499** (disjoint deltas, they add exactly) and `loc_ceiling`/`loc_observed`/`COMPOSITION_ABSOLUTE_SRC_LOC` carry that number. ⚠ ~~trusted-submit logic → `triggers`/`conversations`~~ — **STOPPED, not attempted; the row names two destinations and both are refused by a stated rule.** Measured on this tree: `automation/trigger_poller_trusted_submit.rs` is 2,268 lines of which only **~470 are production** (the rest is one inline test module). Its production surface — `ConversationContentRefMaterializer` — needs `ironclaw_threads` (`SessionThreadService`, `EnsureThreadRequest`, `AcceptInboundMessageRequest`, `MessageContent`, `ThreadScope`) and `ironclaw_product` (`automation_trigger_thread_metadata_json`). **`ironclaw_conversations` is refused by its own charter**, which says in its crate doc: *"It is not the transcript. `ironclaw_threads` owns canonical threads and their message content; this crate owns the *binding* … Keep it that way."* The materializer's whole job is `record_trigger_prompt`, i.e. writing the transcript — so moving it there would import precisely the concern the crate exists to exclude, and the crate holds no `ironclaw_threads` dependency today. **`ironclaw_triggers` is refused by cost**: it would need `conversations` + `threads` + `safety` + `extension_contracts` + a re-homed product helper, four or five new same-layer substrates edges, to host an adapter that is composition's by shape. The honest reading is that this clause needs a *destination decision* first (a fourth owner, or the materializer inverted behind a port the way #7159 just did for the coordinator handle), not an eviction — recorded here rather than forced. **The security gates were left green and UNMODIFIED**: `untrusted_ingress_paths_cannot_submit_host_trusted_inbound`, `conversation_trusted_trigger_submitter_stays_conversation_or_composition_owned`, `..._stays_out_of_root_exports`, `conversation_trusted_trigger_classifier_stays_out_of_root_exports`, and `trusted_trigger_submit_request_minting_stays_worker_owned` all pass untouched. ✎ **Resolved 2026-08-04 (delegated authority): the trusted-submit materializer stays in composition; the clause is closed, not owed.** Decided rather than escalated because it is a placement call, not an empirical claim. Both named destinations are refused by stated rules — `ironclaw_conversations` by its own charter (it owns the binding, not the transcript, and `record_trigger_prompt` writes the transcript) and `ironclaw_triggers` by cost (four or five new same-layer substrates edges to host a composition-shaped adapter). Composition is the one crate whose charter already licenses an adapter over `threads` + a product helper for trigger assembly, and §6.10.1's own Keeps list names the deployment grant as config-as-data. The five security gates named above stay armed and pin the ownership. ~~admin-user directory → `product`~~ — **done 2026-08-04 (WS6)**: `RebornAdminUserDirectory` is `ironclaw_product::admin_user_directory`; `AdminApiTokenMinter` moved to `ironclaw_product_contracts::admin_users` (so the CLI implements it and product calls it without either naming composition) and composition's `pub use admin_token::AdminApiTokenMinter` is deleted, not forwarded; composition keeps only `FilesystemAdminSecretProvisioner`, the mount-view half. ~~trace capture → `traces` + turn-runner observer seam~~ — **done 2026-08-04 (WS6)**: the 1,170-LOC module is now `ironclaw_reborn_traces::capture` (policy gate, envelope, queue, flush, flush worker — keyed on a scope string and `ConversationMessage`, naming no turn/thread type) plus `ironclaw_runner::trace_capture` (the `TurnEventSink`, the history port, and the record→message adaptation). Both destinations were needed: `traces` is `substrates` and `ironclaw_turns` is `kernel`. ✎ **The row's "(+ hooks projection)" is a miscount, corrected 2026-08-04.** The 350-line figure §2 records is `composition/src/observability/hooks/projection.rs` (356 LOC), which is installed-extension `[[hooks]]` manifest discovery/admission and has nothing to do with trace capture; it was fused into this clause by adjacency in the `observability/` tree, and neither named destination can receive it. It needs its own row against `ironclaw_hooks`.; ~~system-prompt content → owning prompt asset~~ — **done 2026-08-03**: the four assets are `crates/ironclaw_loop_host/prompts/{default_system,tool_disclosure_protocol,self_knowledge,benchmarking_mode}.md`, exported from `system_prompt_assets.rs`; composition consumes the consts and keeps only assembly plus the boot-time seeding of the on-disk `SYSTEM.md` (`std::fs` on a real host path — `ironclaw_loop_host` has zero `std::fs` uses, so the seeding could not travel). Pinned by `reborn_composition_boundaries.rs::composition_root_embeds_no_prompt_content`, which fails on either half — a re-added `include_str!("…​.md")` or a re-added shipped `.md` asset. The runtime storage path `system/prompts/default-system.md` is deliberately unchanged: it is where existing installs' user-edited file lives.; OpenAI-compat + NEAR-login route mounts → `openai_compat`/`operator` factories — ✎ **half discharged, half blocked, measured 2026-08-04 (WS6)**: the NEAR-login serve module already lives at `ironclaw_operator/src/llm_admin/nearai_login_serve.rs` and composition keeps only a 20-line accessor over runtime-private session/reload/boot state, which is assembly and owes nothing. The OpenAI-compat half cannot move as written: `openai_compat_serve.rs` is 1,471 LOC of which ~1,240 are adapters naming `ironclaw_threads`, `ironclaw_turns` and `ironclaw_event_streams`, and all three are on `ironclaw_reborn_openai_compat`'s own armed `BoundaryRule` forbidden list ("must not … reach into runtime/composition services directly"). Those adapters implement the owner crate's *own* ports, which is the shape the gate exists to require. The genuinely movable residue is ~230 LOC — `model_entries_from_snapshot` + `LlmConfigModelCatalog` + its error map, `product_surface_caller_from_openai_scope`, `OpenAiCompatRuntimeProjectionStreamer` + `decode_product_outbound_events`, and the router-state assembly behind a ports params struct — all reachable with `product_contracts` + `host_ingress` only. Re-scope the row to that residue. project filesystem reader → `identity::projects` — ✎ **blocked by the layer matrix, measured 2026-08-04 (WS6)**: what is composition-resident is `support/fs/mount_filesystem_reader.rs` (505 LOC), and it implements `ironclaw_product::FilesystemBrowseReader` over `ironclaw_product` DTOs. `ironclaw_projects` (and the `ironclaw_identity` it merges into) is `substrates`, so it may not name a `products` crate. The second hop §6.10.1 asks for is blocked the same way and one step earlier: `ProjectService` is declared in `ironclaw_product/src/reborn_services/projects.rs`, **not** in `product_contracts` as §6.4.11 assumes, and `reborn_identity`'s allowlist is armed at `{reborn_identity, host_api, filesystem}` — so §6.4.11's "identity's pinned allowlist is unchanged" is false for an adapter that implements a product-tier port. Prerequisites: move the port + its ~18 DTOs to `product_contracts`, then widen the identity allowlist by one entry. Both are decisions, not mechanics. ~~blocked-auth resume fan-out → `product`/`auth`~~ — **done 2026-08-04 (WS6)**: `BlockedAuthResumeFanout` is `ironclaw_product::blocked_auth_resume`; `ironclaw_auth` could not take it (it is `substrates` and the fan-out names `ironclaw_processes` and `ironclaw_turns`, both `kernel`, both also on `ironclaw_auth`'s forbidden list). `process_gate_turn_view.rs` travelled with it rather than being duplicated. Google OAuth secret store + NEAR-AI MCP module → package/auth recipes — ✎ **both reported rather than moved, 2026-08-04 (WS6)**. `GoogleOauthSecretStore` (155 LOC) is a fixed-handle wrapper over `SecretStorePort` whose live consumer is the CLI's `config set google.client_secret`; §6.10.3's 2026-08-04 amendment already rules that the Google half "move[s] with §6.10.2's CLI shed or not at all", and #7153 item 2 scopes it as one slice with the CLI's ~200-line credential resolution. Moving the store alone strands its caller. `llm_admin/nearai_mcp.rs` (341 LOC) is boot-time auto-install + activate + manual-token submit for the `nearai` extension; **there is no package-owned mechanism to move it to** — the manifest has no bootstrap/auto-activate recipe (`rg 'auto_activate|auto_install|bootstrap' crates/ironclaw_extension_contracts/src` is empty), and `[admin_configuration]` governs an installed extension's live configuration, not first-boot provisioning. Inventing one is a feature, not an eviction; the row owes a mechanism decision first. - [x] Retire the `local_dev` misnomer (production path renamed; deployment-mode naming ratchets extended to catch it). **Landed with #6691:** `runtime/local_dev` → `runtime/capability_host`, `local_dev_authorization` → `capability_authorization`, `local_dev_mounts` → `runtime_mounts`, `local_dev_boot` → `standalone_boot`, and the ratchet itself `reborn_localdev_typename_ratchet` → `reborn_standalone_typename_ratchet`. One residue for a later PR: the local variable at `composition/src/runtime.rs:3016` is still named `local_runtime`. ✎ **Corrected 2026-08-03 (WS6) — that sentence is wrong twice, and the residue is not one line.** Quoted verbatim as it stood: *"One residue for a later PR: the local variable at `composition/src/runtime.rs:3016` is still named `local_runtime`."* Measured against `origin/main` @ `0f897e9366`: the local variable is at **`runtime.rs:3095`**, not `:3016`, and `local_runtime` appears **191 times in `crates/ironclaw_reborn_composition/src` alone** — including six *public* API symbols (`local_runtime_build_input`, `local_runtime_build_input_with_options`, `with_local_runtime_identity`, `with_local_runtime_workspace_root`, `with_local_runtime_confirmed_host_home_root`, `requires_local_runtime_confirmed_host_home_root`), the public type `RebornLocalRuntimeIdentity`, the `extension_host_assembly` field `local_runtime: Option<&RebornRuntimeStores>`, and ~20 call sites across composition's own `tests/`. `reborn_standalone_typename_ratchet` did not catch them because it governs *type* names, not function or field names. So this is a public-API rename with a test-wide blast radius, not a one-line cleanup, and it is tracked in **#7098** rather than folded into an eviction PR — renaming composition's public API while five composition-touching PRs are open would conflict with all of them. -- [ ] `RebornRuntime` slimmed: ~40 `_for_test` accessors behind `test-support`; re-export wall reduced to the documented snapshot (every survivor names consumer + enforcing test); delete the dead `product_live_adapters` export block (integration harness repointed). ✎ **Re-measured 2026-08-03 (WS6) against `origin/main` @ `0f897e9366`: two of these three clauses are already-done or refuted — do not redo them.** +- [x] `RebornRuntime` slimmed: ~40 `_for_test` accessors behind `test-support`; re-export wall reduced to the documented snapshot (every survivor names consumer + enforcing test); delete the dead `product_live_adapters` export block (integration harness repointed). ✎ **Re-measured 2026-08-03 (WS6) against `origin/main` @ `0f897e9366`: two of these three clauses are already-done or refuted — do not redo them.** ✎ **DONE 2026-08-04 (WS6 runtime-and-types PR).** All three clauses discharged; two of them by confirming the 2026-08-03 re-measurement rather than by doing work, and the third is the only one that moved code. 1. **`~40 _for_test accessors behind test-support` — already done.** `composition/src/runtime.rs` holds **38** `fn *_for_test` definitions and **zero** are ungated; each carries `#[cfg(any(test, feature = "test-support"))]` or `#[cfg(feature = "test-support")]`. Across the whole crate there are **149**, of which 13 carry no attribute of their own — and all 13 sit inside a module gated at its declaration site (`lib.rs:64-65` `#[cfg(feature = "test-support")] pub mod test_support;` and `factory.rs:1388-1389` `#[cfg(test)] mod capability_host_tests;`). **No `_for_test` function compiles into a production build of this crate.** Method: a Python walk from each `fn *_for_test` line back over its contiguous attribute/doc-comment block, so an attribute two lines up still counts. 2. **`delete the dead product_live_adapters export block (integration harness repointed)` — refuted; the block is not dead.** It is already `#[cfg(any(test, feature = "test-support"))]` (`lib.rs:189-196`), and its consumers are *not* the root integration harness: `crates/ironclaw_product/tests/support/planned_agent_loop.rs:54-57` imports seven of the eight names, `crates/ironclaw_product/tests/inbound_turn_contract.rs:41` imports `ProductLiveCapabilityIo`, and `crates/ironclaw_reborn_composition/tests/product_live_adapters.rs:46-50` is a suite dedicated to them. `ironclaw_product/Cargo.toml:87` carries `ironclaw_reborn_composition = { …, features = ["test-support"] }` as a **dev-dependency**, so this is live cross-crate test-support API — deleting it strands a sibling crate's test support. The clause should be re-worded to "keep, and document the cross-crate consumer" or dropped. 3. **`re-export wall reduced to the documented snapshot` — still open**, and is the only live clause on this row. + 4. **Clause 1 re-measured on this branch and the earlier count corrected +1 — but #7107's reading of it is refuted.** `composition/src/runtime.rs` now holds **39** `fn *_for_test` definitions (not 38 — one landed since), and **all 39 carry their own gate**: 28 are `#[cfg(any(test, feature = "test-support"))]`, 10 are `#[cfg(feature = "test-support")]`, 1 is `#[cfg(any(test, feature = "test-support"))]` beside an `#[allow(clippy::type_complexity)]`. **#7107 said "22 of them already sit under a cfg… so the work is the remaining ~17" — that is wrong, and wrong in the direction that invents work**: it counted only the exact `#[cfg(any(test, feature = "test-support"))]` spelling and missed the 10 `test-support`-only ones plus the one carrying a second attribute. Crate-wide the figure is **156** `_for_test` functions, of which 19 carry no attribute of their own — and all 19 sit in modules gated at their declaration site (`lib.rs:65-66` `#[cfg(feature = "test-support")] pub mod test_support;`, `factory.rs:1396-1397` `#[cfg(test)] mod capability_host_tests;`, and a `#[cfg(test)] mod tests` in `automation/trigger_poller_trusted_submit.rs`). **No `_for_test` function compiles into a production build.** Nothing was changed for this clause. + 5. **Clause 3's refutation is now written into the code, not only into this row.** The `product_live_adapters` block stays, and carries a comment at its declaration site naming its cross-crate consumers and saying that CHECKLIST WS6 asked for its deletion and why that was wrong. A refutation recorded only in a planning doc is one grep away from being re-litigated by the next agent reading `lib.rs`. + 6. **Clause 2 executed — the wall is down by a third, and the reduction was measured, not eyeballed.** Method: parse every top-level `pub use` in `composition/src/lib.rs` into its exported leaf names (handling `as` renames and nested braces), then scan every `.rs` file in the repo outside `composition/src/` for names imported *through* `ironclaw_reborn_composition::` — both `use` statements (multi-line) and inline fully-qualified paths. Zero glob imports of this crate exist workspace-wide, so the scan is complete. Result: **52 → 36 statements, 179 → 109 exported names, snapshot 132 → 82 lines**, and **zero** statements now have no external consumer (there were 13). What came out: 17 whole statements' worth of pure pass-throughs of *other crates'* types — `ironclaw_auth` ×3, `ironclaw_host_api` ×3 (incl. the whole `user_identity` block), `ironclaw_host_runtime`, `ironclaw_product_contracts` ×2, `ironclaw_runner::failure_lane`, `ironclaw_runtime_policy`, `ironclaw_triggers`, `ironclaw_extension_host::provider_identity`, `ironclaw_operator` — plus ~50 individually-dead names inside surviving statements. + 7. **The compiler confirmed the census, which is the part worth keeping.** `pub use` deletion emits no `unused` warning, so a wrong census would have been silent. It was not: removing the dead entries turned **15 `unreachable_pub` warnings** on in the crate — every one a `pub` item whose *only* path to the outside world had been a re-export nobody imported. They are now `pub(crate)` (`PostSubmitDeliveryHook`, `TracingBudgetEventObserver`, `HOOKS_*_ENV`, `MAX_TOTAL_HOOKS_PER_TENANT`, `tenant_extension_root`, `build_hook_dispatcher_builder_factory_for_tenant`, `blocked_auth_flow_canceller`, `ResolvedRebornLlm`, the two `DEFAULT_TURN_RUNNER_*_INTERVAL` consts), and `memory_provider_factory::create_provider` + `create_third_party_provider` went `#[cfg(test)]` after `cargo check` proved production reaches mem0 through `resolve_memory_provider`, never through them. **That is 15 units of public API this crate did not know it was exporting.** + 8. **Four consumers were repointed to the owning crate rather than the survivor being kept.** `ironclaw_reborn_cli/src/first_party/gsuite.rs` (8 `ironclaw_auth` types + `RuntimeDispatchErrorKind`), `tests/integration/auth/oauth_popup_journeys.rs` (4 `host_api::user_identity` types) — both consumers already depend on the owning crate, so the laundering bought nothing. `SecretHandle` was doubly-exported: the CLI already reached it through the `pub mod host_api` facade. + 9. **A second gate now holds the "documented" half, and it caught three real cases on its first run.** `composition_public_pub_use_entries_name_their_consumer` (`reborn_composition_boundaries.rs`) requires every top-level `pub use` to carry a `// consumer: · pinned by: ` line in the comment block above it. The snapshot gate pins *what* is exported; this one pins *why*. Annotations sit above any `#[cfg]`, which is exactly where the snapshot extractor's attribute walk tolerates them, so documenting an entry can never perturb the snapshot. On its first run it failed on three entries whose annotation spanned two lines — i.e. it was sabotage-tested by construction, then went green. + 10. **What survives, and the rule that decides it.** A re-export earns its place only when the consumer *cannot* reach the symbol at its owner: the app tier (`ironclaw_reborn_cli`) deliberately does not depend on `ironclaw_turns` / `ironclaw_runner` / `ironclaw_skills` / `ironclaw_product` / `ironclaw_reborn_identity`, so those five stay as a narrow façade; and ~12 names stay purely so a *retained* export's signature remains nameable (`KeychainMasterKeyOutcome`, `GoogleOauthSecretStoreError`, `MemoryLifecycleConsumers`, `RebornCompositionProfileParseError`, `HookDispatcherBuilderFactory`, the `RebornSkill*` family, the `TriggerFireAccess*` family) because their home modules are private. `RebornRuntimeProfileError` is *not* among them and left the root: `deployment` is a `pub mod`, so it was already nameable twice. That rule is written at the top of the wall in `lib.rs` and is what the new gate exists to make enforceable. ⚠ **Guidance follow-up owed once #7084 lands:** `crates/AGENTS.md`'s `ironclaw_loop_host` row should gain the prompt assets (`prompts/*.md` via `system_prompt_assets.rs`) and the note that on-disk `SYSTEM.md` seeding stays in the composition root. It is **not** in this PR because the Reborn test planner fails closed on `crates/AGENTS.md` (`unmapped crate path`) — it is markdown directly under `crates/` that resolves to no package. #7084 already fixes that, depth-independently, with its own regression test; editing the file here would collide with it in the same function. See #7100. The snapshot (`docs/plans/composition-pubuse.snapshot`) and its enforcing test (`composition_public_pub_use_surface_matches_snapshot`) both exist; what is unmeasured is whether every survivor names its consumer and enforcing test. -- [ ] `ChannelExtensionBinding.extension_id` becomes typed `ExtensionId`; env reads consolidate behind `ironclaw_config`. +- [~] `ChannelExtensionBinding.extension_id` becomes typed `ExtensionId`; env reads consolidate behind `ironclaw_config`. ✎ **Typed clause DONE 2026-08-04 (WS6 runtime-and-types PR); the env clause is measured and deliberately not started. The box stays `[~]`.** + 1. **Scope decided explicitly, which #7107 asked for and the row never said.** #7107 offered two honest scopes — *type the seam* (one field, ~6 `.as_str()` calls, "cosmetically typed" per `.claude/rules/types.md`) and *type the chain* (a `ironclaw_extension_contracts` change touching every `ChannelAdapter` implementation). **Neither was taken whole.** What landed is the seam **plus the first downstream hop**, which is the smallest cut that does not immediately untype the field: `ChannelExtensionBinding.extension_id`, `ironclaw_extension_host::GenericExtensionHostParams.channel_adapters` (`Vec<(ExtensionId, …)>` and the `HashMap` behind it), and `GenericChannelHostAssembly::register_extras(&ExtensionId, …)`. The manifest comparison in `production_backend_assembly.rs` drops its `.as_str()` and is now a newtype equality. + 2. **The boundary is named, and it is a wire boundary, not a stopping point of convenience.** `ironclaw_extension_contracts::channel_adapter` keeps `VerifiedInbound.extension_id: &'a str` and `InboundAdmission.extension_id: String`. Those are the *contract* types every `ChannelAdapter` implementation and every extension package speaks; typing them is a contracts-crate change with the extension-surfaces checklist attached, and it is the second half of #7107's "type the chain". The two `.as_str()` / `.as_str().to_string()` calls left in `native_extensions.rs` sit exactly on that boundary and mark it. + 3. **#7107's two-crate `ExtensionId` trap held and is now documented at the use site.** The field's doc comment says which `ExtensionId` it is (`ironclaw_host_api::ids`, the authority-bearing one) and that the `ironclaw_hooks::identity` type coexists by design — the owning crate's own stated contract, so resolving by crate is a rule, not advice. Construction uses `ExtensionId::from_trusted`: the two shipped ids are compile-time literals, so the fallible `new` would only add an `expect` in the binary's startup path. + 4. **Clause 2, "env reads consolidate behind `ironclaw_config`", is MEASURED AND NOT DONE — #7107 left it unscoped, so here is the scope.** `ironclaw_reborn_composition/src` performs **14** `std::env::var` reads, all in production paths, in four files: `input.rs` ×6 (the Postgres connection/pool/SSL block — `IRONCLAW_REBORN_POSTGRES_POOL_MAX_SIZE`, `IRONCLAW_REBORN_POSTGRES_RESOURCE_GOVERNOR_SINGLETON`, `DATABASE_SSLMODE`, `IRONCLAW_REBORN_ALLOW_REMOTE_POSTGRES_CLEAR_TEXT`, plus two generic helpers), `factory.rs` ×4 (`SECRETS_MASTER_KEY_ENV` plus three `USERDOMAIN`/`USERNAME` reads), `runtime.rs` ×3 (two generic helpers + `SKILL_INJECTION_MODE_ENV_KEY`), `model_gateway_assembly.rs` ×1 (`IRONCLAW_SKILL_LEARNING_MODEL`). **The destination already has the right shape**: `ironclaw_reborn_config` uses a `resolve_from_env()` / `resolve_from_env_parts(…)` split (`boot.rs`, `home.rs`, `budget.rs`) that keeps the read at the edge and makes the parse injectable. **One carve-out the clause should record before anyone starts:** `factory.rs`'s `USERDOMAIN`/`USERNAME` reads are not configuration — they name the *OS keychain account*, which is platform identity, and moving them into a config crate would mis-file them. So the honest target is **12 of 14**. Not attempted here because it cannot be verified on this branch (see the PR's verification note). - [ ] `config` narrows: vendor sections (`SlackSection`/`TelegramSection`/`GoogleSection`, Google update pipeline, `update_slack_enabled`) and `capability_remediation.rs` move to package-owned admin-config/data; compatibility window: old sections parse into migration guidance for one release. **(compat constraint — PROPOSAL §12.2)** ✎ **Amended 2026-08-04 — the Slack/Telegram half landed; the row's framing was wrong for it, and the Google half is a different problem.** The row says all three sections "move to package-owned admin-config/data". Re-measured on live `main`: **`SlackSection` and `TelegramSection` had nothing to move.** Their only consumers were `reject_legacy_slack_config` (which exists to reject them), `config list`'s display expansion, and `update_slack_enabled`. Zero runtime readers — the enablement gate they fed (`[slack].enabled` / `IRONCLAW_REBORN_SLACK_ENABLED`, and the Telegram equivalents) was **deleted with the unified extension runtime in #6116** (2026-07-21, which removed `serve_slack.rs`/`serve_telegram.rs` outright), and nothing replaced it: the ingress route is generic and always mounted, gated only by whether the extension's signing secret is registered (503 until it is, 401 on mismatch). So `ironclaw config set slack.enabled true` printed `slack.enabled: saved` and changed nothing — a user-visible no-op the docs still instructed operators to run (`setup-slack-for-reborn-binary.md` called it "one gate"; the same file's troubleshooting step could never fix anything). **What landed instead of a move:** the three vendor structs, their three builders, and `update_slack_enabled` are deleted, and `RebornConfigFile` no longer names a vendor at all — retired sections are split off the raw document *before* the typed parse, so the schema keeps `deny_unknown_fields` without declaring a retired key. The compat window is honoured and widened: an existing file still parses, a retired *setup* key still fails `serve` closed with the same message, an inert section still boots and now **says so** instead of being silently ignored, and inline-secret rejection over retired sections goes from nine hardcoded keys to every string at any depth. `config set slack.enabled` now answers with migration guidance rather than a typo report. **Decision recorded under delegated authority (PROPOSAL §6.10.3, dated amendment): a retired section stays in `ironclaw_config`, it does not become package-owned.** A boot-time config-migration check runs before any extension exists and this crate may hold no workspace dependency, so a package cannot own it; the package owns *live* admin configuration, the config crate owns the gravestones for keys it used to define. Alternatives rejected: (a) delete the sections outright — breaks every existing operator file against `deny_unknown_fields`, which is the constraint this row exists for; (b) a `serde(flatten)` catch-all — silently disables `deny_unknown_fields`, trading a typo-catcher for a gravestone. Extension-specificity allowlist **126 → 124** (baseline lowered to match); the two surviving vendor tokens are the TOML table names, quarantined in `retired_sections.rs`. ✎ **Re-measured 2026-08-04 with the Wave 4 consolidation (#7139).** This clause read *"Extension-specificity allowlist **127 → 125**"* when it was written against `origin/main` @ `1e2a294083`. #7094 then deleted an entry on `main` (127 → 126), so these same two net removals now land on **124** and the baseline is `124`. The ratchet is `<=`, so it stayed *green* at 125 while carrying a unit of untracked slack — which the constant's own doc forbids (*"Lower it in the same PR that deletes entries so the new floor is locked in"*). The count is read off the ratchet's own failure message with the baseline temporarily set to `0`, never counted by eye: a plain paren count over the literal answers 142, because the entries' comments contain parentheses too. **Still open on this row:** `GoogleSection` + the Google update pipeline (genuinely live — read by the CLI's OAuth resolution, so it moves with the WS6 CLI row below, not before it) and `capability_remediation.rs` (**not dead** — the filename greps to two files, but its five functions have real consumers in four crates: `ironclaw_extension_manager`, `ironclaw_extension_host` ×2, `ironclaw_reborn_cli` ×3; a move is a four-crate change). - [ ] CLI sheds Google-OAuth resolution + `reject_legacy_slack_config` to package-owned steps behind generic seams; dir rename `ironclaw_reborn_cli`→`app/ironclaw_cli` (package name `ironclaw` unchanged) **[decision — severable]**. ✎ **Amended 2026-08-04 — the `reject_legacy_slack_config` clause is discharged; it landed with the `config` row above, not here, and not "package-owned".** The function is gone: it is now `reject_retired_config_sections`, a five-line call into `ironclaw_reborn_config`'s retired-section table, with the vendor knowledge as data in the config crate rather than as code in the CLI. That is PROPOSAL §12.2's "the existing `reject_legacy_slack_config` shape, relocated" — the two rows described the same seam from opposite sides, so doing it twice would have meant building it twice. Its serve-startup test moved with it (`serve_startup_rejects_loaded_config_with_legacy_slack_fields` → `..._with_retired_setup_fields`) and gained the `[telegram]` case the table-driven form made free. The CLI also shed `ConfigKey::SlackEnabled`, its shape validator, its write path, and `slack_remediation_text` (whose only production caller was that key). **Still open on this row:** the ~200-line Google-OAuth resolution in `runtime/mod.rs` (still reads `GoogleSection`, so it is one slice with the Google half of the `config` row above — do them together or neither). **The dir rename is deliberately NOT done** and is not this row's next step: renames are parked program-wide, and this one would conflict with every open PR touching the CLI. - [ ] Renames executed — decided (2026-07-29, kill the family/crate stutters): `ironclaw_events`→`ironclaw_event_log`, `ironclaw_extensions`→`ironclaw_extension_registry`, `ironclaw_product`→`ironclaw_assistant`; no compatibility re-export shims; all consumers + docs repointed in the same PR. ✎ **2026-07-31, superseded 2026-08-01 by #6996:** the `ironclaw_extensions` rename still has to repoint `reborn_registration_pipeline_boundary.rs`, but it is no longer a *silent* trap. That gate now resolves its owned scopes by crate **name** through the crate inventory and asserts every scope resolves to at least one real file, so a rename that misses it fails loudly with a message naming the crate. Repoint the name; do not raise `REGISTRATION_BOUNDARY_ALLOWLIST_BASELINE`. - [ ] Renames executed — decided (2026-07-30 naming audit): `ironclaw_architecture`→`ironclaw_architecture_tests` (tests-only crate says so; CI lane names updated), `ironclaw_first_party_extensions`→`ironclaw_extension_support` (dir `extensions/ironclaw_extension_support/`), `ironclaw_runner`→`ironclaw_turn_runner`; same no-shim discipline. ✎ **Amended 2026-08-02 (WS2.6): `ironclaw_first_party_extensions`→`ironclaw_extension_support` is DONE**, landed early because WS2's colocation row names the rename too and doing the directory move without it would have touched all 253 occurrences twice. The other two renames on this row are untouched. - [ ] Renames executed — the `reborn_` batch, decided (2026-07-30; the discriminator discriminates nothing): `composition`, `config`, `event_store`, `identity`, `openai_compat`, `reborn_traces`→`trace_commons`, cli directory→`app/ironclaw_cli`, root `reborn_integration_tests`→`integration_tests`; no shims; all consumers + docs repointed in the same PR. -- [ ] Domain-internal cleanups: ~~`traces` `contribution.rs` split~~ + `ScopedFilesystem` + re-export modules dropped; `llm` `providers.json` becomes a crate asset/composition input + boundary rule added; `skills` stale v1 lib.rs doc rewritten; `triggers` SQL ADR-or-converge **[decision]**; `identity` absorbs `host_api::user_identity` ports + resolves the dual binding-store ambiguity **[decision]**; `projects` absorbs its composition service adapter. +- [ ] Domain-internal cleanups: ~~`traces` `contribution.rs` split~~ + `ScopedFilesystem` + re-export modules dropped; `llm` `providers.json` becomes a crate asset/composition input + boundary rule added; `skills` stale v1 lib.rs doc rewritten; ~~`triggers` SQL ADR-or-converge~~ **[decision — RESOLVED 2026-08-04, ADR 0003]**; ~~`identity` absorbs `host_api::user_identity` ports + resolves the dual binding-store ambiguity~~ **[decision — RESOLVED 2026-08-04: absorption refuted, ambiguity resolved as nominal, #5618 residue deleted]**; `projects` absorbs its composition service adapter. ✎ **2026-08-04 (WS6 runtime-and-types PR): clause (e) `skills` stale v1 `lib.rs` doc is DONE; clauses (d) `providers.json` and (h) `projects` are measured with their blockers named. Five clauses remain, so the box stays open.** ✎ **Amended 2026-08-04 (Wave 4/WS6) — the `traces` `contribution.rs` split is DONE; the other two `traces` clauses on this row are not, and one of them is worded backwards.** 1. **The split landed.** 17,470 lines (not §6.4.14's "17,467" — the figure drifted +3) became a directory module: 13 production submodules plus a mirrored `tests/` tree, largest file 1,290 lines. §6.4.14 suggested five modules (`schema/redaction/queue/credits/credentials`); the shipped set is finer because two of those five are each two owners — redaction splits by *key* (`privacy` matches patterns over any text, `tool_payloads` matches tool-and-field names) and the queue splits into state (`queue`), wire (`remote`), and the orchestration that is the only caller of both (`submission`). The charter table in `src/contribution/mod.rs` is the rule for where new code goes. **No public API change and zero consumer edits**: the submodules are private and `mod.rs` glob-re-exports them, so `contribution::X` stayed the single public path for all four consumers (`product`, `reborn_composition`, `host_runtime`, `reborn_cli`) — which also kept the PR out of every crate Wave 4 had occupied. Preservation was *proved*, not assumed: 501 top-level items before and after (zero missing, zero extra, diffed against `origin/main`), and 216 lib tests with leaf names identical before and after. 2. **The `// arch-exempt: large_file` waiver is deleted, not carried forward** (it dated to plan #6168's mechanical rename). No new waiver was added — every file is under the 1,500-line ARCH-SPRAWL threshold, which `scripts/pre-commit-safety.sh` enforces with `exit 1`, not a warning. @@ -369,6 +414,12 @@ owners. See the retraction on that row. --> 5. **A gate got narrower as a side effect.** `reborn_extension_specificity.rs`'s `PATH_TERM_COLLISIONS` carried four whole-file vendor carve-outs (`slack`/`telegram`/`gmail`/`github`) for `contribution.rs`, which permitted those names anywhere in 17,470 lines. They now resolve to `tool_payloads.rs` (the rule tables) and `classification.rs` (`classify_tool_side_effect`, `slack` only), so the gate polices the other eleven production files. Those entries are staleness-checked, so the old path would have failed loudly rather than silently — sabotage-tested both ways (stale path → *"stale PATH_TERM_COLLISIONS carve-outs"*; deleted entry → the exact `(path, term)` pair reported as a new violation). 6. **The crate gained its first guidance file** (`crates/ironclaw_reborn_traces/CLAUDE.md`), which records the glob-re-export invariant and items 3, 4 and the pending rename as known gaps. §6.4.14's "add guidance files" clause is thereby partly discharged. 7. ✎ **Clause-by-clause status of this compound row, added 2026-08-04 with the Wave 4 consolidation (#7139) so the next agent measures nothing already measured.** This row bundles eight clauses and the box stays open because five are untouched. **Done:** (a) `traces` `contribution.rs` split — items 1–6 above. **Measured and blocked, with the blocker named:** (b) `traces` `ScopedFilesystem` — item 3; note the row's verb is *backwards*, §6.4.14 asks for **adoption**, not dropping. (c) `traces` re-export modules — item 4, all three call sites in `ironclaw_reborn_cli`, occupied this wave. (d) `llm` `providers.json` — **see the "Module charters" row below, item 6**, which measured it while charting `ironclaw_llm`: 21 `include_str!` sites, the load-bearing one at `crates/ironclaw_reborn_cli/src/commands/config/init.rs:311` reaching five levels up *because* the CLI may not depend on `ironclaw_llm`, so it needs a new mechanism rather than a new path; its §11.2 gate is still `REPORT_ONLY` in `reborn_cross_crate_include_scan.rs`. **Untouched by Wave 4 part 1:** (e) `skills` stale v1 `lib.rs` doc, (f) `triggers` SQL ADR-or-converge **[decision]**, (g) `identity` absorbing `host_api::user_identity` ports + the dual binding-store ambiguity **[decision]**, (h) `projects` absorbing its composition service adapter. + 8. ✎ **Clauses (f) and (g) are DISCHARGED 2026-08-04 (WS6 decision-rows PR; delegated authority — PROPOSAL §12.12 D-L and D-N). Only (b), (c), (e) and (h) remain, so the box stays open.** + - **(f) `triggers` SQL — ADR, do not converge. [`docs/adr/0003-triggers-keeps-hand-written-sql.md`](../../adr/0003-triggers-keeps-hand-written-sql.md).** The hand-SQL body is **3,372 lines** (`libsql.rs` 1,869 + `postgres.rs` 1,503) — §6.4.3's "3,347" has drifted +25 and the ADR corrects it — carrying **46 / 40** distinct statements (**26 / 25** on the runtime path). What blocks convergence is measured, not asserted: the claim is a five-predicate single-statement CAS inside `BEGIN IMMEDIATE` (`libsql.rs:754`) and a `SELECT … FOR UPDATE` + unconditional `UPDATE … RETURNING` on PostgreSQL (`postgres.rs:515`/`:536`); lease release proves ownership *in the predicate* (`active_fire_slot = ?4 AND active_run_ref IS NULL`); and every claim/settlement spans `trigger_records` **and** `trigger_run_history` in one transaction with per-column `ON CONFLICT` precedence. The fabric offers per-document CAS and expresses none of those three. **Two costs the row did not name:** convergence would also have to delete `ironclaw_filesystem` from this crate's `forbidden` list (`reborn_dependency_boundaries.rs:3968`) — a new substrate→substrate edge on top of the rewrite — and unlike hooks, **both trigger backends are genuinely wired** (`backend_store_assembly.rs:89`/`:99`, `production_backend_assembly.rs:1330`/`:1373`), so converging deletes a shipped deployment shape. **Parity verified, not assumed:** `tests/repository_contract.rs` is 4,710 lines / **51 tests** built from **31 shared `assert_*` helpers**, all **21** `TriggerRepository` methods are exercised by shared helpers, and claim atomicity is raced per durable backend (`assert_durable_claim_is_atomic:2989` + two idempotency races). ⚠ **One real fail-open found and fixed in this PR:** all five Postgres skip paths returned `None` after an `eprintln!`, so on a Docker-less runner the entire PostgreSQL half of the matrix skipped **silently and reported green** — the suite now honours `IRONCLAW_REQUIRE_POSTGRES=1` (the switch `ironclaw_hooks/tests/parity_matrix.rs` already carried), turning every skip into a hard failure. ⚠ **Half-closed, stated as such in the ADR:** the only lane exporting that variable is `hooks-parity` (`platform-and-compat.yml:168`), which runs `-p ironclaw_hooks` targets only *and* serves Postgres from a workflow service container via `DATABASE_URL` where this suite starts its own via `testcontainers` — so the switch is opt-in and giving triggers a Docker-guaranteed lane is the follow-up. Recorded-not-fixed: the suite is private helpers in one test binary rather than an exported `pub mod contract`, so it is not runnable by an out-of-crate backend — costless today, a prerequisite for a third one. + - **(g) `identity` — the absorption is REFUTED and the row should stop asking for it; the ambiguity is resolved as *nominal*; the one real deletion is taken.** ✎ This clause was **independently refuted first by #7152** (branch `ws6/wave4-part2`, still open at the time of writing) and re-measured here against this branch; both measurements agree, so this is a confirmation, not a second ruling. `host_api::user_identity` is 160 lines (3 traits, 2 newtypes, 1 DTO, 2 errors, 1 pure fn); its **sole production implementor is `ironclaw_extension_host::channel_identity_store::FilesystemChannelIdentityStore`** and `ironclaw_reborn_identity` implements **none** of the three. The move is layer-matrix-*legal* (`products → substrates` is allowed) but would force `extension_host` to take a **new** dependency purely to name a port it implements — an added edge in a restructure whose purpose is edge removal. **Do not cite CHECKLIST item 5 above as the blocker**: `AdapterInstallationId` blocks moving `user_identity` *up* into a contracts tier, not *down* — `ironclaw_reborn_identity` already depends on `ironclaw_host_api`, so it could name the type for free. The real blocker is the new edge. **The "dual binding-store" is two disjoint concerns, not one duplicated one:** `ironclaw_reborn_identity::identity_store` owns **principal** identity (mints users, owns the profile + verified-email index, keyed on five path segments including `surface_kind`), `extension_host::channel_identity_store` owns **post-OAuth binding** (never mints, keyed `(provider, provider_user_id)`, one tenant per instance). What was owed is that the charters say so — now landed in `ironclaw_reborn_identity/CONTRACT.md` ("Two external-identity stores") and mirrored in `channel_identity_store.rs`'s module doc. **The deletion that WAS live is done (#5618, closed):** `ExternalIdentityKey` + `RebornIdentityResolver::{lookup, bind}` + the now-orphaned `identity_user` helper had **zero production callers**, and the key was deliberately absent from the composition facade so downstream could not construct one. Un-masking roster **39 → 34**, exactly the five tests that drove the deleted methods, with `different_provider_instance_does_not_collide` **repointed onto `resolve_or_create` and kept** (its key axis is not part of the dead slice) and the two corrupt-record error-classification tests likewise repointed. #5615 (`bind()` has no OAuth-surface guard) closes with it — the method it guards is gone. ⚠ **Two consequences recorded rather than buried:** the retired `bind` was an *upsert* that re-pointed a key, the **opposite** of the shipped contract (`ProviderIdentityAlreadyBound` → `AlreadyBoundToOtherUser`), so no production semantic was lost; and it took the tenant **per call** where the channel store fixes one tenant per instance — tenant keying of the identity store itself is unaffected, but a future multi-tenant channel binding must revisit the *channel store's* shape. ⚠ **Unrelated live gap found while measuring, filed rather than fixed here:** `installation_scoped_provider_user_id` (`host_api/src/user_identity.rs:155`) flattens `(installation, actor)` into one `:`-joined string that the reverse lookup matches with `starts_with` (`channel_identity_store.rs:558`), so an actor id containing `:` — or an installation id that is a prefix of another — can satisfy a prefix check it should not. The principal store avoids this by keeping the parts in separate path segments. + 9. ✎ **(e) `skills` stale v1 `lib.rs` doc — DONE, and it was worse than "stale": two of its three claims were *false*, and one was a security-model misstatement.** The block said trust-based tool filtering happens in `src/skills/attenuation.rs` (**no such file exists anywhere in the tree**, and there is no repo-root `src/`), that "in v2 the Python orchestrator handles trust labels" (no Python orchestrator exists in the Reborn stack), and that "the policy engine controls tool access via capability leases" — leases are real (`ironclaw_authorization`/`ironclaw_capabilities`) but **they have nothing to do with skill trust**. The live mechanism is `ironclaw_loop_contracts::skill_context::SkillTrustLevel`, and what `SkillTrust` gates is **content exposure** (prompt body vs. safe description only), not tool access. That correction had to travel to two more sites the row does not name, because the crate was *internally consistent and externally wrong*: `types.rs`'s `SkillTrust` variant docs still said "Read-only tools only" / "all tools available", and `AGENTS.md` said "preserve tool-ceiling attenuation". **`AGENTS.md` was in worse shape than `lib.rs`**: it claimed ownership of modules `gating`, `registry` and `catalog` and a `v2` module exporting `V2SkillMetadata`/`CodeSnippet`/`SkillMetrics`/`SkillRevision`/`SkillRepairRecord` "serialized into `MemoryDoc.metadata` by the engine crate" — **none of those modules or symbols exists** (`rg` over `crates/` matched only that file) and `ironclaw_engine` is deleted. Corrected, with the correction dated in place. The rewritten `lib.rs` block also names the real module set (`selector` and `learning` were simply missing) and records the two invariants the crate's tests actually defend: selection is deterministic with no skill content in context (so a skill cannot influence its own selection), and `Installed < Trusted` ordering is load-bearing. + 9. ✎ **(d) `llm` `providers.json` — measured; the recorded blocker is REAL but overstated, and the honest count is 1 production site, not 21.** Verified on this branch: exactly **21** `include_str!` sites — 20 in `ironclaw_llm/src/registry.rs` and 1 in `ironclaw_reborn_cli/src/commands/config/init.rs:311` — but **`registry.rs`'s `#[cfg(test)] mod tests` opens at line 541**, so 19 of its 20 are test-only and the single production loader is `builtin_provider_definitions()` at `registry.rs:383`. The CLI site is *also* `#[cfg(test)]` (module opens at `init.rs:296`) and is a **drift guard**, not a loader: it asserts the three hand-maintained `DEFAULT_LLM_*` consts still match `providers.json`'s `nearai` entry. **Correction to the recorded blocker:** both CLI gates that forbid `ironclaw_llm` — `assert_no_normal_workspace_deps` and `assert_workspace_deps_exactly` — filter to **normal** dependencies (the file says so in as many words), and the CLI already carries four internal `[dev-dependencies]`. So a dev-dep is *legal today*; "it needs a new mechanism, not a new path" is stronger than the gates require, and what is actually open is an owner call on whether the app tier may see the provider cone in test builds. **Three findings for whoever takes it.** (i) The runtime overlay lane already exists — `ProviderRegistry::load()` / `load_from_path` / `try_load_from_path` over `~/.ironclaw/providers.json` — so "composition input" is half-built. (ii) `scripts/ci/classify-test-scope.sh` hardcodes `providers.json` **twice**, and only one of the two survives a move: `is_code_path` still matches via its `crates/*` arm, but `is_shared_test_path` **stops matching**, silently dropping edits to the catalog out of the shared/full test lane. That is the one step that fails *quietly*. (iii) `ironclaw_operator/src/llm_admin/provider_admin.rs:934` is **not** a `providers.json` include as the WS2 residue inventory implies — it is `include_str!` of the **CLI's source file**, scraped for a `PROVIDERS_STUB` literal. A separate §11.2.7 cross-crate reach-in, in the blast radius but a different defect. **Not attempted here** (see the PR's verification note); the seven CLI-free steps — `git mv`, 20 literal rewrites, two `Dockerfile` COPY deletions, the two `classify-test-scope.sh` arms, the stale `check-include-str-paths.sh` comment, the redundant `tests/e2e/conftest.py` entries, and `ironclaw_llm`'s missing `BoundaryRule` — are unblocked and touch `ironclaw_reborn_cli` zero times. + 10. ✎ **(h) `projects` absorbs its composition service adapter — REFUTED AS WRITTEN: the adapter is not in composition, and the migration note §6.4.11 attaches to it is false.** The clause targets "the authorization-gating adapter (665 lines in composition today)". `git show d46fdc9b86^:crates/ironclaw_reborn_composition/src/support/fs/project_service.rs | wc -l` → **665**, exactly the figure — and `d46fdc9b86` (#6691) **moved it into `ironclaw_product`**, where it is now `src/project_service.rs` at 737 lines (its doc comment still opens "Composition adapter…"). `runtime/local_dev/project_create.rs` (308) went with it as `project_create_capability.rs` (338). CHECKLIST line 349 and §6.10.1 both flag the wrong landing zone; **§6.4.11's own body and §9's table row 27 were never updated**, so the clause reads as a composition eviction when it is a `product → projects/identity` re-shed. **A second stale figure travels with it:** §2's "project filesystem reader (453)" is listed as still resident in composition; it moved in the same commit and is `ironclaw_product/src/scoped_fs/project_filesystem_reader.rs` at 454 lines. **And the blocker is a sequencing one, not a sizing one:** `trait ProjectService` + `ProjectServiceError` live in `ironclaw_product` (layer `products`) while `ironclaw_projects` is `substrates`, so the port must move to `ironclaw_product_contracts` **first** — `product_contracts/src/workspace_views.rs:1-12` already names that call and assigns it to the WS5 `product` row. **That refutes §6.4.11's migration note in one line**: "identity's pinned allowlist is unchanged — `{host_api, filesystem}` already covers the merged crate verbatim" is true of `ironclaw_projects` *today* and false the moment the adapter travels with it, because the adapter needs `ironclaw_product_contracts` and `reborn_dependency_boundaries.rs:392-402` pins identity to exactly those two. Also `crates/ironclaw_projects/CLAUDE.md` still records the **W2 decision §6.4.11 overturned** and names `ironclaw_product::RebornProjectService` as the right home — i.e. the crate's own guidance endorses where the code actually went. Nothing changed here; the collision with the services-evictions sibling never materialised, because the two touchpoints they would have shared (`composition/src/product_surface.rs`, `ironclaw_product/src/lib.rs`) belong to the READER hop, which that sibling owns. (§6.4.11's "842 lines" is also drift: `ironclaw_projects/src` is 883.) - [x] event_store: stop leaking `deadpool_postgres::Pool` in the public API (wrap) (§6.3.2). **Done 2026-08-03.** `ironclaw_reborn_event_store`'s public API names `deadpool_postgres` **zero** times; the driver survives only inside its private `postgres_backed` module, which is where the TLS policy and pool construction §6.3.2 assigns this crate actually live. Three dispositions, one of them a deletion: 1. **Half the leak was dead code.** `open_postgres_pool` and `open_postgres_pool_with_max_size` had exactly one caller each — composition's `open_reborn_postgres_pool` / `open_reborn_postgres_pool_with_max_size` — and *those* had **zero** callers anywhere in `crates/`, `tests/`, `tools/` or `scripts/`. A four-function pass-through chain across two crates whose only effect was to publish the driver type in two public APIs. Deleted, not wrapped. Un-masking: `ironclaw_reborn_event_store` 71 → 71 tests and `ironclaw_reborn_composition` 928 → 928, both rosters byte-identical, so nothing was masking them. 2. **The survivors take a carrier.** `open_postgres_pool_with_tls_options` returns `ironclaw_filesystem::PostgresConnectionPool` and `RebornEventStoreConfig::PostgresPool` holds one. The newtype lives in `ironclaw_filesystem` rather than in event_store because it is the only crate `event_store`, `auth` and `composition` can all name without a new dependency edge — and because that crate *is* the Postgres substrate, so the driver is chartered there (§11.2.6) rather than leaked. No `Deref` (an implicit unwrap re-admits the driver into a signature unnoticed) and a hand-written `Debug` that renders nothing (the driver's own `Debug` prints user/dbname/host/port; the password is redacted upstream by `tokio_postgres::Config`, the rest is not). @@ -389,7 +440,7 @@ owners. See the retraction on that row. --> Two adjacent defects found and **filed rather than patched**: `coding/mod.rs:212` computes the JSON byte count before checking whether latency tracing is on (the crate's zero-cost-when-off property holds for the trace, not for that field), and the private extractors' "no text found in RTF/XLSX/PPTX/binary" outcomes classify as `Failed` rather than `Empty`, which changes model-facing text and so wants its own PR. (#7103, #7104.) - [ ] conversations: move trusted-trigger-prompt safety scanning behind the triggers/kernel seam (§6.4.2). -- [ ] Module charters: mcp single-file split (§6.6.3); ~~llm sub-owner map (§6.4.13)~~; auth two-engine split (§6.4.8); webui `handlers.rs` charter map (§6.9.4). +- [x] Module charters: ~~mcp single-file split (§6.6.3)~~; ~~llm sub-owner map (§6.4.13)~~; ~~auth two-engine split (§6.4.8)~~; ~~webui `handlers.rs` charter map (§6.9.4)~~. **Row closed 2026-08-04** — all four clauses landed (llm with #7139; the other three with the WS6 charters-remainder PR). Findings per clause below. ✎ **Amended 2026-08-04 (Wave 4/WS6) — the `llm` sub-owner map is DONE, and building it refuted two things §6.4.13 asserts.** 1. **Five sub-owners were not enough, measured.** §6.4.13 names five (`providers` / `auth-sessions` / `registry` / `decorators` / `recording`). Against the tree they cover **28 of 48 files** — 79.6% of lines — leaving 20 files with no owner, including `lib.rs`, `provider.rs`, `error.rs` and `config.rs`. Five more are named to reach 100%: **`core-contract`** (the `LlmProvider` trait, request/response vocabulary, error taxonomy, config, shared HTTP hardening — upstream of every implementor, so charging it to `providers` would make providers the owner of `decorators`' and `recording`' own dependencies), **`normalization`** (cross-provider wire hygiene in all three directions — outbound tool schemas, inbound tool args, inbound content text — as distinct from the single-provider shims that stay beside their provider), **`model-catalog`** (facts about *models*, a different noun from `registry`'s catalog of *providers*, with zero code overlap), **`transcription`** (`TranscriptionProvider` is a **different trait**; nothing there implements `LlmProvider`), and **`test-support`** (a published feature with its own compatibility obligation, not a decorator). Rejected alternative: folding the 20 into the nearest of the five, which would have produced buckets whose stated charter does not describe their contents — the failure mode a charter exists to prevent. 2. **⚠ "Deletes: `reasoning.rs` (4.5k lines, zero external references — `SUPERSEDED` v1 engine remnant)" is refuted.** The file is **1,299 lines** (the dead half went in #6964, commit `67088a426f`) and the survivor is **live on the production model-response path**: `clean_response`, `contains_codex_text_tool_call_syntax` and `recover_codex_text_tool_calls_from_tool_names` are re-exported from `lib.rs:88-91` and called at **five sites** in `crates/ironclaw_loop_host/src/model_gateway.rs` (`:1617`, `:1619`, `:1634`, `:1807`, `:2156`). It is charted under `normalization` and must not be deleted. `AGENTS.md` carried the same staleness ("legacy reasoning engine") and is corrected here. @@ -398,6 +449,30 @@ owners. See the retraction on that row. --> 5. **Not done on this row:** `mcp` (crate occupied this wave), `auth` two-engine split (a code change, not a map), and `webui handlers.rs`. For webui the design is settled and only execution remains: §6.9.4 contains **no** charter-map clause at all — the definition has to be read off §6.9.1 ("module-charter map … the audited ≥11 sub-owners") and §6.4.15 ("module-charter work, **not a split**") — and the file already has the mechanism, one banner comment at `handlers.rs:296` and a working `handlers/run_artifact.rs` submodule declared at `handlers.rs:17`. Its `// arch-exempt: large_file` waiver must **stay**: unlike the one deleted from `contribution.rs`, it names a live pending plan (#5985, the WebUI route split), and a charter map does not discharge it. 6. **The `providers.json` clause on the row above is blocked, not skipped.** It has 21 `include_str!` sites, and the one that matters is `crates/ironclaw_reborn_cli/src/commands/config/init.rs:311`, which reaches five levels up *because* the CLI is barred from depending on `ironclaw_llm` — so it needs a new mechanism, not a new path. Plus two `Dockerfile` COPY lines, `scripts/ci/check-include-str-paths.sh`, `scripts/ci/classify-test-scope.sh` and the e2e staleness inputs. `ironclaw_reborn_cli` was occupied this wave. Note the violation is currently visible only in warn mode: `reborn_cross_crate_include_scan.rs` has `REPORT_ONLY: bool = true`. + ✎ **Amended 2026-08-04 (WS6) — the `mcp` single-file split is DONE, and doing it exposed a gate that the split would otherwise have silenced.** + 7. **The file split, and it had grown again.** §6.6.3 says "**2,709-line** single file (re-measured 2026-07-31 at `2e6522580`)"; on this branch's base (`89080c5160`) it is **2,767** — the figure drifted +58 more, so this is the third recorded value for one file. It becomes **seven private modules** — `contract` / `runtime` / `client` / `jsonrpc` / `discovery` / `egress` / `diagnostics` — plus a 61-line `lib.rs` that is the charter table and the re-export list and nothing else. Largest file is now **658** lines (`jsonrpc.rs`); crate `src/` totals 2,979 lines across 8 files, the +212 being seven module doc-headers and seven import blocks. + 8. **The waiver is deleted, not carried forward.** `lib.rs:1` carried `// arch-exempt: large_file, … pending the adapter module split, plan #4088` — the split this row is. No replacement was added: every file clears the 1,500-line ARCH-SPRAWL threshold that `scripts/pre-commit-safety.sh` enforces with `exit 1`, not a warning. (Contrast the `webui handlers.rs` waiver in item 5, which must stay because it names a *different* live plan.) + 9. **Move-only, proved rather than asserted.** Top-level item roster **105 → 105**, name-and-kind identical with zero additions and zero removals; **21 → 21** items declared `pub`, so the crate's public surface is unchanged and every consumer compiled with **zero edits**. Twenty-eight items newly cross a module line and were widened `priv` → `pub(crate)`; **zero** were widened to `pub`. Unfiltered `cargo test -p ironclaw_mcp --all-features -- --list`: **75 → 75** (32 lib + 38 + 5 integration), and the diff of *leaf* names is empty — the only change is the module prefix, `tests::` → `::tests::`, with the 32 lib tests bucketed to the owner they exercise (discovery 15, jsonrpc 13, diagnostics 2, client 1, runtime 1). No test helper crossed an owner, so no shared test-support module was needed. + 10. **⚠ A gate would have gone silently green, and this is the second lane it has happened to.** `reborn_dependency_boundaries.rs:1124` read `crates/ironclaw_mcp/src/lib.rs` **as one string** and scanned it for forbidden dispatcher-composition surface. After the split that file is 61 lines of `pub use`, so the scan would have found nothing and passed for the wrong reason. It is repointed to `concatenated_crate_sources(crates/ironclaw_mcp/src)` with a non-vacuity assertion — the identical shape the `ironclaw_sandbox` lane **three lines above it** already carries, from WS3 hitting this exact trap when the script lane stopped living in a `lib.rs`. The pattern is now two-for-two: **any gate that names a single `lib.rs` is a landmine for the crate it guards**, and the remaining ones should be swept before, not after, the WS7 family `git mv`. + 11. **Two rules were written into the charter because the code already depended on them.** (a) *No module builds a failure string of its own* — every reason token is constructed from `diagnostics`' three cause enums, which is what keeps the model-visible token set enumerable in one 209-line file; the split made this checkable by making `diagnostics` the only module with no crate-internal dependencies. (b) *`discovery` owns the catalog rules, `client` owns the paging loop* — the three ceilings (`MAX_DISCOVERED_MCP_TOOLS`, `MAX_MCP_TOOLS_LIST_PAGES`, `MAX_MCP_TOOLS_CATALOG_BYTES`) are `discovery`'s and the loop reads them, which is the drift-proofing `MAX_DISCOVERED_MCP_TOOLS`' own doc comment already claimed but could not enforce while both enforcement points sat in one file. + 12. **Two placement calls recorded** (delegated authority). `McpAuthContext` and `PreparedMcpClientRequest` are *not* in `contract` despite being vocabulary by shape: both are constructed and consumed entirely inside `runtime`, name no public type in their own right, and putting them in `contract` would have made the public-vocabulary module the owner of the runtime's private plumbing. `requires_host_http_egress` is in `egress`, not `contract`, because it is a transport predicate consumed by both `client` and `runtime` — charging it to either consumer would have made one depend on the other. + + ✎ **Amended 2026-08-04 (WS6) — the `auth` two-engine split is DONE, and building it refuted the "two owners" framing and discharged three other §6.4.8 clauses that were already quietly done.** + 13. **The two modules already existed; the charter did not — and the split was already severed.** §6.4.8 says the "internal two-engine split (engine vs product_auth) becomes two chartered **top-level modules**". `src/engine/` and `src/product_auth/` are top-level modules today, and measured on `89080c5160` **neither names the other: zero references in both directions.** So the missing half was never structural, it was the charter. Each module's `mod.rs` now carries one (owns / never-contains), and the severance is no longer an observation that could silently lapse — `tests/module_charter.rs::the_two_engines_do_not_name_each_other` fails on the first `use crate::engine::…` inside `product_auth` or the reverse. + 14. **⚠ Two owners were not enough, measured — the same refutation the `llm` map produced.** Charting only the two engines leaves the crate's **11 shared top-level modules** unowned. Counted symbol-by-symbol (both engines import through the crate root's flat `pub use` list, so counting `crate::::` paths reads zero and is the wrong instrument): **6 of the 11 are named by _both_ engines** — `credential`, `provider`, `oauth`, `scope`, `ids`, `error` — so charging them to either engine would make one engine the owner of the other's dependencies. Four are `product_auth`-only (`cleanup`, `domain`, `flow`, `interaction`) and one is `engine`-only (`account_state`). The map therefore has **four** owners: the two engines, `vocabulary`, and `test-support`. Rejected alternative: forcing the shared six into the larger consumer, which would have produced exactly the buckets-whose-charter-does-not-describe-their-contents failure a charter exists to prevent. + 15. **Three placement calls recorded** (delegated authority). **`account_state.rs` → `engine`**, not `vocabulary`, despite sitting at the crate root: `AuthAccountState` is named by `engine/` and by **zero** files in `product_auth/`, and `engine/mod.rs`'s own doc already claimed "the auth-account state machine". **`cleanup.rs`/`domain.rs`/`flow.rs`/`interaction.rs` → `product-auth`** on the same measured test; they are the four files a later slice could physically `git mv` into `product_auth/`, and the map says so — with the blocker named: `domain.rs` needs a rename first because `product_auth/durable/domain.rs` already holds that name. **`credential.rs` is the one genuinely two-owner file** (18 of 25 symbols `product_auth`-only, 6 named by both, including `CredentialAccountService` and `ProviderBackedCredentialAccountService` which `engine/keepalive.rs` drives for the refresh sweep); charged to `vocabulary` because the shared half is what makes it un-movable, with the service split recorded as owed work — the `gemini_oauth.rs` precedent from the `llm` map. + 16. **Three other §6.4.8 clauses were found already discharged, and are struck rather than left to be re-attempted.** *Deletes `loopback_oauth` + its `urlencoding` dep* — neither the module nor the dependency is in the tree; both `CLAUDE.md` and `AGENTS.md` still described `loopback_oauth` as a live "temporary exception" and are corrected here. *Gate `fakes.rs` behind `test-support`* — `lib.rs:21-22` carries `#[cfg(any(test, feature = "test-support"))]`. *Drops the `turns` dep via the gate-prompt port* — `ironclaw_turns` does not appear in `crates/ironclaw_auth/Cargo.toml` at all. What remains open on §6.4.8's row is nothing this clause owns. + 17. **Proof (charter, not move — stated rather than dressed up as one).** No production code moved, so the top-level item roster is **609 → 609 byte-identical, visibility included** — zero widenings, which is the strongest form of the move-only property rather than a weaker one. Unfiltered `cargo test -p ironclaw_auth --all-features -- --list`: **288 → 291**, the +3 being exactly the new charter gate; no pre-existing test was renamed, moved, or removed. Full suite 291 passed / 0 failed. The gate is **sabotage-proved in five directions**, each restored green: drop a file from the map → *"1 source file(s) have no sub-owner"*; add a phantom path → *"no longer exists"*; claim a file twice → *"claimed by more than one"*; `use crate::product_auth::…` inside `engine/` → severance failure naming the probe; `use crate::engine::…` inside `product_auth/` → the mirror. It also guards itself against going vacuous: it fails if the table parses to zero rows, if the source walk finds implausibly few files, if either engine directory is missing, or if a module concatenates to implausibly little code. The severance scan strips comment lines, because both charters deliberately *name the other engine in prose* and a scan counting those would be unsatisfiable by construction. + 18. **Coordination note for the sibling relocating the `ChannelAuthAccountState` family.** That family is declared in `ironclaw_product` (`reborn_services.rs:677`), **not** in `ironclaw_auth`, so this clause touches none of its files and there is no collision. But if the relocation lands a new file under `crates/ironclaw_auth/src/`, `every_source_file_has_exactly_one_sub_owner` will fail until that file is given a row — by design, and the failure message says which owner rule to apply (a file only one engine names belongs to that engine, not to `vocabulary`). + + ✎ **Amended 2026-08-04 (WS6) — the `webui handlers.rs` charter map is DONE, which closes this row.** + 19. **The definition item 5 above reconstructed is the one that was built, and item 5's three factual claims all re-verified.** §6.9.4 still contains no charter-map clause; the shape came from §6.9.1 ("module-charter map … the audited **≥11** sub-owners") and §6.4.15 ("module-charter work, **not a split**"). Re-measured on `89080c5160`: the file is **4,593 lines**; the banner comment item 5 cites at `handlers.rs:296` is now at **`:305`** (`// --- Admin user management ---`) and the `run_artifact` submodule declaration it cites at `:17` is still at **`:17`**. The map has **19** sub-owners against §6.9.1's floor of 11. + 20. **The `// arch-exempt: large_file` waiver stays, and a test now says so out loud.** Item 5 required this; `the_large_file_waiver_survives_the_charter_map` fails if the waiver is deleted *or* if it stops naming plan #5985, because the plan number is the only thing that makes the waiver revocable and `scripts/pre-commit-safety.sh` requires it. This is the opposite disposition to the `contribution.rs` waiver two rows up, which was deleted — the difference is that this one names a plan that has not landed. + 21. **⚠ Owners had to be conceptual, not positional, and that was forced by the row's own "not a split" constraint.** The obvious mechanism for a single file is banner-delimited regions with one region per owner. It is unbuildable here without moving code: `threads` holds **two** regions (`create_thread`/`delete_thread` at `:265-303` and `send_message`/`get_timeline` at `:591-654`), split by the admin-users block. Making them contiguous is exactly the code movement §6.4.15 forbids for this row, so the gate is **item**-granular instead — every top-level `fn`/`struct`/`enum`/`const`/`type` in `handlers.rs` and its `handlers/` submodules maps to exactly one owner, positions irrelevant. Recording the alternative because the next reader will reach for banners first. + 22. **Three placement calls recorded** (delegated authority). The **`*_activity_id` family splits three ways**: `product_capability_activity_id` and `product_surface_activity_id` are `dispatch` (the generic derivation), while `extension_lifecycle_`/`llm_provider_upsert_`/`outbound_preferences_`/`admin_configuration_activity_id` go to the concern whose request fields each one reads. **`capability_failure_http_class` is `outbound`, not `dispatch`**, despite the generic name — it is the classification the outbound-preferences routes introduced and every caller is in that owner; the promotion trigger is stated in advance (a second concern calling it moves it to `dispatch`) rather than argued later. **`get_attachment` is `attachments`, not `workspace-fs`**: both serve bytes, but attachment identity is a thread-scoped ref rather than a mount path, and keeping them apart is what stops a future path-scoping fix from being *assumed* to cover attachment downloads. + 23. **Proof (map, not move).** **Zero** files changed under `crates/ironclaw_webui/src` — `git diff --stat 89080c5160 -- crates/ironclaw_webui/src` is empty — so the item roster is identical by construction rather than by comparison, and the unfiltered test list grows by exactly the **+4** new gate (webui suite 469 passed / 0 failed). Coverage is **219 of 219** top-level items in `handlers.rs` charted, plus the 5 in `handlers/run_artifact.rs`; zero uncharted, zero phantom, zero double-claimed. **Sabotage-proved in five directions**, each restored green: drop an item from the map → *"1 handler item(s) have no sub-owner"*; add a phantom item → *"no longer exists"*; claim an item twice → *"claimed by more than one"*; delete the `large_file` waiver → the waiver test; add a brand-new uncharted handler to the file → *"no sub-owner"* (the real-world case). The gate self-guards against vacuity three ways: zero parsed rows, implausibly few walked items, and zero submodule items collected (which would leave the `run-artifact` row unchecked). + 24. **What this does *not* do.** It is not plan #5985 and does not shrink the file by a line. What it buys is that #5985 inherits a decided seam list — each of the 19 rows is one candidate module — instead of re-litigating the boundaries when the split is finally attempted. + ## WS7 — Physical family moves - [ ] Family directories created; every crate `git mv`'d to its §5 path **with** its narrowing milestone (retain-as-is crates may move in early batches); root `members` uses family paths; CI selectors/scripts/`Cargo.toml` path deps updated per batch. @@ -481,7 +556,7 @@ owners. See the retraction on that row. --> 6. ⚠ **Editing a guest's `wit_bindgen` path costs a rebuild of six shipped binaries, and WS7 will pay it again.** Six of the nine guests, precisely: the artifact cost falls only on `crates/extensions/packages/*/wasm-src/`, the six packages that commit a `wasm/.wasm` and carry a digest in `scripts/ci/wasm-src-digests.toml`. The three `test-tools/*/wasm-src/` guests commit **no** artifact and appear in neither the digest manifest nor `git ls-files '*.wasm'`, so their `path:` edits are free; the tenth site, the host's `crates/ironclaw_wasm/src/bindings.rs`, is not a guest at all. `scripts/ci/check-wasm-artifact-freshness.py` (#7080/WS2.6) keys each package's committed `wasm/.wasm` to a **digest of its whole `wasm-src/` tree**. A one-character change to a `path:` literal invalidates that digest, and the gate's contract explicitly forbids the cheap fix: *"Re-record only after `./scripts/build-wasm-extensions.sh --first-party` and committing the rebuilt artifact — the digest asserts a claim about the artifact, and updating it without rebuilding launders a stale one."* So this move ships **six rebuilt `.wasm` artifacts** (~2 MB, in their own commit) whose byte deltas are mostly fresh `Cargo.lock` resolution rather than the edit — the guests pin no toolchain, which is the documented reason the gate hashes sources instead of artifact bytes. **Plan for this on the loud-path row above:** the six package guests reach the WIT across two trees (`crates/extensions/packages//wasm-src/` → `crates/ironclaw_wasm/wit/`), so *either* side moving in WS7 breaks all six relative paths and forces the same six-artifact rebuild — this is the one place in the restructure where a pure `git mv` cannot be a text-only diff. Two ways to avoid paying it twice, both worth deciding before WS7 rather than during it: move `ironclaw_wasm` and `extensions/packages` in the **same** PR so the rebuild happens once, or give the gate a sanctioned "source change provably cannot affect codegen" path (it has none today, and a `path:` literal that resolves to byte-identical WIT is the motivating case). - [ ] §11.2.1 family⇄layer consistency test + no-stray-toplevel + explicit-members check. -- [ ] §11.2.2 exception ratchet (empty list; new entries require `removes_in` + owning issue). *Armed shrink-only at the WS0 baseline of **20** by #6936, lowered to **15** by WS1.1 ✎ *(and to **13** by WS1.2 — the ceiling on `main` was then `WS0_LAYER_MATRIX_EXCEPTION_BASELINE: usize = 13`. ✎ *Lowered to **11** on 2026-08-03 by the WS3 sandbox+mcp PR — `mcp → extensions` and `scripts → extensions`, deleted by the extension-runtime-descriptor carve-out. First wave-driven fall since WS1.2.* ✎ **Two corrections, 2026-08-03 (#7065).** (a) This row cited the constant as `reborn_dependency_boundaries.rs:4063`; it sits at **4164**, and had done since before the citation was written. **Do not re-add a line number here** — the file is ~4400 lines and every wave edits it, so a line-pinned citation is stale on arrival; name the constant, which is unique in the repo. (b) The baseline is a **union**, not a per-PR number: WS3's lanes (#7064 `hooks → wasm_limiter`, `runner → agent_loop`, `runner → loop_host`; #7065 the two above) were authored in parallel off the same 13, so whichever lands second must merge `main` down and recompute the constant as `len()` of the **merged** list — 13 − 5 = **8** — rather than carry the number it computed in isolation. Verify by counting entries in the array, never by trusting a previous PR's claim. Corrected 2026-08-02: this row stopped at WS1.1 while §8.3 and the Wave 1 exit block both carry 13, so it was the last place reading 15. **Wave 2 lowered it by zero and could not have**: every edge Wave 2 removed is `products → products`, which the matrix cannot see — PROPOSAL §8.1 reading rule 1's amendment.)* (`reborn_layer_matrix_exceptions_ratchet_down_only` in `reborn_dependency_boundaries.rs`: the list cannot grow past the recorded ceiling, and every entry must carry a non-placeholder `removes_in`). The box ticks when the list is empty and the owning-issue field lands — the ratchet forbids growth, it cannot make the list fall. ✎ *The owning-issue half is still **not a field** on `LayerMatrixException`; only `removes_in` is enforced, and it is not checked against the wave actually landing — `conversations → turns` reads `removes_in = "WS5"` and WS5 has partly shipped without it falling (PROPOSAL §8.3's 2026-08-02 amendment). An owning-issue field plus a milestone-passed check are one slice.* ✎ *Corrected again 2026-08-04: the ceiling citation in this row has now rotted twice — read the constant where it lives (`WS0_LAYER_MATRIX_EXCEPTION_BASELINE` in `reborn_dependency_boundaries.rs`) rather than any number or line quoted here. On `be33ae138f` it is **6** (WS2's registry re-layer took 10 → 6); the in-flight WS3 consolidation (#7141) lowers it to **4**. Line-number citations into a 5k-line test file rot fastest of all; this row now cites by constant name only.* +- [ ] §11.2.2 exception ratchet (empty list; new entries require `removes_in` + owning issue). *Armed shrink-only at the WS0 baseline of **20** by #6936, lowered to **15** by WS1.1 ✎ *(and to **13** by WS1.2 — the ceiling on `main` was then `WS0_LAYER_MATRIX_EXCEPTION_BASELINE: usize = 13`. ✎ *Lowered to **11** on 2026-08-03 by the WS3 sandbox+mcp PR — `mcp → extensions` and `scripts → extensions`, deleted by the extension-runtime-descriptor carve-out. First wave-driven fall since WS1.2.* ✎ **Two corrections, 2026-08-03 (#7065).** (a) This row cited the constant as `reborn_dependency_boundaries.rs:4063`; it sits at **4164**, and had done since before the citation was written. **Do not re-add a line number here** — the file is ~4400 lines and every wave edits it, so a line-pinned citation is stale on arrival; name the constant, which is unique in the repo. (b) The baseline is a **union**, not a per-PR number: WS3's lanes (#7064 `hooks → wasm_limiter`, `runner → agent_loop`, `runner → loop_host`; #7065 the two above) were authored in parallel off the same 13, so whichever lands second must merge `main` down and recompute the constant as `len()` of the **merged** list — 13 − 5 = **8** — rather than carry the number it computed in isolation. Verify by counting entries in the array, never by trusting a previous PR's claim. Corrected 2026-08-02: this row stopped at WS1.1 while §8.3 and the Wave 1 exit block both carry 13, so it was the last place reading 15. **Wave 2 lowered it by zero and could not have**: every edge Wave 2 removed is `products → products`, which the matrix cannot see — PROPOSAL §8.1 reading rule 1's amendment.)* (`reborn_layer_matrix_exceptions_ratchet_down_only` in `reborn_dependency_boundaries.rs`: the list cannot grow past the recorded ceiling, and every entry must carry a non-placeholder `removes_in`). The box ticks when the list is empty and the owning-issue field lands — the ratchet forbids growth, it cannot make the list fall. ✎ *The owning-issue half is still **not a field** on `LayerMatrixException`; only `removes_in` is enforced, and it is not checked against the wave actually landing — `conversations → turns` reads `removes_in = "WS5"` and WS5 has partly shipped without it falling (PROPOSAL §8.3's 2026-08-02 amendment). An owning-issue field plus a milestone-passed check are one slice.* ✎ *Corrected again 2026-08-04: the ceiling citation in this row has now rotted twice — read the constant where it lives (`WS0_LAYER_MATRIX_EXCEPTION_BASELINE` in `reborn_dependency_boundaries.rs`) rather than any number or line quoted here. On `be33ae138f` it is **6** (WS2's registry re-layer took 10 → 6); the in-flight WS3 consolidation (#7141) lowers it to **4**. Line-number citations into a 5k-line test file rot fastest of all; this row now cites by constant name only.* ✎ **2026-08-04 (WS3 closeout): the first half of the tick condition is met and the row still does not tick — the second half is what is left, and it is now a *smaller* slice than when it was written.** `LAYER_MATRIX_EXCEPTIONS` is `&[]` and the baseline is `0`, so "the list is empty" holds; WS12's own row records it. The **owning-issue field still does not exist** on `LayerMatrixException`, and neither does the milestone-passed check this row's previous amendment asks for — so the box stays open, per its own wording ("the box ticks when the list is empty **and** the owning-issue field lands"). What changed in the slice's favour: with the list empty there is no backlog of entries to retrofit, so adding the field and its check is now pure gate work against **zero** rows — the cheapest this will ever be, and the point at which the field starts constraining the *next* exception rather than documenting old ones. Worth doing before an exception is next taken on, not after. **Do not read the empty list as this row being done**; an empty register with no owning-issue field is exactly the state in which the next entry can land under-tracked. - [ ] §11.2.3 contracts-purity allowlists (3 new crates + host_api/common/prompt_envelope; external framework denies). - [ ] §11.2.4 port-location scan (adapter/surface/loop-port traits pinned to their owner; no cross-crate `pub use` of them). - [ ] §11.2.5 sealed-evidence rule (mint visibility + feature-gone pin). @@ -513,7 +588,7 @@ owners. See the retraction on that row. --> ## WS12 — Final verification (the 100% gate) -- [ ] `LAYER_MATRIX_EXCEPTIONS` is the empty list; the exception ratchet is active. +- [x] `LAYER_MATRIX_EXCEPTIONS` is the empty list; the exception ratchet is active. ✎ **Reached 2026-08-04 (WS3 closeout) — 20 → 0, and this is the one WS12 row a wave could close early, because it is a property of the register rather than a survey of the tree.** `LAYER_MATRIX_EXCEPTIONS` is `&[]` and `WS0_LAYER_MATRIX_EXCEPTION_BASELINE` is `0`. The last entry was `host_runtime → ironclaw_extension_support`, deleted by re-layering that crate `loops` → `runtimes` after measuring that WS3's own executor/adapter seam makes the kernel a *designed* consumer of it — the full refutation of the shed its `removes_in` named is on WS3's `first_party_tools` row, and the both-directions evidence is in the register's own narrative. **Both halves of this row are checked, not just the first:** the ratchet (`reborn_layer_matrix_exceptions_ratchet_down_only`) is active and, at baseline `0`, is an equality in effect — any new entry is red on the next commit and re-arming needs an owner-approved baseline raise in the same PR. ⚠ **Two things this row does not say.** It is not a claim that layering is finished: same-layer coupling is invisible to this register by construction, and `SAME_LAYER_EDGE_INVENTORY` (#7149) is the gate that watches it — 72 live edges, plus four `DOWNGRADE_PINS` freezing the consumer sets that past demotions widened, including this one's. And it does not tick anything else in WS12; the package-set and §9-mapping rows below are untouched. - [ ] `cargo metadata` package set == PROPOSAL §5 tree (**64** workspace packages steady-state; script-verified). *(Recomputed 2026-07-30: 66 today − 6 deletions − the `projects`→`identity` merge + 5 new crates. `run_state`'s deletion already landed and `libsql_runtime` joined both the current and target sets.)* - [ ] Every §9 mapping row cross-checked as landed (74-row audit — a one-off script or manual table tick-through). - [ ] Full gauntlet green: fmt, workspace clippy `-D warnings` (both feature lanes), workspace tests, architecture suite, integration lanes, recorded-fixture QA, frontend suites, e2e smoke. diff --git a/docs/reborn/target-architecture/PLAN.md b/docs/reborn/target-architecture/PLAN.md index 598dc367f9b..c0013a77754 100644 --- a/docs/reborn/target-architecture/PLAN.md +++ b/docs/reborn/target-architecture/PLAN.md @@ -60,7 +60,7 @@ ## Wave 3 — Kernel + loop narrowing (WS3 + WS4, non-gated parts) - Sequence: first-party tools → `extensions/ironclaw_extension_support/` (registrar pattern; one tool family per PR) → `sandbox` lane merge (no production behavior — verify at land time) → `mcp` contracts flip → obligations/builder internal splits → secrets direct-consumer tightening ⚠ (port replacements before edge removal) → runner sheds (composition functions out, model gateway → loop_host, tool disclosure) → re-layer runner/hooks/processes → `wit/` move. -- ✎ **First-party tools, started 2026-08-03.** Family 1 (skill management / url-install) landed and took `host_runtime → ironclaw_skills` with it (exceptions 10 → 9 for this family in isolation; **10 → 7** once consolidated with the sandbox+mcp slice, whose removals are disjoint — the constant is recomputed as `len()` of the merged list on the pushed ref, never inherited from a slice. Authored off 13 as "13 → 12", then recomputed after #7064's WS4 re-layer took the list to 10 — the union rule on CHECKLIST §11.2.2). Two things a later family PR should know before costing itself: only the tool's *executor* moves — its handler, manifest, and registry wiring stay host-side because `extension_support`'s boundary rule forbids `ironclaw_host_runtime` and `ironclaw_extensions` (PROPOSAL §6.8.4, amended with the family); and `host_runtime → ironclaw_extension_support` is **not** divisible family-by-family — it falls only with the last executor, so no intermediate family PR should promise it. +- ✎ **First-party tools, started 2026-08-03.** Family 1 (skill management / url-install) landed and took `host_runtime → ironclaw_skills` with it (exceptions 10 → 9 for this family in isolation; **10 → 7** once consolidated with the sandbox+mcp slice, whose removals are disjoint — the constant is recomputed as `len()` of the merged list on the pushed ref, never inherited from a slice. Authored off 13 as "13 → 12", then recomputed after #7064's WS4 re-layer took the list to 10 — the union rule on CHECKLIST §11.2.2). Two things a later family PR should know before costing itself: only the tool's *executor* moves — its handler, manifest, and registry wiring stay host-side because `extension_support`'s boundary rule forbids `ironclaw_host_runtime` and `ironclaw_extensions` (PROPOSAL §6.8.4, amended with the family); and `host_runtime → ironclaw_extension_support` is **not** divisible family-by-family — it falls only with the last executor, so no intermediate family PR should promise it. ✎ **2026-08-04 (WS3 closeout): the second half of that sentence is withdrawn; the first half was right for the wrong reason.** The edge was indeed not divisible family-by-family — but not because it needed *all* the executors: it was held only by the two families whose executors had **already** moved (`coding`, `skills`), so no future family PR was ever going to take it. It is gone, by a `loops` → `runtimes` re-layer of `ironclaw_extension_support` rather than by any shed. The standing advice to a family PR is now simpler: **cost yourself on consolidation alone — there is no exception left to promise.** The first half's other clause still holds unchanged: only the tool's executor moves, and its handler, manifest and registry wiring stay host-side. - ✎ **`wit/` landed 2026-08-03, out of sequence and safely so** — it is last in the list above but depends on nothing in front of it, touches no crate any sibling lane touches, and removes zero exceptions (it moves *files*, not a crate's layer). Two things it found are worth carrying into the rest of this wave. **First: a row can be the only doc site that is wrong, and the majority is not automatically right either.** CHECKLIST WS4's row said `crates/lanes/wit/` where four other sites said inside the crate — but the tie-break that settled it was neither the count nor seniority, it was that only one of the two destinations survives WS7 without a second edit. **Prefer the reading that the later wave cannot break.** **Second: a move can discharge a guardrail row on paper while making the guardrail's own number worse.** Repointing the four `include_str!` literals would have taken §11.2.7 from 19 cross-crate reach-ins to 21 while ticking the box that says "§11.2.7 scan passes"; the fix was to give the moved asset's *text* one owner (`ironclaw_wasm::TOOL_WIT`) rather than four readers. Every remaining `include_str!`-bearing move in WS5/WS7 has this shape — **measure the gate before and after, never infer the direction from the box.** - **Milestone:** exceptions 12 → 0. The ratchet pins it. `host_runtime` has no Docker/DB-driver cone; runner is the thin loop-hosting adapter. - ✎ **First Wave 3 slice landed 2026-08-03 (WS3 runner sheds + the WS4 re-layer).** The milestone figure above is stale in its starting number — the wave opens at **13**, not 12 (Wave 1 closed at 13; see its ✎ note). This slice took it to **10**, the first exception movement since WS0, and it is worth saying *why* it moved when Wave 2's did not: the register only responds to a crate changing **layer**, and `runner`/`hooks` → `loops` is that change. Four things to carry into the rest of the wave: @@ -70,6 +70,7 @@ 4. **Two clauses were deferred with measurements rather than executed** — `build_*` → composition (seven `pub` widenings in the crate the row narrows, plus the decorator-chain ownership conflict with `families/loop.md`; the fix is one runner-owned factory constructor, a semantic change) and the `production_readiness` deletion (verified callerless, but it cascades into five `driver_registry.rs` types). Both are sized and start from evidence; neither belonged in a move-only PR under principles 2 and 4. - ✎ **2026-08-04 — the milestone line above is wrong three ways at once; corrected here with measurements so no slot inherits any of them.** (1) **The numbers:** it was authored "12 → 0"; the wave opened at 13 (see the first ✎ note) and the live register on `main` today is **6**, with `WS0_LAYER_MATRIX_EXCEPTION_BASELINE = 6` (`reborn_dependency_boundaries.rs`). (2) **"The ratchet pins it" is false.** The §11.2.2 ratchet is **ceiling-only** — `LAYER_MATRIX_EXCEPTIONS.len() <= baseline` — it forbids growth, does not force progress, and its own failure message sanctions an owner-approved baseline raise. Nothing pins "→ 0"; the empty list is WS12's gate, not a property any wave's ratchet enforces. (3) **"→ 0" is not this wave's reachable exit.** Of the 6: the in-flight WS3 consolidation (#7141, measured at its tip 2026-08-04) deletes `host_runtime→skills` and `processes→resources` and converts `scripts→resources` into `sandbox→resources` (its register: 4 entries, baseline 4); the two lane `→ resources` edges are owned by **#7067**, which refutes the WS3 `mcp` row's premise — the estimate/usage vocabulary *already* lives in `host_api::resource` and both lanes already import it from there; what holds the edges is `ResourceGovernor`/`ResourceError`, kernel budget authority whose relocation is a carve-out, not a vocabulary move; `conversations→turns` is WS5's (owning row added 2026-08-04). That leaves `host_runtime→extension_support` as the one register entry Wave 3's own remaining rows kill. Honest exit: **register at 3 (or 1 if #7067 lands inside the wave), never 0.** + - ✎ **2026-08-04 (WS3 closeout): "never 0" is falsified — the wave exits at 0.** #7067 did land inside the wave (3 → 1, as the parenthetical allowed), and the final entry then fell too. It did **not** fall the way this note assumed. The note says Wave 3's remaining rows kill `host_runtime→extension_support`, meaning the `first_party_tools` shed; measured, that row could never have killed it. The edge is held by the two families whose executors have **already** moved (`coding`, `skills`) and by nothing else — the five families still awaiting a move keep their executors in `host_runtime` and hold no edge — and this wave's own executor/adapter seam, which leaves each tool's handler kernel-side, makes the kernel a *designed* consumer, so the edge was structural rather than transitional. What closed it is the mechanism **this document's own Wave 2 note names three bullets up**: a downward re-layer, `ironclaw_extension_support` `loops` → `runtimes`, costed by reading the crate's manifest exactly as that note prescribes. Two lessons for the remaining waves, both of which this note got half-right: (a) "expect the register to move when a crate changes layer" is stronger than it reads — **a standing exception is itself evidence that a layer declaration may be wrong**, and checking that before planning the code move is cheaper than either; (b) a row's `removes_in` names an *intention*, not a mechanism, and pricing the intention (here: ~8 kernel `pub` widenings and a semantic change to builtin-tool registration reached by 145 references across 31 files) is what surfaces the cheaper one. The `first_party_tools` row stays open at five of six families on consolidation grounds, and it no longer buys an exception deletion. - ✎ **2026-08-04 — label warning: `removes_in = "W7"` is a retired July-train label, not this program's Wave 5 or WS7.** The tags were written when the layer gate was armed (#5852 era; every W7 entry carries `introduced: 2026-07-09`, three weeks before this program existed), and this program's §8.3 resolves every W7-tagged edge through **WS2/WS3/WS4** work — none of them waits for the Wave 5 family moves. The reading "W7 = Wave 5" has already been relayed upward once as fact; it is wrong, and surviving entries now carry workstream-or-issue keys instead. ## Wave 4 — Composition, app, domains (WS6) diff --git a/docs/reborn/target-architecture/PROPOSAL.md b/docs/reborn/target-architecture/PROPOSAL.md index 0a4f4a2dbe4..392bb625385 100644 --- a/docs/reborn/target-architecture/PROPOSAL.md +++ b/docs/reborn/target-architecture/PROPOSAL.md @@ -65,7 +65,7 @@ Bottom→top (longest-path levels, re-derived 2026-07-30): `host_api`(fan-in 53, Load-bearing facts this proposal is built on (each verified; ✎ = re-measured 2026-07-30): - **`extension_host` sits *above* product today** (normal dep on `ironclaw_product`, ✎ 113 references), because the ports it implements (delivery resolver/reply-context/admission/pairing/preference-codec) are *defined in product*, and its ingress calls product's sealed host-auth mint. -- ✎ **`product` sits above `runner`/`loop_host`** — at authoring this was one pure-data import each (failure-summary formatters at `projection/turn_events.rs:34`; a prompt constant, now `:994`). #6691 added a **third, non-data** edge: the project-create capability it evicted from composition (`product/src/project_create_capability.rs:8`) imports `ironclaw_loop_host` for real behavior. The §6.9.1 shed ("`runner`/`loop_host` single-symbol deps → `host_api::failure` + a product-owned prompt asset") now has one more site to resolve, and it is behavior rather than a constant — noted, not re-designed. +- ✎ **`product` sits above `runner`/`loop_host`** — at authoring this was one pure-data import each (failure-summary formatters at `projection/turn_events.rs:34`; a prompt constant, now `:994`). #6691 added a **third, non-data** edge: the project-create capability it evicted from composition (`product/src/project_create_capability.rs:8`) imports `ironclaw_loop_host` for real behavior. The §6.9.1 shed ("`runner`/`loop_host` single-symbol deps → `host_api::failure` + a product-owned prompt asset") now has one more site to resolve, and it is behavior rather than a constant — noted, not re-designed. ✎ **Re-measured 2026-08-04 (WS6): it has *two* more, and the count everywhere in this document is low.** The live edge is **five production files across three seams**: the project-create capability, the attachment reader, and — recorded nowhere until now — an **input-queue enqueue seam** (`reborn_services.rs`, `steering.rs`, `inbound_turn.rs`) consuming five symbols that all come from `ironclaw_loop_host/src/input_queue.rs`. `RebornServices` holds an `Arc` and steering calls it, so that seam is a port inversion, not a move. Also: §6.4.11's stated destination for the project-create capability is unreachable as written — `ironclaw_projects` is `substrates` and `ironclaw_loop_host` is `loops`, so `projects → loop_host` is upward and matrix-illegal; the reachable owner is `ironclaw_first_party_extension_ports` (`loops`, already holds the sibling `skill_activation_capability.rs`), and only after `ProjectService` leaves `ironclaw_product`. - ✎ **Amended 2026-08-01 (Wave 1 truth audit, measuring PR #6982 / WS1.7): "single-symbol" was wrong on both edges, and only one of the two is gone.** **`product → runner` is SEVERED** — it was two modules, not one symbol (`projection/turn_events.rs` imported `failure_categories::CHECKPOINT_REJECTED_CATEGORY` *and* four items from `failure_summary`); the data moved to `ironclaw_host_api::failure::{categories, summary}`, `ironclaw_product`'s manifest no longer names `ironclaw_runner` under `[dependencies]` (the dev-dep stays, and is now documented — product's harnesses legitimately build a full turn stack), and both runner modules are private. What could not follow, because `host_api` may hold no internal dependency: `checkpoint_rejection_host_explanation` (typed on `agent_loop`'s `CheckpointKind` and `loop_contracts`' `LoopSafeSummary`) and every classifier. **`product → loop_host` SURVIVES on two behavioral sites**, and the prompt clause is the only part done (`FAILURE_EXPLANATION_SYSTEM_PROMPT` and its `prompts/failure_explanation.md` asset are product-owned now; prompt *content* is out of charter for the loop tier, §6.1.4/§6.7.2). The two survivors are `project_create_capability.rs` (the #6691 arrival named above, six `SyntheticCapability*` symbols, owed a second hop to `identity::projects` per §6.4.11) and — **not previously recorded in any document** — `scoped_fs/attachment_reader.rs` (renamed from `attachment_landing.rs` when the lander moved), which consumes the `LoopAttachmentReadPort`/`LoopAttachmentReadError` port pair. Severing needs both owners moved: WS5's product narrowing and WS6's project re-shed. **Neither edge was ever a `LAYER_MATRIX_EXCEPTION`** (`products → kernel` and `products → loops` are matrix-legal), so this work cannot move the exception count — its value is dependency-graph narrowing and prompt-content placement, worth stating because the wave milestone is written in exceptions. - ✎ **`turns` now sits *above* `processes` and `approvals`** (normal dep, added by #6696 when the turn store became a journal projection). Both ends are `kernel` in the target, so the edge is legal by the matrix and adds no exception — but it does mean `turns` is no longer a bottom-tier "domain store" in the topology, which is what §6.5.8 predicted would happen. - ✎ **`libsql_runtime` is a true leaf** (fan-out 0, no workspace dependencies; consumed by `filesystem`, `triggers`, and `composition`) — the driver-admission runtime #6863 introduced. @@ -557,17 +557,19 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **6.4.1 `ironclaw_threads`** — retain. Canonical transcript service (`SessionThreadService`, filesystem/in-memory impls). Never: turn lifecycle authority, delivery policy. Deps: `common`, `filesystem`, `host_api`, `safety`. Why a crate: contract w/ 5 consumers + 2 impls. **Naming fix obligation:** the `conversations` collision (§6.4.2). ✎ **Discharged 2026-08-01 (WS5 naming traps):** the collision was **five** names, not four — `ThreadMessageRecord` collided too — and every one was renamed on the *conversations* side, so this crate's vocabulary is unchanged. `reborn_conversations_threads_attachments.rs` now compares the two crates' declared names by discovery, so a new collision fails at introduction. - **6.4.2 `ironclaw_conversations`** — retain, rename internals. External↔canonical binding, actor pairing, accepted-message/turn-submission idempotency, trusted-trigger submitter. Never: payload parsing, transcript content. **Contract fixes:** rename its `SessionThreadService` (→ `InboundConversationService`) and its same-named DTO trio — the audited worst naming trap; unify `ExternalActorRef`/`ExternalConversationRef` with the `host_api` pair (one canonical definition, the other deleted; product's field-by-field translators removed). ✎ **Amended 2026-08-01 (Wave 1 truth audit): "the `host_api` pair" is stale, and the duplication is lossier than "one canonical definition, the other deleted" implies.** WS1.4 (#6980) moved the counterpart out of `host_api`, so the two declarations today are `ironclaw_conversations/src/ids.rs:48,72` and **`ironclaw_extension_contracts/src/external.rs:69,144`** (`ironclaw_product_contracts` only *imports* them, at `inbound.rs:13-15` and `projection.rs:11-13`). Both sites are hand-written `pub struct`s, not `bounded_ref!` output. **They are not field-compatible:** conversations' `ExternalActorRef` is `{kind, id}` while extension_contracts' adds `display_name`; conversations' `ExternalConversationRef` is `{space_id, conversation_id, thread_id, message_id}` against extension_contracts' `{space_id, conversation_id, topic_id, reply_target_message_id}`, and the error types differ (`InboundTurnError` vs `ProductAdapterError`). So the "translators" are lossy and inconsistent, not mechanical: `product/src/conversation_binding.rs:818-821` **silently drops `display_name`**, `:826-835` maps `topic_id → thread_id` and `reply_target_message_id → message_id`, and `product/src/workflow.rs:595-606` is a **second, differently-behaving** translator that hardcodes `None` for the fourth field, with five more ad-hoc constructions inline at `product/src/run_delivery/gate_routes.rs:40,48,59,68,77`. The unification therefore has to pick a field set and a `None`-semantics before it can delete anything. The finding is already pinned in-tree as a deliberate exemption — `reborn_extension_contract_location_scan.rs:120-136` carries both names in `COLLISION_EXEMPT` and calls them "the same concept, declared twice … a real duplicate-surface finding, not a false positive" — so the scan will not regress it, but it will not close it either. Tracked on CHECKLIST WS5's `conversations`/`threads` row. ✎ **Done 2026-08-01 (WS5 naming traps), with three corrections.** (a) The trio is a **quartet**: `ThreadMessageRecord` collided as well (`src/types.rs:243`), so the renames are `InboundConversationService`, `AcceptConversationMessageRequest`, `AcceptedConversationMessage`, `AcceptedConversationMessageReplay`, `AcceptedConversationMessageLookup`, `ConversationMessageRecord`. (b) **"with the `host_api` pair" is stale**: WS1.4 moved `external.rs` to `ironclaw_extension_contracts` (`src/external.rs:69,144`), which is where the unification landed; this crate's target deps gain `extension_contracts` (`substrates → contracts`, no layer exception). (c) The duplicate was **field-divergent**, and the divergence that mattered was *equality* — conversations' derived `PartialEq`/`Hash` included the per-event message id that the canonical type deliberately excludes, so the same route compared equal or unequal depending on which copy the caller held. The durable record grammar (`thread_id`/`message_id`, no `display_name`) is preserved by `ironclaw_conversations::stored_refs`, in the crate that owns the records rather than the crate that owns the type — ✎ **and as of the 2026-08-02 review correction that means preserved on the *write* side too**, not only accepted on read; see the CHECKLIST WS5 row for why the original one-way shape was a mistake; the delivered-gate-route *fingerprint* format does change, and self-heals inside its 48-hour TTL (CHECKLIST WS5 records the exposure). Move the safety-scanning of trusted trigger prompts behind the triggers/kernel seam it guards (module move). Deps: `filesystem`, `host_api`, `safety`, `triggers` + turn vocabulary via `host_api`. Why a crate: distinct identity/idempotency authority consumed by extension_host/product/composition. ✎ **This entry contradicts itself, measured 2026-08-04 (WS5 sever slice) — [decision owed].** Its charter sentence retains the **trusted-trigger submitter** in this crate; its Deps clause drops the turn coordinator that submitter holds (`ConversationTrustedTriggerSubmitter` wraps `InboundTurnService`, generic over `C: TurnCoordinator`, calling `submit_turn`). Both cannot hold. The resolution §8.3's 2026-08-02 amendment and CHECKLIST WS5 both wrote — *move the inbound submit orchestration to the product tier* — is **refuted by §8.2's own retained named rule**, "untrusted-ingress paths never construct trusted trigger submitters": `untrusted_ingress_paths_cannot_submit_host_trusted_inbound` lists `crates/ironclaw_product/src` as an untrusted root and forbids all four trusted-submitter symbols there, and `conversation_trusted_trigger_submitter_stays_conversation_or_composition_owned` independently names conversations/composition as the only owners. Moving it into `ironclaw_product` relaxes a security boundary rather than repointing a path-keyed gate. Note also that the product tier *already* owns its own inbound submit orchestration — `ironclaw_product::DefaultInboundTurnService` calls `TurnCoordinator::submit_turn` directly and never routes through this crate's `InboundTurnService`, whose untrusted entry point has zero callers outside its own crate and test file — so what is actually left here is the **trusted-trigger** submitter alone, i.e. precisely the thing §8.2 excludes from that destination. **Discharged in the same slice:** the vocabulary half of this Deps clause is now real — the ten `host_api`-owned turn names this crate uses are imported from `ironclaw_host_api::turn` instead of through the `ironclaw_turns` re-export hop, leaving exactly two turn-crate-owned names (`SubmitTurnResponse`, `TurnError`) plus the orchestration. The owner call — strike the charter clause and move the submitter to composition, or strike the Deps clause and keep it here — is recorded with full measurements, sizing and both candidate costs on the CHECKLIST WS5 `conversations -> turns` row. ✎ **Resolved 2026-08-04 (delegated authority): this entry's "product tier" clause is STRUCK.** The submitter moves to `ironclaw_reborn_composition`, the co-owner both enforcing gates already sanction and which already constructs it. Execution is blocked one step earlier, and the blocker is measured on the CHECKLIST row: the untrusted `handle_inbound_turn` entry is production-uncalled (zero callers outside its own crate and test file) but **not dead** — 22 regression tests reach the orchestration only through it, including `untrusted_trigger_adapter_records_product_inbound_not_scheduled_trigger`, the sole executable proof that an untrusted adapter cannot spoof `TrustedTrigger` classification. Deleting it surfaces 37 `E0599`s and the compiler's own `variant Untrusted is never constructed`. The workable shape moves both entry points and all 22 tests, gating the untrusted one behind composition's existing `test-support` feature, at ~540 production + ~2,224 test lines — and needs `SubmitTurnResponse` to descend to `host_api::turn` first, because it is in the *retained* ledger contract, not in the moved code. ✎ **Superseded the same day — final shape is PORT INVERSION, and this entry's Deps clause is reachable without moving any behaviour.** Relocating orchestration into composition was rejected (composition's charter is wiring and its mass gates exist to shrink it). Instead `ironclaw_conversations` keeps the orchestration and declares a **one-method submission port** — the coordinator handle is touched at exactly one call site — which composition implements with the handle it already constructs; that adapter is the sanctioned home for the `TurnCoordinator` handle. Both pre-build gates passed: the minting gate polices `TrustedTriggerSubmitRequest`, not `SubmitTurnRequest`, and `TurnErrorCategory`/`adapter_status_code` are named only in this crate's tests, so the port error carries three equivalence classes rather than the kernel denial cone. **`SubmitTurnResponse` has descended to `ironclaw_host_api::turn` (zero new dependencies, re-exported through `ironclaw_turns`' already-documented facade), so this crate's retained ledger contract — `traits.rs`, `types.rs`, `memory.rs`, `conversation_state_store.rs` — no longer names the kernel at all.** The residue is the orchestration in three files, which the port removes. ✎ **BUILT 2026-08-04 — this entry's Deps clause is now literally true, and its charter sentence is intact.** `ironclaw_conversations`' deps are `filesystem`, `host_api`, `safety`, `triggers`, `extension_contracts` + turn vocabulary via `host_api`, with **no** `ironclaw_turns` under `[dependencies]` and no coordinator anywhere in its production code; the `conversations -> turns` layer-matrix exception is deleted and the baseline lowered 4 → 3. The charter's *"trusted-trigger submitter"* stays exactly where it was: `src/turn_submission.rs` declares `ConversationTurnSubmitter` — one method, `submit_conversation_turn` — plus its `ConversationTurnSubmission` request, the `ConversationInboundClassification` trust value the orchestration derives from its own binding policy, and a `TurnSubmissionError` carrying `retry()` (the three-class rotate/retry/permanent partition) and `category()`/`adapter_status_code()` (identical statuses to the kernel's) over the host's verbatim rendered cause. `ironclaw_reborn_composition::automation::conversation_turn_submitter` implements it over the `TurnCoordinator` handle composition already constructed for the trigger poller and owns the total `TurnError` → port-error mapping and the `product_context::resolve_inbound` call; that adapter is **+158 net production lines** in composition, against the ~540 the struck relocation candidate would have cost it. Two corrections to the pre-build analysis, both recorded on the CHECKLIST row: the retry class is **not** derivable from the category (the `Conflict` category straddles retryable `TurnError::Conflict` and permanent `LeaseMismatch`/`InvalidTransition`/`RunNotRetryable`), so the port error carries two axes rather than one three-valued one; and `ironclaw_turns` is retained as a **dev**-dependency, documented in the manifest, so the crate's own fakes can stand in for the adapter on the real `SubmitTurnRequest` shape — which is what lets `untrusted_trigger_adapter_records_product_inbound_not_scheduled_trigger` stay byte-identical in its home while the composition-side half of the same guard is added at the real adapter. Dev-dependencies are not layer-matrix edges (`is_normal_dependency` filters them out of the `cargo metadata` walk), so the exception is gone rather than relocated. -- **6.4.3 `ironclaw_triggers`** — retain. Scheduled-trigger records, cron/timezone validation, deterministic fire identity, `TriggerPollerWorker::tick_once`, trusted-submit minting (`TriggerTrustedInboundBinding`). Never: poller *lifecycle* (composition), a parallel agent loop. **Persistence idiom flag:** its hand-written libSQL/Postgres repos (3,347 lines) are the family's documented exception; converge on the filesystem fabric or write the ADR (§12.6). Boundary role: **security-relevant** (host-trusted ingress minting — the sealed trusted-submitter path stays here, pinned by the existing trusted-trigger tests). Why a crate: distinct domain + trusted-mint authority. +- **6.4.3 `ironclaw_triggers`** — retain. Scheduled-trigger records, cron/timezone validation, deterministic fire identity, `TriggerPollerWorker::tick_once`, trusted-submit minting (`TriggerTrustedInboundBinding`). Never: poller *lifecycle* (composition), a parallel agent loop. **Persistence idiom flag:** its hand-written libSQL/Postgres repos (~~3,347~~ ✎ **3,372** lines at `89080c5160`) are the family's documented exception; ~~converge on the filesystem fabric or write the ADR (§12.6)~~ ✎ **ADR written 2026-08-04 and the exception is permanent — `docs/adr/0003-triggers-keeps-hand-written-sql.md` (§12.12 D-L).** The claim/lease semantics are not expressible on the fabric, and both backends ship by profile. Boundary role: **security-relevant** (host-trusted ingress minting — the sealed trusted-submitter path stays here, pinned by the existing trusted-trigger tests). Why a crate: distinct domain + trusted-mint authority. - **6.4.4 `ironclaw_memory` / 6.4.5 `ironclaw_memory_native` / 6.4.6 `ironclaw_memory_mem0`** — retain all three. The audited *justified* provider seam: neutral contract (allowlist `{host_api, prompt_envelope}`), two production providers, shared conformance suite, composition-only mem0 naming (dedicated test). Fixes: delete `memory_native`'s dead `EmbeddingProvider` port (restoring vector search is §12.10), delete its six path-preservation re-export shims, drop its unused `prompt_envelope` dep (the write-safety engine consumes envelope vocabulary via `ironclaw_memory`, which owns that dep). Why crates: criteria 1+4 (2 production impls) + 6 (mem0's HTTP cone off-by-default). **Amendment (2026-07-29, owner decision):** the two *providers* are extension packages, not domains crates — `ironclaw_memory_native` → `extensions/packages/memory-native/` and `ironclaw_memory_mem0` → `extensions/packages/mem0/`, at the same level, each declaring a `[memory]` manifest surface and linked only by the binary; the native package ships installed by default so memory stays always-on. `ironclaw_memory` (contract + conformance suite) stays here, and the kernel and composition keep consuming the contract only. The seam, the conformance suite, and every fix above are unchanged — what changes is where provider code ships. Mapping rows 21–22 updated; `families/domains.md` and `families/extensions.md` carry the amended layout. - **6.4.7 `ironclaw_skills`** — retain, narrow. Skill parsing/validation/selection/management + pure learning (prompts as crate assets; `SkillInferencePort` stays the intended inversion port). Deletes: `registry`/`catalog`/`v2`/`gating` (~4k lines, zero consumers) or explicit revival with a consumer named; fully rewrite the stale v1 `lib.rs` doc. Layer: **substrates** (today `loops`; its consumers are kernel/hosting-tier — reassignment makes current reality legal). Gains: `SkillActivationObserver` + observed-event type (from `first_party_extension_ports`) so product's projection needs only this domain. -- **6.4.8 `ironclaw_auth`** — retain, narrow. Product-auth flow/account/interaction/cleanup contracts + durable services + the recipe-driven `AuthEngine` (vendor differences are recipe data — the invariant stays). Deletes: `loopback_oauth` (dead, §2.6) + its `urlencoding` dep; gate `fakes.rs` behind `test-support` (today ships ungated in release builds — a real hygiene bug). Drops the `turns` dep via the gate-prompt port in `host_api` (the named follow-up exception). Internal two-engine split (engine vs product_auth) becomes two chartered top-level modules. Why a crate: credential-custody domain, 8 consumers, boundary rule already comprehensive. +- **6.4.8 `ironclaw_auth`** — retain, narrow. Product-auth flow/account/interaction/cleanup contracts + durable services + the recipe-driven `AuthEngine` (vendor differences are recipe data — the invariant stays). Deletes: `loopback_oauth` (dead, §2.6) + its `urlencoding` dep; gate `fakes.rs` behind `test-support` (today ships ungated in release builds — a real hygiene bug). Drops the `turns` dep via the gate-prompt port in `host_api` (the named follow-up exception). Internal two-engine split (engine vs product_auth) becomes two chartered top-level modules. Why a crate: credential-custody domain, 8 consumers, boundary rule already comprehensive. ✎ **Amended 2026-08-04 (WS6): the two-engine split is DONE, three of this entry's four other clauses were already discharged before it ran, and building the charter refuted the "two" in "two chartered modules".** (a) **The split was never structural.** `src/engine/` and `src/product_auth/` are already top-level modules and, measured on `89080c5160`, **neither names the other — zero references in both directions**. What was missing was the charter. Each `mod.rs` now carries one, and the severance is pinned rather than observed: `tests/module_charter.rs::the_two_engines_do_not_name_each_other` fails on the first cross-reference either way. (b) **⚠ Two owners cover only 2 of the crate's 13 top-level modules.** Counted symbol-by-symbol — the right instrument, because both engines import through the crate root's flat `pub use` list, so counting `crate::::` paths reads zero — **6 of the 11 shared modules are named by _both_ engines** (`credential`, `provider`, `oauth`, `scope`, `ids`, `error`); charging them to either would make one engine the owner of the other's dependencies. Four are `product_auth`-only (`cleanup`, `domain`, `flow`, `interaction`) and one is `engine`-only (`account_state`). The enforced map in `crates/ironclaw_auth/CLAUDE.md` therefore has **four** owners: the two engines, `vocabulary`, and `test-support` — the same refutation §6.4.13's five-sub-owner claim met in the `llm` map, arriving independently. (c) **Already done, struck rather than left to be re-attempted:** `loopback_oauth` and its `urlencoding` dep are **gone** from the tree (both `CLAUDE.md` and `AGENTS.md` still called it a live "temporary exception"; corrected); `fakes.rs` **is** gated (`lib.rs:21-22`, `#[cfg(any(test, feature = "test-support"))]`); and the `turns` dep is **not present** — `ironclaw_turns` appears nowhere in `crates/ironclaw_auth/Cargo.toml`, so the gate-prompt-port clause has no work left. (d) **This clause moved no production code**, and says so rather than dressing a charter up as a move: the top-level item roster is **609 → 609 byte-identical including visibility**, and the unfiltered test list goes **288 → 291**, the +3 being exactly the new gate. The gate is sabotage-proved in five directions and self-guards against vacuity in four. (e) **One movability finding for a later slice**, recorded in the map with its blocker: `cleanup.rs`, `flow.rs` and `interaction.rs` could be `git mv`'d into `product_auth/` on the measurement above; `domain.rs` cannot without a rename, because `product_auth/durable/domain.rs` already holds that name. - **6.4.9 `ironclaw_attachments`** — retain, widen. The single landing routine **plus its ports** (`InboundAttachmentLander`/`InboundAttachmentReader` move in from product; their composition impls move in as the default impl over `ScopedFilesystem`) — ending the 3-crate spread; one home for the size-ceiling constants (webui/openai_compat import them). Why a crate: single-authority landing path with 3 consumers. ✎ **Widened 2026-08-01 (WS5), with one carve-out and one correction.** Both ports, `AttachmentCleanupReport`, the default `ProjectScopedAttachmentLander`, and the advertised-ceiling pair (`AttachmentCapabilities` / `attachment_capabilities()`, moved out of `ironclaw_product`) now live here; WebUI reads its advertised ceilings from the crate that enforces them. **Correction:** "their composition impls" — the impls were never in composition, they were in `ironclaw_product::scoped_fs::attachment_landing`; composition only constructs them. **Carve-out:** `ProjectScopedAttachmentReader` **cannot** move — it also implements `ironclaw_loop_host::LoopAttachmentReadPort`, a `loops`-layer trait a `substrates` crate may not name, and moving the struct would orphan that impl. It stays in product and the gate pins it there. The ports keep erroring with `ProductSurfaceError`, so this crate names `ironclaw_product_contracts` (layer-legal, but a **[decision]** the CHECKLIST row records: narrowing the error would move the WebUI 404/403 status mapping, which is behavior). - **6.4.10 `ironclaw_extractors`** — retain. Pure MIME→text with bomb caps. Fixes: typed error across the boundary (today `Result`), remove the caller-less `extract_text` from the public surface, add a guidance file. Why a crate: pure leaf with heavy deps (pdf/zip) kept out of consumers. > ✎ **Executed 2026-08-03 (WS6). All three fixes landed, and the row understated the work in two ways worth recording rather than quietly absorbing.** (1) **`extract_text` was not the only caller-less public item — `TRUNCATION_MARKER` was too**, and it was the more dangerous of the pair: `ironclaw_agent_loop` (`executor/capability_helpers.rs:43`) and `ironclaw_mcp` (`lib.rs:1459`) each declare their **own** constant of that name with a *different* value (`" […truncated]"`, `"..."` vs this crate's `"\n[... truncated, document too long ...]"`), so a public one here invited a cross-crate mix-up for a value nobody imported. Both items are private now; the census that proves it is exact, because **no crate anywhere writes `use ironclaw_extractors::…`** — every consumer calls through the full path, so a path grep is complete. (2) **The typed error closed a live violation of the very invariant that motivated it.** The rule — *"carries the error reason for logging only; callers render a model-safe marker, never this string"* — lived as a doc comment on `DocumentExtraction::Failed(String)` and **only** there; the other boundary site, `extract_document_text_by_filename`'s `Result<_, String>`, carried no such comment, and `ironclaw_extension_support`'s `read_file` interpolated its raw string into a **model-facing safe summary** (`coding/file.rs:325-329`) — while carefully redacting the *path* one argument earlier. The new `ExtractionError`'s `Display` renders the classification and nothing else, so that call site became safe without changing, which is the argument for the type over the comment. The regression test lives **at the call site**, not on `Display`: the wrapper composing the summary is what leaked, and a unit test on the error type alone would not have caught it. Two further notes for whoever touches this next: the crate's private ZIP-safety enum was renamed `ExtractionError` → `ZipEntryError` to free the natural name; and the private extractors' "no text found in RTF/XLSX/PPTX/binary" outcomes still classify as `Failed`, not `Empty`, which is a pre-existing fidelity nit deliberately preserved (it changes model-facing text) and filed separately (#7104). -- **6.4.11 `ironclaw_projects`** — **merge into `ironclaw_identity`** as its `projects` module (decided 2026-07-30; the consolidation audit overturns the W2 retain: 842 lines, one wiring consumer, a dependency set byte-identical to identity's pinned allowlist, and no rule anywhere that distinguishes it). Migration: identity's pinned allowlist is unchanged — `{host_api, filesystem}` already covers the merged crate verbatim; the authorization-gating adapter (665 lines in composition today) moves in as the module's service half; the product port stays in `product_contracts`; "access resolution is never cached" becomes a module test. +- **6.4.11 `ironclaw_projects`** — **merge into `ironclaw_identity`** as its `projects` module (decided 2026-07-30; the consolidation audit overturns the W2 retain: 842 lines, one wiring consumer, a dependency set byte-identical to identity's pinned allowlist, and no rule anywhere that distinguishes it). Migration: identity's pinned allowlist is unchanged — `{host_api, filesystem}` already covers the merged crate verbatim; the authorization-gating adapter (665 lines in composition today) moves in as the module's service half; the product port stays in `product_contracts`; "access resolution is never cached" becomes a module test. ✎ **Corrected 2026-08-04 (WS6): two of this entry's three load-bearing facts are stale, and the migration note is refuted.** (a) **The adapter is not in composition.** `git show d46fdc9b86^:crates/ironclaw_reborn_composition/src/support/fs/project_service.rs | wc -l` → **665**, exactly this entry's figure, and #6691 (`d46fdc9b86`) moved it into `ironclaw_product`, where it is `src/project_service.rs` at **737** lines; `runtime/local_dev/project_create.rs` (308) went with it as `project_create_capability.rs` (338). §6.10.1 and CHECKLIST line 349 flag the wrong landing zone, but this entry and §9 row 27 still describe a composition eviction. The clause is a **`product → projects/identity` re-shed**. (b) **"identity's pinned allowlist is unchanged" is false once the adapter travels.** It is true of `ironclaw_projects` as it stands (its only ironclaw deps are `host_api` + `filesystem`) and false the moment the adapter moves with it: `trait ProjectService` + `ProjectServiceError` live in `ironclaw_product` (`products`), `ironclaw_projects` is `substrates`, so the port must first move to `ironclaw_product_contracts` — a call `product_contracts/src/workspace_views.rs:1-12` already assigns to the WS5 `product` row — and the adapter then needs `ironclaw_product_contracts`, which `reborn_dependency_boundaries.rs:392-402` pins identity *against*. **This is a two-PR sequence, not a merge.** (c) "842 lines" is drift: `ironclaw_projects/src` is **883** (454 + 429). (d) `crates/ironclaw_projects/CLAUDE.md` still records the W2 decision this entry overturned *and* names `ironclaw_product::RebornProjectService` as the correct home — the crate's own guidance endorses where the code actually went, so it must be rewritten with the move, not after it. - **6.4.12 `ironclaw_identity`** — retain, rename (from `ironclaw_reborn_identity`). External identity → stable `UserId` + minimal user directory; keeps its allowlist `{host_api, filesystem}`. Absorbs: `host_api::user_identity` store ports (persistence ports don't belong in the vocabulary crate) — resolving the audited "two parallel identity-binding stores" ambiguity in its CONTRACT (unresolved half → §12.10). Trim: the three zero-caller resolver methods per open issue #5618 or wire them. Why a crate: bottom-of-stack identity authority with machine-enforced never-reach-upstream rule. - **6.4.13 `ironclaw_llm`** — retain, narrow. Provider contract + providers + registry + reliability decorators + recording. Gains: `llm_costs`/`provider_transcript`/`model_selection` from `common`. ✎ **Amended 2026-08-01 (Wave 1 truth audit): this crate gains none of the three — all three moves are refuted by pinned boundary rules and the modules stay in `ironclaw_common`** (measured by PR #6982/WS1.6; full reasoning at §6.1.5). The residual design work is not a move at all: `llm_costs`' static pricing table wants to route behind `ModelCostTable`, the port this crate's consumers already reach through `ironclaw_loop_host` and that composition already overrides — a WS4 shed item needing an owner, not a §6.1.5 eviction. Deletes: `reasoning.rs` (4.5k lines, zero external references — `SUPERSEDED` v1 engine remnant). Fixes: `providers.json` stops being an `include_str!` two levels above the crate (becomes a crate asset or composition-supplied data); stale v1 guidance rewritten; add its own boundary rule (today only consumers are ruled). Internal module charters for its five sub-owners (providers/auth-sessions/registry/decorators/recording); the three-OAuth-stacks finding is §12.10. Why a crate: provider cone isolation + 8 consumers. ✎ **Amended 2026-08-04 (Wave 4/WS6): the sub-owner map is DONE and lives in `crates/ironclaw_llm/CLAUDE.md`, enforced by `tests/module_charter.rs`. Two claims in this entry are refuted by building it.** (a) **"its five sub-owners" is short by five.** Measured across the tree, `providers`/`auth-sessions`/`registry`/`decorators`/`recording` own **28 of 48 files**; the other 20 — including `lib.rs`, `provider.rs`, `error.rs`, `config.rs` — fit none of them. The map adds `core-contract`, `normalization`, `model-catalog`, `transcription` and `test-support`, each for a stated reason (the trait and error taxonomy are *upstream* of every implementor; cross-provider wire hygiene is not one vendor's protocol; model facts are a different noun from the provider catalog; `TranscriptionProvider` is a different trait; `testing/` is a published feature with a compatibility obligation). (b) **"Deletes: `reasoning.rs` (4.5k lines, zero external references — `SUPERSEDED` v1 engine remnant)" is wrong on both figures and on the disposition.** The file is **1,299 lines** after #6964 deleted its dead half, and the survivor is **live**: `lib.rs:88-91` re-exports `clean_response`, `contains_codex_text_tool_call_syntax` and `recover_codex_text_tool_calls_from_tool_names`, which have **five production call sites** in `crates/ironclaw_loop_host/src/model_gateway.rs` (`:1617`, `:1619`, `:1634`, `:1807`, `:2156`). Note the caller also moved — this entry's cited `crates/ironclaw_runner/src/model_gateway.rs` no longer exists. The module is charted under `normalization` and is not a deletion candidate. The same staleness in `AGENTS.md` ("legacy reasoning engine") is corrected with this amendment. (c) The remaining fix on this row, **`providers.json` ceasing to be an `include_str!` above the crate, is blocked rather than open**: of its 21 include sites the load-bearing one is `crates/ironclaw_reborn_cli/src/commands/config/init.rs:311`, which reaches five levels up precisely *because* the CLI may not depend on `ironclaw_llm` — so it needs a new mechanism, not a new path — and `ironclaw_reborn_cli` was occupied this wave. Its §11.2 gate is still `REPORT_ONLY` in `reborn_cross_crate_include_scan.rs`. - **6.4.14 `ironclaw_trace_commons`** — retain, rename (from `ironclaw_reborn_traces`; target name amended 2026-07-30 — the naming audit found `traces` promised trace machinery while the crate is the Trace Commons client, unresolvable beside `observability`), restructure internally. Trace Commons client: envelope schema, deterministic redaction, submission queue/holds/telemetry, credits, device-key onboarding. Fixes: split the 17,467-line `contribution.rs` into chartered modules (schema/redaction/queue/credits/credentials); take a `ScopedFilesystem` instead of raw `dirs`/env access; drop the boundary-laundering re-export modules (`recording`, `paths`) — consumers import the owners; add guidance files. The `trace_commons` model-callable tool moves to the first-party package (§6.8.4). Why a crate: distinct external-service domain with a security-critical redaction obligation. ✎ **Amended 2026-08-04 (Wave 4/WS6): the `contribution.rs` split is DONE; two figures in this entry are corrected and one of its four fixes is re-scoped.** (a) **"the 17,467-line `contribution.rs`"** measured 17,470 at `74778bab78` — the file drifted after this entry was written; it is now a directory module of 13 production submodules plus a mirrored `tests/` tree, largest file 1,290 lines, with the charter table in `src/contribution/mod.rs`. (b) **"chartered modules (schema/redaction/queue/credits/credentials)"** understates the owner count by more than granularity: two of those five are each *two* owners, and the split says why — redaction divides by **key** (`privacy` matches patterns over arbitrary text; `tool_payloads` matches tool-and-field names, and a rule belongs to whichever input it keys off), and the queue divides into **state** (`queue`), **wire** (`remote`), and the orchestration that is the only module permitted to call both (`submission`), which is what stops a transport change from silently becoming a queue-semantics change. (c) The entry's own `// arch-exempt: large_file` waiver (plan #6168) is **deleted rather than carried forward**, with no replacement — every file clears the 1,500-line ARCH-SPRAWL threshold, which `scripts/pre-commit-safety.sh` enforces with `exit 1`. The split is **API-invariant**: submodules are private and `mod.rs` glob-re-exports them, so `contribution::X` remains the single public path and **no consumer crate was edited**. Preservation was proved rather than asserted — 501 top-level items before and after (zero drift, diffed against `origin/main`) and 216 lib tests with identical leaf names. (d) **The remaining three fixes are not done, and the CHECKLIST's shorthand for one of them is worded backwards** — it reads "`ScopedFilesystem` … dropped", but this entry's instruction is *adoption*: `ScopedFilesystem` is `ironclaw_filesystem`'s type, absent from this crate entirely, and taking it means replacing ~91 raw `std::fs`/`tokio::fs` call sites in the contribution pipeline plus `dirs::home_dir()` and eight `std::env::var` reads, and dropping the direct `dirs` dependency. That is a persistence-plane behavior change and was deliberately kept out of the move PR, where mixing it in would have destroyed the roster and test-name evidence. Dropping the `recording`/`paths` shims is **blocked by crate occupancy, not difficulty**: all three call sites are in `ironclaw_reborn_cli`; `paths` is a dependency-section move, while `recording` needs a decision because the CLI has no `ironclaw_llm` dependency at all. "Add guidance files" is partly discharged — the crate got its first (`CLAUDE.md`), recording the glob-re-export invariant and these gaps. + + > ✎ **`ScopedFilesystem` adoption re-measured 2026-08-04 (WS6): the blocker is discharged and the number is 39, not ~91 and not 18.** #7152 deferred this on the grounds that every call site sat inside the 17,470-line `contribution.rs` that #7124 was concurrently splitting; that split has landed, so the file no longer exists and the sequencing constraint is gone. Counted on the split tree with `rg -o 'std::fs::[a-z_]*|tokio::fs::[a-z_]*' crates/ironclaw_reborn_traces/src` excluding `tests/`: **39 production call sites across 5 files** — `contribution/maintenance.rs` **16**, `contribution/queue.rs` **10**, `contribution/submission.rs` **4**, `contribution/notice.rs` **1**, `onboarding/device_key.rs` **8**. #7153's "11 in `contribution.rs` + ~7 in `device_key.rs`" was measured before the split and undercounts the contribution half by more than half; the correct figure is above. The `device_key.rs` caveat #7153 raises stands and is now the deciding question rather than a footnote: those 8 sites carry 0700-permission logic, and `ScopedFilesystem` has no permission vocabulary to express it — so this is not one conversion but two decisions (adopt for the 31 contribution sites; decide whether the device key stays on raw `std::fs` or the mount plane grows a mode concept). Not attempted in this slice: it is a persistence-plane behaviour change across 5 files, and folding it into an eviction PR would destroy exactly the roster evidence the split PR preserved. - **6.4.15 `ironclaw_outbound`** — retain. Metadata-only outbound policy/state: notification opt-in, sealed claim→grant trust types, subscription cursors, at-most-once delivery-attempt reservation (CAS `Prepared→Sending`), resolution engine. Never: any transport send (verified), projection mutation. Deletes: `RouteCurrentRunFinalReply` (0 impls). The 20-method fat port is module-charter work, not a split. Boundary role: **authority** (sole writer of delivery-attempt state; sealed grant minting). Why a crate: distinct durable authority consumed by product/extension_host/streams. ### 6.5 `crates/kernel/` — the authority perimeter @@ -591,7 +593,7 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **6.6.1 `ironclaw_wasm`** — retain. WASM component lane over its crate-local `wit/` (the directory moves inside the crate — `crates/lanes/ironclaw_wasm/wit/` — matching the spec's ownership claim and the wit-bindgen default, ending the invisible repo-root path coupling), deny-by-default host traits, fresh store per call, fuel/epoch/memory limits. ✎ **As built 2026-08-03 (Wave 3): `crates/ironclaw_wasm/wit/{tool,channel}.wit`** — this entry's claim is executed, in Wave-3 coordinates, and the WS7 family move carries the directory to the path written above with no further edit. Three as-built notes. (a) **The bindgen default is *not* used, and cannot be**: `tool.wit` is `near:agent@0.3.0` and `channel.wit` is `near:agent@**0.3.1**` — the same WIT package name at two versions — so handing bindgen the directory would collide; `src/bindings.rs` names the single file, `path: "wit/tool.wit"`. (b) **"Ending the repo-root path coupling" is only half-true of a naive move.** Four call sites read the ABI *text* through `include_str!`, two of them in `ironclaw_host_runtime`; moving the directory and repointing the literals would have converted two repo-root reach-ins into **cross-crate** ones (§11.2.7's strict class, 19 → 21). The coupling is actually ended by a single owner for the text — `pub const TOOL_WIT` in `src/config.rs` — with all four sites reading it: escaping include sites **133 → 129**, cross-crate unchanged at 19, zero `wit/` entries left. **A crate that owns an asset owns its `include_str!` too; exporting the bytes is what keeps ownership from turning into a reach-in.** (c) CHECKLIST WS4's row said `crates/lanes/wit/` — a sibling of the crate, not inside it — and was the sole doc site saying so; corrected there, and in `README.md`'s tree, which drew the same sibling. Deps: `host_api`, `extension_contracts` (surface vocab), `wasm_limiter`. Boundary role: **runtime/artifact isolation** (the sandbox). Why a crate: wasmtime cone + genuine trust environment. - **6.6.2 `ironclaw_wasm_limiter`** — retain. Shared `ResourceLimiter` for the tool lane and the hook engine — the documented reason it exists (extracted from a cross-crate `#[path]` import so the edge is visible to tooling). Why a crate: criterion 6 (two wasmtime hosts share one limiter without depending on each other). -- **6.6.3 `ironclaw_mcp`** — retain. MCP lane: JSON-RPC over host-mediated HTTP only (verified no direct networking). Deps: `host_api`, `extension_contracts` (drops the registry-crate dep — its W7 exception), `resources` vocabulary via `host_api` (the `mcp → resources` exception dissolves by moving the shapes it needs into `host_api::resource`, where the estimate/usage vocabulary already lives). ✎ **Amended 2026-08-03 (WS3): the registry half of this entry is DONE; the `resources` half is refuted as phrased.** The estimate/usage vocabulary is already in `host_api::resource` *and the lane already imports it from there*, so "moving the shapes it needs" describes work that does not exist. The edge is held by `ResourceGovernor` (a 10-method kernel budget-authority trait; the lane calls 3 and implements none) and `ResourceError`'s denial cone — together `ResourceAccount`, `ResourceLimits`, `ReservationOutcome`, `AccountSnapshot`, `ResourceTally`, `ResourceDenial`, `ResourceApprovalNeeded`, `BudgetWarning`, `ResourceDimension`, `ResourceValue`. That is a kernel carve-out, not a vocabulary move, and this sentence must not be read as authorizing it; the resolution is a narrow reserve/reconcile/release port, owed its own slice. What *did* land: the registry-crate dep is gone (`ExtensionRuntime` + the hosted-MCP discovered-tool pair moved to `extension_contracts`; the lane request struct no longer names `ExtensionPackage`, which it only ever read three fields from), and `ResourceReceipt` was a §11.2.4 re-export hop through `ironclaw_resources` onto `host_api`'s own type, repointed for free. See CHECKLIST WS3. ✎ **Amended 2026-08-04 (#7067): the `resources` half is now EXECUTED — as an inversion, not the move this entry originally described.** Nothing relocated. `ironclaw_host_api::resource` declares a **port**, `RuntimeResourceBudget` (`reserve`/`reconcile`/`release` only, typed on shapes that crate already owned, plus a narrow classified error `RuntimeResourceError`/`RuntimeResourceErrorKind`); `ironclaw_resources` implements it over any `ResourceGovernor` as `GovernorRuntimeBudget` and owns the `ResourceError` projection, which is subtractive (`type-placement.md` §3) — `LimitExceeded` and `RequiresApproval` stay distinct, the account/limit/dimension *values* stop in the kernel. The lane's dependency is gone from `[dependencies]` (dev-only retained so the lane suites drive the port over the real governor), and the `mcp → resources` exception is **deleted, not waived**. Behavior-free at the effect level: same authority calls in the same order, and the `model_visible_cause` string is byte-identical because the projection carries the authority's own rendering. The same port closes `sandbox → resources` in the same change; register 4 → 2. Internal: split the ✎ **2,709-line** single file (re-measured 2026-07-31 at `2e6522580`; #6930 added +226 for `tools/list` pagination and catalog caps, and the `arch-exempt: large_file` marker at `lib.rs:1` still points at plan #4088). Why a crate: distinct protocol lane with production wiring. ✎ **Verified unchanged by #6930 (2026-07-31):** the "host-mediated HTTP only" invariant holds — `Cargo.toml` has no HTTP-client dependency and `src/lib.rs` names no `reqwest`/`hyper`/`TcpStream`; the registry-crate import list (`ExtensionPackage`, `ExtensionRuntime`, `HostedMcpDiscoveredTool`, `HostedMcpDiscoveredToolAnnotations`, `lib.rs:20-22`) is byte-identical, so the W7 exception this entry dissolves is the same edge. One addition to plan the flip around: the lane now also consumes `host_api::hosted_mcp::McpAuthChallenge` (`lib.rs:27`), so its hosted-MCP vocabulary spans two contracts modules rather than one — see §6.1.1. +- **6.6.3 `ironclaw_mcp`** — retain. MCP lane: JSON-RPC over host-mediated HTTP only (verified no direct networking). Deps: `host_api`, `extension_contracts` (drops the registry-crate dep — its W7 exception), `resources` vocabulary via `host_api` (the `mcp → resources` exception dissolves by moving the shapes it needs into `host_api::resource`, where the estimate/usage vocabulary already lives). ✎ **Amended 2026-08-03 (WS3): the registry half of this entry is DONE; the `resources` half is refuted as phrased.** The estimate/usage vocabulary is already in `host_api::resource` *and the lane already imports it from there*, so "moving the shapes it needs" describes work that does not exist. The edge is held by `ResourceGovernor` (a 10-method kernel budget-authority trait; the lane calls 3 and implements none) and `ResourceError`'s denial cone — together `ResourceAccount`, `ResourceLimits`, `ReservationOutcome`, `AccountSnapshot`, `ResourceTally`, `ResourceDenial`, `ResourceApprovalNeeded`, `BudgetWarning`, `ResourceDimension`, `ResourceValue`. That is a kernel carve-out, not a vocabulary move, and this sentence must not be read as authorizing it; the resolution is a narrow reserve/reconcile/release port, owed its own slice. What *did* land: the registry-crate dep is gone (`ExtensionRuntime` + the hosted-MCP discovered-tool pair moved to `extension_contracts`; the lane request struct no longer names `ExtensionPackage`, which it only ever read three fields from), and `ResourceReceipt` was a §11.2.4 re-export hop through `ironclaw_resources` onto `host_api`'s own type, repointed for free. See CHECKLIST WS3. ✎ **Amended 2026-08-04 (#7067): the `resources` half is now EXECUTED — as an inversion, not the move this entry originally described.** Nothing relocated. `ironclaw_host_api::resource` declares a **port**, `RuntimeResourceBudget` (`reserve`/`reconcile`/`release` only, typed on shapes that crate already owned, plus a narrow classified error `RuntimeResourceError`/`RuntimeResourceErrorKind`); `ironclaw_resources` implements it over any `ResourceGovernor` as `GovernorRuntimeBudget` and owns the `ResourceError` projection, which is subtractive (`type-placement.md` §3) — `LimitExceeded` and `RequiresApproval` stay distinct, the account/limit/dimension *values* stop in the kernel. The lane's dependency is gone from `[dependencies]` (dev-only retained so the lane suites drive the port over the real governor), and the `mcp → resources` exception is **deleted, not waived**. Behavior-free at the effect level: same authority calls in the same order, and the `model_visible_cause` string is byte-identical because the projection carries the authority's own rendering. The same port closes `sandbox → resources` in the same change; register 4 → 2. Internal: split the ✎ **2,709-line** single file (re-measured 2026-07-31 at `2e6522580`; #6930 added +226 for `tools/list` pagination and catalog caps, and the `arch-exempt: large_file` marker at `lib.rs:1` still points at plan #4088). Why a crate: distinct protocol lane with production wiring. ✎ **Verified unchanged by #6930 (2026-07-31):** the "host-mediated HTTP only" invariant holds — `Cargo.toml` has no HTTP-client dependency and `src/lib.rs` names no `reqwest`/`hyper`/`TcpStream`; the registry-crate import list (`ExtensionPackage`, `ExtensionRuntime`, `HostedMcpDiscoveredTool`, `HostedMcpDiscoveredToolAnnotations`, `lib.rs:20-22`) is byte-identical, so the W7 exception this entry dissolves is the same edge. One addition to plan the flip around: the lane now also consumes `host_api::hosted_mcp::McpAuthChallenge` (`lib.rs:27`), so its hosted-MCP vocabulary spans two contracts modules rather than one — see §6.1.1. ✎ **Amended 2026-08-04 (WS6): the split is DONE, this entry's line figure was stale by 58, and its `lib.rs:NN` citations are now stale too.** The file measured **2,767** lines on `89080c5160`, not the 2,709 recorded above — a third value for one file, which is the argument for splitting it rather than re-measuring it again. It is now **seven private modules**, with the charter table in the `lib.rs` doc comment: `contract` (the vocabulary a caller names — config, DTOs, the `McpClient`/`McpExecutor` traits, the `McpError`/`McpClientError` taxonomy), `runtime` (reserve → call → reconcile/release, descriptor admission, the manifest credential context), `client` (the Streamable-HTTP `McpClient`: handshake, per-invocation session lifecycle, the `tools/list` paging loop), `jsonrpc` (the JSON-RPC 2.0 codec and response hygiene — framing, id matching, session-id and protocol-version validation, auth-challenge extraction, per-method credential routing), `discovery` (`tools/list` catalog admission — ceilings, per-tool classification, schema bounds, tool-name grammar), `egress` (the `McpHostHttp` port and the host-owned egress plan/planner), and `diagnostics` (every stable bounded failure token). Largest file 658 lines; the `// arch-exempt: large_file` marker naming plan #4088 is **deleted**, not replaced, because every file clears the 1,500-line threshold `scripts/pre-commit-safety.sh` enforces with `exit 1`. **Move-only, measured:** top-level item roster **105 → 105** name-for-name, **21 → 21** declared `pub`, unfiltered test list **75 → 75** with identical leaf names, twenty-eight items widened `priv` → `pub(crate)` and **zero** widened to `pub`, and every consumer compiled unedited. The submodules are private and `lib.rs` re-exports, so `ironclaw_mcp::X` stays the single import path — read this entry's import citations against `contract.rs`/`egress.rs` now, not `lib.rs`. **Neither dependency clause on this row is discharged by the split, and both were already done before it**: the imports this entry attributes to the registry crate now read `ironclaw_extension_contracts::{hosted_mcp, runtime}`, and the resource vocabulary already arrives through `host_api::resource`. One finding to carry past WS7: `reborn_dependency_boundaries.rs` guarded this lane by reading `crates/ironclaw_mcp/src/lib.rs` **as a single string**, so the split would have left it scanning a 61-line re-export list and passing for the wrong reason. It is repointed to the whole `src/` tree with a non-vacuity assertion — the **second** lane needing that repair, after the `ironclaw_sandbox` one three lines above it in the same test (WS3). Any gate that names a single `lib.rs` is a landmine for the crate it guards, and the survivors should be swept before the family `git mv`, not after. - **6.6.4 `ironclaw_sandbox`** — NEW by merge (plan-contract from `ironclaw_process_sandbox` + Docker/broker/credential-firewall/CA machinery from `host_runtime/sandbox_process/**` + the Docker execution path from `ironclaw_scripts`). Purpose: the sandboxed process lane — typed `SandboxProcessPlan` validation and its execution backend behind the `SandboxCommandTransport` port — which moves to `host_api` so a runtimes-layer lane can implement what the kernel consumes (amended 2026-07-30, merge audit). ✎ **Amended 2026-08-03 (WS3 merge, landed): the "everything merged is currently unwired or test-only" claim below is FALSE and must not be re-used.** Three production paths cross the merged crate — plan parse/validate on `host_runtime`'s spawn path (`production.rs:1581`), the `process_executor` routing check (`:184`), and the saved-command-output scope digest (`process_output.rs:496`). The accurate, narrower statement is that there is no production **execution backend**: `with_script_runtime` and `RebornScopedSandboxCommandTransport::new` have zero production callers. The consolidation still changed no behavior — proven at the diff (11 of 26 moved files byte-identical, 9 more differing by a single import line, +63/−36 overall) rather than inferred from the deadness claim. *Original text follows.* Everything merged is currently unwired or test-only (`CURRENT`, §2.3/§2.6), so this consolidation changes no production behavior; it gives the W6 "egress proxy / sandbox" work one home with the `bollard`/`rcgen`/`libc` cone isolated. Never: ambient credentials (the credential-firewall design stays), direct `std::process` outside the transport seam (fixing scripts' bypass). Why a crate: criterion 6 (Docker/CA cone) + 3 (artifact/trust isolation). `ironclaw_scripts` and `ironclaw_process_sandbox` are then deleted. Two migration details (2026-07-30 merge audit): `PROCESS_SANDBOX_CAPABILITY_ID` moves to `host_api::capability` — it is loop_host's one production import of the plan crate, and the merged lane's Docker/CA cone must not enter the loop tier for a string constant; and the transport port's `host_api` home above is load-bearing, not cosmetic. ### 6.7 `crates/loop/` — the loop-hosting tier @@ -609,7 +611,7 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **scheduler → `processes::ProcessSupervisor` — DONE (#6696).** `turn_scheduler` is 292 lines and self-describes as "an agent-turn projection over the generic process supervisor"; the 2,625-line scheduler contract suite moved with the mechanism. The dependency inversion this entry predicted (kernel defines the executor port; runner registers into it) is live. - **`subagent/` await-edge machinery — NOT done; ⚠ this entry's claim that #6696 deletes it was wrong.** The merge reworked it onto process edges and removed `roster.rs` + `goal_store.rs` (7.7k → 4.9k for `subagent/`), but `subagent/await_edge/` remains at 2,885 lines. The shed stays target work, now owned here rather than deferred: reduce the surviving resolver/store/boot-recovery to journal edges, or write down why an await-edge resolver is a loop-tier concern the journal cannot express. **This is a design question, not bookkeeping** — it is listed as such in §12.10. - ✎ **Amended 2026-08-03 (WS3 runner sheds).** Prior text, quoted: *"Unchanged and untouched by the merges: model gateway + tool disclosure (→ loop_host / product prompt policy), `runtime.rs` `build_*` composition functions (→ composition), `production_readiness` (no production caller — delete or wire), failure-summary data (→ `host_api::failure`)."* Status now: **model gateway → loop_host DONE**; **tool disclosure → loop_host DONE and the "/ product prompt policy" alternative is refuted** — `loops → products` is an illegal upward edge, so the ~160 lines of prompt content in that cluster cannot *relocate* to product at all; they would need an injection seam, and they do not need one, because nothing forbids prompt content in the loop tier and `crates/ironclaw_loop_host/prompts/` already holds five such assets. **Read this clause as "tool disclosure → loop_host".** **`build_*` → composition DEFERRED with a measured design** (it costs seven `pub(crate)` → `pub` widenings in the crate this entry exists to narrow, and relocates the capability-port decorator *ordering* — which `families/loop.md` assigns to this crate — into the `app` layer; the resolution is one runner-owned `pub` factory constructor, a semantic change that PLAN principle 2 keeps out of a move PR). **`production_readiness` DEFERRED**, its zero-production-caller claim re-verified and its cascade into `driver_registry.rs` measured (five readiness types have no other consumer). **failure-summary data → `host_api::failure` was already done by #6982/WS1.7** and this line was stale in claiming otherwise. **The layer clause below is now live**: the crate declares `layer = "loops"` and both `runner →` exceptions are deleted (register 13 → 10, with `hooks → wasm_limiter`). Layer: **loops** (it hosts loop execution; with the supervisor inversion the kernel calls it only through the executor port — dependency inversion, kernel defines the port). This is what dissolves the two `runner → agent_loop/loop_host` W7 exceptions. Why a crate: the trusted-adapter artifact between kernel work claims and loop userland; its narrow charter is exactly the "neutral dispatch boundary" the exception text asks for. -- **6.7.4 `ironclaw_hooks`** — retain, move layer (substrates→loops). Trust-tiered hook framework: 4 trust classes fixed by source, sealed decision sinks, ordering/failure policy, predicate state, the wasm hook engine, and the `HookedLoop*Port` middleware (deliberately colocated with the dispatcher). Layer `loops` states what it is — loop-tier middleware implementing `loop_contracts` ports — and legalizes `hooks → wasm_limiter` (the W6 exception dissolves). Persistence: its folded libSQL/Postgres predicate backends are the second documented exception to the filesystem idiom (ADR'd or converged, §12.6). Why a crate: independent trust-tier contract + wasmtime cone + 2 consumers (runner installs, composition loads). +- **6.7.4 `ironclaw_hooks`** — retain, move layer (substrates→loops). Trust-tiered hook framework: 4 trust classes fixed by source, sealed decision sinks, ordering/failure policy, predicate state, the wasm hook engine, and the `HookedLoop*Port` middleware (deliberately colocated with the dispatcher). Layer `loops` states what it is — loop-tier middleware implementing `loop_contracts` ports — and legalizes `hooks → wasm_limiter` (the W6 exception dissolves). Persistence: its folded libSQL/Postgres predicate backends are the second documented exception to the filesystem idiom (~~ADR'd or converged, §12.6~~ ✎ **ADR written 2026-08-04, decision KEEP — `docs/adr/0004-hooks-keeps-its-predicate-state-backends.md` (§12.12 D-M). Note they are complete but *unwired*: composition hard-codes `InMemoryPredicateStateBackend`, so unlike `triggers` this is staged work against multi-host counters, not a shipped deployment shape.**). Why a crate: independent trust-tier contract + wasmtime cone + 2 consumers (runner installs, composition loads). ### 6.8 `crates/extensions/` — everything "installable package" @@ -667,7 +669,7 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **6.9.2 `ironclaw_operator`** ✎ *(the contracts flip, the route-carrier clause, and the guidance/boundary-rule clause all landed 2026-08-01; see the note after this entry)* — retain, narrow. Deployment-operator control plane implementations: LLM provider admin (registry write-side, keys, active model, NEAR-AI/Codex logins), operator log ring, OS service lifecycle — now implementing `product_contracts` ports (its product dep flips to a contracts dep; ownership un-inverts). Its Axum route fragments move behind `host_ingress` carriers wired by composition (it stops owning routers). Gets: guidance files + a boundary rule (today it has neither). Why a crate: distinct operator authority with a vendor-integration cone (this *is* the LLM-vendor admin layer), consumed only by app-family crates. ✎ **Landed 2026-08-01 (WS5 operator row), with two corrections to this entry's wording.** (1) *"Its Axum route fragments move behind `host_ingress` carriers wired by composition (it stops owning routers)"* — the carriers already existed and operator had **duplicated** them: `OperatorPublicRouteMount`/`OperatorProtectedRouteMount` were field-identical copies of `ironclaw_host_ingress::{PublicRouteMount, ProtectedRouteMount}`, and the duplicate forced a composition-side shim whose whole body converted one into the other. The clause was satisfied by *deleting* both the local carriers and the shim, not by moving a route; the protected copy had no consumer at all. Operator still owns the one route it has (the public NEAR AI login callback) and hands it back as a host-owned mount — which is what "stops owning routers" should say: it never mounts, it never nests, it hands back a carrier. (2) *"Gets: guidance files + a boundary rule (today it has neither)"* — done, and the absence turned out to be causal rather than cosmetic. `ironclaw_operator` and `ironclaw_product` are both `products`-layer, so `products → products` is legal by the matrix and **invisible to every existing gate**; with no `BoundaryRule` and no crate guidance, nothing in the workspace could have reported the edge. It now has `AGENTS.md`, `CLAUDE.md`, a `BoundaryRule`, and a purpose-built gate (`reborn_operator_port_inversion.rs`) that proves the manifest edge gone through `cargo metadata` rather than a literal path, so WS10's move of this crate into `product/` fails loudly instead of silently scanning nothing. - **6.9.3 `ironclaw_openai_compat`** — retain, rename (drop `reborn_`). The OpenAI-shaped ingress adapter: route descriptors, wire DTOs, sanitized error envelope, ref/idempotency store, workflows over `BoundProductSurface`. Change: depends on `product_contracts` (+`extension_contracts` where channel DTOs are shared) instead of `ironclaw_product`; stale feature-gating guidance corrected. ✎ *2026-08-01: both edges landed with the WS5 transport inversion — 23 → 3 product symbols, the three survivors being the same frozen command constants that keep webui's edge alive. The `extension_contracts` edge carries exactly one type, `ProductTriggerReason`.* ✎ **Amended 2026-08-02 (delegated authority — §12.11 D-B): unlike webui's, this crate's edge *can* close, and it now has a named owner.** `ironclaw_reborn_openai_compat` names **3 constants and zero DTOs** (`responses_workflow.rs:42`, `chat_workflow.rs:39`); two are already clean, and the entire remaining blocker is one response type, `RebornCreateThreadResponse` → `ironclaw_threads::SessionThreadRecord`, reachable through `CREATE_THREAD_COMMAND`'s type parameter. Resolving that single DTO drops `ironclaw_product` from this crate outright. Treating the two transports as one problem — which §6.9.3, §6.9.4 and the WS5 rows all did — is what hid the difference; this is a WS5 item on this crate's row, independent of webui's permanent edge. Open modeling question (adapter-as-extension?) stays §12.10 — not forced. Why a crate: a protocol surface with its own wire-stability contract and the tightest honored guardrails in the audit. -- **6.9.4 `ironclaw_webui`** — retain. Route surface + descriptor table (✎ **92** routes, contract-locked — re-counted 2026-07-31 at `2e6522580`: `rg -c 'pub const WEBUI_V2_ROUTE_' crates/ironclaw_webui/src/webui_v2/descriptors.rs` → 92, was 91; #6930 added `WEBUI_V2_ROUTE_REGISTER_HOSTED_MCP_EXTENSION` with its frozen-table row and updated the crate's own `CLAUDE.md` route table in the same PR — the contract lock working as intended), gateway middleware order, serve loop, host authentication (Env/Session/OIDC/composite + `/auth/*` login), product-auth HTTP routes, embedded SPA. Changes: `ironclaw_product` dep → `product_contracts` (the one non-DTO import, the bearer-evidence mint, moves to `host_api`'s sealed evidence home, deleting the `host-auth-mint` feature plumbing) ✎ **Corrected 2026-08-01 (WS5 transport inversion): "the one non-DTO import" is wrong by 91.** Beyond the mint (which left with WS1.5), webui names **91 concrete command/view/capability constants** — the frozen inventory §6.1.3 keeps in product — plus 11 wire DTOs whose fields name `ironclaw_attachments`/`threads`/`auth`/`common`/`loop_contracts`. Measured at `f4819bb50`: 228 product symbols before the inversion, 102 after. ✎ *Corrected 2026-08-02 (Wave 2 truth audit): **9** DTOs and **100** symbols at merged `main`. The row predicted its own invalidation and nobody applied it — the WS5 `attachments widened` slice in the very same PR moved `ProductAttachmentCapabilities`/`product_attachment_capabilities` into `ironclaw_attachments` (they are `AttachmentCapabilities`/`attachment_capabilities()` now), which the transport gate's own comment records: "102 when the WS5 transport inversion landed; **100** after the WS5 `attachments widened` row". The pin is `WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 100` (`reborn_transport_product_boundary.rs:212`) over a 100-entry exact-match list. **91 constants is unchanged and exact**, as is "92 routes". The stale pair propagated to three other places — CHECKLIST WS5's `webui` row, its "eleven survivors" sub-finding, and `crates/ironclaw_webui/CLAUDE.md` — all corrected in this audit.* **The dep therefore does not flip in this row**; ✎ **and as of 2026-08-02 it does not flip at all — decided (delegated authority, §12.11 D-B): `webui → ironclaw_product` is a charter-sanctioned permanent edge, not a pending flip.** The clause this replaces read "*whether the inventory follows the descriptor types into contracts is the open §6.1.3-vs-§6.9.4 decision recorded on the CHECKLIST row*". It is decided against moving the inventory, because the constants are generic over the DTOs (so it is not a separable move) and because webui independently names **9** wire DTOs — the flip would need 17 product-local types plus the `ironclaw_threads` record family relocated into the contracts crate, which §6.1.3 forbids. Three corrections to this entry's own numbers: the DTO residue is **9**, not 11 (the two dropped were `ProductAttachmentCapabilities` and a *function*, `product_attachment_capabilities`); the foreign crates are **4** — `threads`, `auth`, `common`, `loop_contracts` — and **`ironclaw_attachments` is named by zero DTO fields** (the `AttachmentRef` at depth 3 is `ironclaw_common`'s, via `ironclaw_threads/src/contract.rs:2`); and "the one non-DTO import, the bearer-evidence mint" no longer exists at all, WS1.5 having moved it. Superseded text kept for the record: ~~gains the pairing routes from `extension_host`~~ ✎ **done 2026-08-02** (WS2 strays-and-follow-ups PR) — `src/channel_pairing.rs`, exported as `channel_pairing_route_mount`, mounted by the binary through the shared `ProtectedRouteMount` seam; the three route patterns are a separate mount and do **not** join the frozen 92-row `webui_v2/descriptors.rs` table, which stays the count it was. The routes arrive with a new normal dependency on `ironclaw_extension_host` (the pairing service core stays there by §6.8.2), which is this crate's first edge onto the extension host and the reason §6.9.4's boundary rule should be re-derived rather than assumed — it happens to pass unchanged today. Its second OAuth stack (host login) stays by charter (documented, distinct concern) — §12.10 records the consolidation question. Why a crate: the transport/presentation artifact (axum + SPA cone) with a comprehensive boundary rule. +- **6.9.4 `ironclaw_webui`** — retain. Route surface + descriptor table (✎ **92** routes, contract-locked — re-counted 2026-07-31 at `2e6522580`: `rg -c 'pub const WEBUI_V2_ROUTE_' crates/ironclaw_webui/src/webui_v2/descriptors.rs` → 92, was 91; #6930 added `WEBUI_V2_ROUTE_REGISTER_HOSTED_MCP_EXTENSION` with its frozen-table row and updated the crate's own `CLAUDE.md` route table in the same PR — the contract lock working as intended), gateway middleware order, serve loop, host authentication (Env/Session/OIDC/composite + `/auth/*` login), product-auth HTTP routes, embedded SPA. Changes: `ironclaw_product` dep → `product_contracts` (the one non-DTO import, the bearer-evidence mint, moves to `host_api`'s sealed evidence home, deleting the `host-auth-mint` feature plumbing) ✎ **Corrected 2026-08-01 (WS5 transport inversion): "the one non-DTO import" is wrong by 91.** Beyond the mint (which left with WS1.5), webui names **91 concrete command/view/capability constants** — the frozen inventory §6.1.3 keeps in product — plus 11 wire DTOs whose fields name `ironclaw_attachments`/`threads`/`auth`/`common`/`loop_contracts`. Measured at `f4819bb50`: 228 product symbols before the inversion, 102 after. ✎ *Corrected 2026-08-02 (Wave 2 truth audit): **9** DTOs and **100** symbols at merged `main`. The row predicted its own invalidation and nobody applied it — the WS5 `attachments widened` slice in the very same PR moved `ProductAttachmentCapabilities`/`product_attachment_capabilities` into `ironclaw_attachments` (they are `AttachmentCapabilities`/`attachment_capabilities()` now), which the transport gate's own comment records: "102 when the WS5 transport inversion landed; **100** after the WS5 `attachments widened` row". The pin is `WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 100` (`reborn_transport_product_boundary.rs:212`) over a 100-entry exact-match list. **91 constants is unchanged and exact**, as is "92 routes". The stale pair propagated to three other places — CHECKLIST WS5's `webui` row, its "eleven survivors" sub-finding, and `crates/ironclaw_webui/CLAUDE.md` — all corrected in this audit.* **The dep therefore does not flip in this row**; ✎ **and as of 2026-08-02 it does not flip at all — decided (delegated authority, §12.11 D-B): `webui → ironclaw_product` is a charter-sanctioned permanent edge, not a pending flip.** The clause this replaces read "*whether the inventory follows the descriptor types into contracts is the open §6.1.3-vs-§6.9.4 decision recorded on the CHECKLIST row*". It is decided against moving the inventory, because the constants are generic over the DTOs (so it is not a separable move) and because webui independently names **9** wire DTOs — the flip would need 17 product-local types plus the `ironclaw_threads` record family relocated into the contracts crate, which §6.1.3 forbids. Three corrections to this entry's own numbers: the DTO residue is **9**, not 11 (the two dropped were `ProductAttachmentCapabilities` and a *function*, `product_attachment_capabilities`); the foreign crates are **4** — `threads`, `auth`, `common`, `loop_contracts` — and **`ironclaw_attachments` is named by zero DTO fields** (the `AttachmentRef` at depth 3 is `ironclaw_common`'s, via `ironclaw_threads/src/contract.rs:2`); and "the one non-DTO import, the bearer-evidence mint" no longer exists at all, WS1.5 having moved it. Superseded text kept for the record: ~~gains the pairing routes from `extension_host`~~ ✎ **done 2026-08-02** (WS2 strays-and-follow-ups PR) — `src/channel_pairing.rs`, exported as `channel_pairing_route_mount`, mounted by the binary through the shared `ProtectedRouteMount` seam; the three route patterns are a separate mount and do **not** join the frozen 92-row `webui_v2/descriptors.rs` table, which stays the count it was. The routes arrive with a new normal dependency on `ironclaw_extension_host` (the pairing service core stays there by §6.8.2), which is this crate's first edge onto the extension host and the reason §6.9.4's boundary rule should be re-derived rather than assumed — it happens to pass unchanged today. Its second OAuth stack (host login) stays by charter (documented, distinct concern) — §12.10 records the consolidation question. Why a crate: the transport/presentation artifact (axum + SPA cone) with a comprehensive boundary rule. ✎ **Amended 2026-08-04 (WS6): this entry gains the internal-charter clause it never had.** CHECKLIST WS6's module-charters row names a "webui `handlers.rs` charter map (§6.9.4)", but **§6.9.4 contained no such clause** — the definition had to be reconstructed from §6.9.1 ("module-charter map … the audited **≥11** sub-owners") and §6.4.15 ("module-charter work, **not a split**"). It is written down here so the next reader does not have to reconstruct it again. **As built:** `src/webui_v2/handlers.rs` is **4,593 lines** and gains a **19**-sub-owner module-charter map in `crates/ironclaw_webui/CLAUDE.md`, enforced by `tests/handlers_module_charter.rs` — `session`, `threads`, `admin-users`, `workspace-fs`, `projects`, `attachments`, `streaming`, `runs`, `commands`, `automations`, `traces`, `outbound`, `skills`, `extensions`, `admin-config`, `dispatch`, `operator`, `llm-admin`, `run-artifact`. Four things worth carrying: (a) **The `// arch-exempt: large_file` waiver stays and is now pinned.** It names a live pending plan (#5985, the WebUI route split) that a charter map does not discharge, so a test fails if the waiver is deleted *or* if it stops naming the plan number — the opposite disposition to §6.4.14's `contribution.rs` waiver, which was deleted, and the difference is precisely that this plan has not landed. (b) **⚠ Owners are conceptual, not positional, and the row's own "not a split" constraint forced that.** Banner-delimited regions with one region per owner is unbuildable here without moving code: `threads` holds two regions (`:265-303` and `:591-654`) split by the admin-users block. The gate is therefore **item**-granular — every top-level item maps to exactly one owner, positions irrelevant. (c) **Zero source lines changed**: `git diff --stat` over `crates/ironclaw_webui/src` against the base is empty, so the item roster is unchanged by construction; coverage is **219 of 219** items in `handlers.rs` plus the 5 in `handlers/run_artifact.rs`, and the test list grows by exactly the +4 new gate. (d) **This does not shrink the file.** What it buys is that #5985 inherits a decided seam list — each of the 19 rows is one candidate module — rather than re-litigating boundaries when the split is attempted. ✎ **Re-derivation DONE 2026-08-04 (WS6): the rule passed unchanged, but it was also nine entries short. Zero removals, nine additions, all no-op ratchets** (webui's ten normal workspace deps are `host_api`, `product_contracts`, `extension_contracts`, `extension_host`, `host_ingress`, `auth`, `attachments`, `common`, `product`, `reborn_openai_compat`; none of the nine appears in any dependency kind). Added: `ironclaw_reborn_composition` (§8.2 **app ✗** — and the edge runs the other way; it was on 15 other rules and on every products-layer rule but `ironclaw_product`'s), `ironclaw_wasm_limiter`, `ironclaw_slack_extension`, `ironclaw_telegram_extension`, `ironclaw_event_projections`, `ironclaw_event_streams`, `ironclaw_extension_support`, `ironclaw_first_party_extension_ports`, `ironclaw_storage`. **`ironclaw_wasm_limiter` is the only one no other gate covered** — a lane crate no `BoundaryRule` in the workspace named, whose sole gate checks its *outbound* deps. Deliberately not added: `ironclaw_product` (§12.11 D-B) and `ironclaw_extension_host` (this entry's own pairing amendment). The rule must stay **normal-deps-only**: `ironclaw_secrets`, `ironclaw_loop_host`, `ironclaw_threads` and `ironclaw_turns` are live *dev*-deps of `ironclaw_webui`, so the `ironclaw_host_ingress`-style all-kinds tightening would go red on four counts. ⚠ Two adjacent gaps stay open and are recorded on the CHECKLIST row rather than closed here: `crates/ironclaw_webui/src` is still absent from `reborn_product_api_crates_do_not_bind_http_ingress`'s roots (its `KNOWN GAP` comment survives, though §8.2's 2026-08-02 amendment already decided the fix), and `families/product.md`'s webui entry still says webui never depends on "hosting crates", which this entry's pairing amendment overrode. Its second OAuth stack (host login) stays by charter (documented, distinct concern) — §12.10 records the consolidation question. Why a crate: the transport/presentation artifact (axum + SPA cone) with a comprehensive boundary rule. - **6.9.5 `ironclaw_host_ingress`** — retain as-is (107 lines, exactly one job): Axum route-mount carriers pairing prebuilt routers with `host_api` descriptors. Why a crate: criterion 2 in its purest audited form — it exists so contracts stay Axum-free. ### 6.10 `crates/app/` — assembly and enforcement @@ -677,10 +679,20 @@ Compact entries (all: layer `substrates`; forbidden = anything ≥ kernel unless - **6.10.1 `ironclaw_composition`** — retain, rename (from `ironclaw_reborn_composition`), radically narrow. Keeps (the ~30% that matches its charter): deployment config-as-data, `RebornHostBindings`/`RebornRuntimeInput` (with `ChannelExtensionBinding.extension_id` becoming the typed `ExtensionId`), storage catalog + backend selection, owner-factory invocation, readiness, service-graph handles (`RebornRuntime` slimmed to service methods — the ~40 `_for_test` substrate accessors move behind `test-support`), background-task start/stop. Sheds (each to its named owner). ✎ **Reconciled 2026-07-30 against merged #6691** — the eviction list was written while that PR was open, and roughly half of it is now done. Struck items landed; the rest is the real remaining inventory: - ~~automations panel service → `assistant`~~ — **done**: `composition/src/automation/service*` → `product/src/automation_product_service*`. - ~~communication-context orchestration → `assistant`~~ — **done**: `composition/src/root/communication_context.rs` → `product/src/communication_context.rs`. - - ~~project access gating (service half) → its owner~~ — **partly done**: `support/fs/project_service.rs` and `runtime/local_dev/project_create.rs` moved to `product`. ⚠ Note the destination: they landed in **product**, not in `projects`/`identity` as §6.4.11 targets, and `project_create_capability.rs` brought a new `product → loop_host` behavioral edge with it (§2.3). Re-shedding them onto the merged `identity::projects` module stays target work. + - ~~project access gating (service half) → its owner~~ — **partly done**: `support/fs/project_service.rs` and `runtime/local_dev/project_create.rs` moved to `product`. ⚠ Note the destination: they landed in **product**, not in `projects`/`identity` as §6.4.11 targets, and `project_create_capability.rs` brought a new `product → loop_host` behavioral edge with it (§2.3). Re-shedding them onto the merged `identity::projects` module stays target work. ✎ **Sever re-measured 2026-08-04 on the waves-0-4 batch union (post the WS6 evictions + extension-host residue folds), correcting the earlier "5 files / 3 seams" figure:** the `product → loop_host` surface is **6 production files across 4 distinct seams** — (1) the `HostInputEnqueuePort` input-enqueue seam (`steering.rs:24`, `reborn_services.rs:67`, `inbound_turn.rs:25` — with `RejectingInputEnqueue`, `EnqueueQueuedMessageRequest`, `HostInputQueueError`); (2) `scoped_fs/attachment_reader.rs:23` *implementing* `ironclaw_loop_host::LoopAttachmentReadPort`; (3) `project_create_capability.rs:16` importing the synthetic-capability family (`SyntheticCapability*`, `CapabilityResultWrite`, `DurablePersistence`); (4) `projection/turn_events.rs:871`, a doc-comment-only mention of `FAILURE_EXPLANATION_SYSTEM_PROMPT` (zero code dependency). `products → loops` is a downward edge, so no armed gate fires — the debt is design-rule only. Severing means hoisting or re-homing three port families, which is a design slice, not a mechanical move; carried over past the Waves 0–4 close with this measurement as its scope. - ~~capability-surface + skill-activation + external-tool/result-read/surface-disclosure/synthetic-capability adapters~~ — **done**: to `extension_host`, `first_party_extension_ports`, and `loop_host` respectively. - ~~the `local_dev` misnomer~~ — **done**: module names and the typename ratchet renamed to `capability_host`/`capability_authorization`/`runtime_mounts`/`standalone_boot`. One residue: the local variable in `runtime.rs:3016` is still `local_runtime`. ✎ **Corrected 2026-08-03 (WS6).** The sentence quoted verbatim — *"One residue: the local variable in `runtime.rs:3016` is still `local_runtime`."* — understates the residue by two orders of magnitude. At `origin/main` @ `0f897e9366` the variable is at `runtime.rs:**3095**` and `local_runtime` appears **191 times** in `crates/ironclaw_reborn_composition/src`, including six *public* API symbols (`local_runtime_build_input`, `local_runtime_build_input_with_options`, `with_local_runtime_identity`, `with_local_runtime_workspace_root`, `with_local_runtime_confirmed_host_home_root`, `requires_local_runtime_confirmed_host_home_root`), the public type `RebornLocalRuntimeIdentity`, and the `extension_host_assembly` field `local_runtime`. `reborn_standalone_typename_ratchet` governs *type* names only, which is why it stayed green. Tracked as **#7098**; it is a pure-rename PR, not a residue. - - **Still resident, still owed to their owners:** approval/authorization/trigger-fire policy → `approvals`/`authorization`/`runtime_policy`+`triggers` (the tree is now `capability_authorization` + `trigger_fire_access.rs`); trigger poller lifecycle stays but its trusted-submit *logic* (~4.3k across `automation/trigger_poller*` and the trigger assembly modules) → `triggers`/`conversations`; admin-user directory → `assistant`; trace capture (+ its hooks projection) → `trace_commons` + the turn-runner observer seam; ~~system-prompt content → prompt assets in the loop/product owner~~ — **done 2026-08-03**: `crates/ironclaw_loop_host/prompts/{default_system,tool_disclosure_protocol,self_knowledge,benchmarking_mode}.md` + `system_prompt_assets.rs`; the resolved owner is the **loop** half, not the product half — `HostIdentityContextSource` is a `loop_host` port and that crate already ships `prompts/`. Composition keeps assembly and the boot-time seeding of the on-disk `SYSTEM.md`, which could not travel: it is `std::fs` work on a real host path and `ironclaw_loop_host` has zero `std::fs` uses. Pinned by `reborn_composition_boundaries.rs::composition_root_embeds_no_prompt_content`; OpenAI-compat + NEAR-login route mounts → `openai_compat`/`operator` factories behind `host_ingress`; project filesystem reader → `identity::projects`; blocked-auth resume fan-out → `assistant`/`auth`; Google OAuth secret store and the NEAR-AI MCP module → package/auth recipes. Env reads consolidate behind `ironclaw_config`. The re-export wall shrinks to the composition-boundary snapshot (every survivor keeps its consumer+test doc, per the house rule). Why a crate: the assembly root — criterion 1 by definition (the only crate allowed to see everything), with `composition_public_api_is_service_shaped` + the mass ratchet keeping it honest. + + > ✎ **Authoritative recount, 2026-08-04 (WS6). Neither this entry nor #7152's re-scope was right; each was right about the half the other got wrong, and both undercounted.** Measured on `ws/waves-0-4-batch` @ `89080c516` with `rg -o 'local_runtime[a-zA-Z_]*' crates --glob '*.rs'`: + > + > - **326 occurrences across 50 files workspace-wide**, of which **194** are in `crates/ironclaw_reborn_composition/src`. This entry's "191 times in `crates/ironclaw_reborn_composition/src`" was close for composition and silent about the other 132 — the `local_runtime_storage_root` / `local_runtime_storage_subdir` family alone is **47** occurrences living in `ironclaw_reborn_cli` and `ironclaw_reborn_config`, outside anything either document scoped. + > - **24 distinct identifier spellings**, not #7152's 14. The long tail is real, not noise: `local_runtime_skill_management` (8), `local_runtime_allows_unsafe_raw_http_diagnostics` (8), `local_runtime_volume` (7), `local_runtime_workspace_root` (9), `local_runtime_policy` (3). + > - **Public API: this entry is right and #7152 is wrong.** Six public symbols exist exactly as listed here (`deployment.rs:621,636`; `input.rs:396,518,537,556`), plus a **seventh neither document counted** — `ironclaw_reborn_config::RebornProfile::local_runtime_storage_subdir` (`profile.rs:80`), public on a crate with a machine-enforced zero-workspace-dep rule, i.e. the operator-facing boot contract. + > - **The type: #7152 is right and this entry is wrong.** `RebornLocalRuntimeIdentity` is `pub(crate)` at `input.rs:250`. "the public type `RebornLocalRuntimeIdentity`" above is struck. + > + > **Not executed here, on two independent grounds.** (a) #7153 records that the *sanctioned* exit is Slice B — deployment mode becomes a `DeploymentConfig` value — and that `reborn_deployment_mode_typename_ratchet` already inventories this family by name in its frozen `Local*` allowlist; a rename would satisfy neither ratchet's intent. (b) #7152 renames the composition crate wholesale (4,806 occurrences across 901 files, plus the crate directory), so a 326-site identifier sweep landing beside it collides on nearly every file this touches. The residual is recorded here for #7152's refresh rather than attempted. + - ~~approval/authorization/trigger-fire policy → `approvals`/`authorization`/`runtime_policy`+`triggers`~~ — **done 2026-08-04 (WS6)**, and the three-way destination list resolved to two owners. The approval gate (`profile_approval_authorization.rs` + `runtime_profile_approval_policy.rs`, 2,178 lines) is now `ironclaw_approvals::{profile_gate, profile_gate_policy}` — **not** `authorization`, because §6.5.2 forbids that crate from doing "approvals resolution" and this module's whole subject is approvals resolution. The fire-time trigger-access contract and its two backend-free checkers are now `ironclaw_triggers::fire_access`. `runtime_policy` needed nothing: `production_runtime_policy.rs` is a smart constructor over composition's own error type, and `builtin_capability_policy.rs` is **pinned at composition's crate root** by `reborn_composition_boundaries.rs`, which asserts the module declaration by name. Two pieces of the trigger half deliberately stayed — the deployment `TriggerFireAccessPolicy` (this entry's own Keeps list names config-as-data) and the identity-directory checker (an adapter over a backend composition selects). **Cost:** `approvals → trust` and `approvals → runtime_policy`, two new same-layer kernel edges, `SAME_LAYER_EDGE_BASELINE` 72 → 74, both unavoidable at the destination (the gate implements a trait whose signature names `TrustDecision`, and consumes the `MinimalApprovalBypass` classification §4.4 pins to `runtime_policy`). The trigger half cost zero edges. Composition production LOC **45,127 → 42,688** on its own branch; ✎ on the batch union with the WS6 service-cluster eviction the joint figure is **40,499** (disjoint deltas, they add exactly). + - **Still resident, still owed to their owners:** trigger poller lifecycle stays but its trusted-submit *logic* → ~~`triggers`/`conversations`~~ — ⚠ **both named destinations are refused by a stated rule (measured 2026-08-04, WS6; not attempted).** The module is 2,268 lines of which only ~470 are production; that production surface writes the transcript (`record_trigger_prompt`) and so needs `ironclaw_threads` and one `ironclaw_product` helper. `ironclaw_conversations`' own crate doc forbids it — *"It is not the transcript. `ironclaw_threads` owns canonical threads and their message content … Keep it that way"* — and `ironclaw_triggers` would need four or five new same-layer substrates edges to host an adapter that is composition-shaped. This clause needs a **destination decision** (a different owner, or the materializer inverted behind a port as #7159 did for the coordinator handle), not an eviction; ✎ **Resolved 2026-08-04 (delegated authority): the trusted-submit materializer stays in composition; the clause is closed, not owed.** Decided rather than escalated because it is a placement call, not an empirical claim. Both named destinations are refused by stated rules — `ironclaw_conversations` by its own charter (it owns the binding, not the transcript, and `record_trigger_prompt` writes the transcript) and `ironclaw_triggers` by cost (four or five new same-layer substrates edges to host a composition-shaped adapter). Composition is the one crate whose charter already licenses an adapter over `threads` + a product helper for trigger assembly, and §6.10.1's own Keeps list names the deployment grant as config-as-data. The five security gates named above stay armed and pin the ownership. ~~admin-user directory → `assistant`~~ — **done 2026-08-04 (WS6)**: `ironclaw_product::admin_user_directory` (322 LOC), with `AdminApiTokenMinter` re-declared in `ironclaw_product_contracts::admin_users` and `AdminSecretProvisioner` declared beside its caller in product; composition keeps `FilesystemAdminSecretProvisioner`, whose whole content is building a per-target-user `MountView` — deployment shape, so it is the half that belongs here. ~~trace capture → `trace_commons` + the turn-runner observer seam~~ — **done 2026-08-04 (WS6)**, and both destinations were load-bearing rather than a hedge: `ironclaw_reborn_traces::capture` takes the consent gate, envelope build, Submit/Held/Skipped disposition, queue, immediate flush and the 300s flush worker, keyed on a scope string plus its own `ConversationMessage`; `ironclaw_runner::trace_capture` keeps the `TurnEventSink`, the `load_context_window`-backed history port and the record→message adaptation. The traces crate is `substrates` and `ironclaw_turns` is `kernel`, so the sink provably cannot live with the pipeline; `ironclaw_runner` is `loops` and already holds both `ironclaw_turns` and `ironclaw_threads`, which is what makes it the observer seam. All 15 tests moved with identical leaf names. ✎ **"(+ its hooks projection)" is retracted as a miscount, 2026-08-04.** The 350-line figure §2 pairs with the 1.2k is `composition/src/observability/hooks/projection.rs` (356 LOC) — installed-extension `[[hooks]]` manifest discovery, admission and path-containment. It shares a directory with trace capture and nothing else; neither `trace_commons` nor the turn-runner observer seam can receive it, and its plausible owner (`ironclaw_hooks`, §6.7.4) is not named anywhere in this clause. It needs its own entry; do not carry it under this one.; ~~system-prompt content → prompt assets in the loop/product owner~~ — **done 2026-08-03**: `crates/ironclaw_loop_host/prompts/{default_system,tool_disclosure_protocol,self_knowledge,benchmarking_mode}.md` + `system_prompt_assets.rs`; the resolved owner is the **loop** half, not the product half — `HostIdentityContextSource` is a `loop_host` port and that crate already ships `prompts/`. Composition keeps assembly and the boot-time seeding of the on-disk `SYSTEM.md`, which could not travel: it is `std::fs` work on a real host path and `ironclaw_loop_host` has zero `std::fs` uses. Pinned by `reborn_composition_boundaries.rs::composition_root_embeds_no_prompt_content`; OpenAI-compat + NEAR-login route mounts → `openai_compat`/`operator` factories behind `host_ingress` — ✎ **re-scoped 2026-08-04 (WS6) after measuring both halves.** The NEAR-login half is **already discharged**: `nearai_login_serve.rs` lives in `ironclaw_operator/src/llm_admin/`, `ironclaw_operator::nearai_login_callback_mount` already returns the host-owned `PublicRouteMount`, and what composition retains (`runtime.rs::nearai_login_callback_mount`, 20 lines) reads runtime-private session/reload/boot handles — assembly by definition. The OpenAI-compat half **cannot move as written**, and the obstacle is one of this programme's own armed gates rather than effort: `ironclaw_reborn_openai_compat`'s `BoundaryRule` forbids `ironclaw_threads`, `ironclaw_turns` and `ironclaw_event_streams`, and ~1,240 of `openai_compat_serve.rs`'s 1,471 LOC are adapters (`OpenAiChatCompletionThreadProjectionReader`, `OpenAiResponsesThreadProjectionReader`, `OpenAiCompatRuntimeExternalToolStore`, `OpenAiCompatRuntimeExternalToolResume` and their mappers) that name those crates. Those adapters implement the *owner crate's own ports*, which is precisely the inversion the gate exists to enforce — so composition holding them is the target state, not debt, and amending the rule to allow the move would trade a real boundary for a mass number. What is genuinely owed is the ~230-LOC residue reachable with `product_contracts` + `host_ingress` alone: `model_entries_from_snapshot` + `LlmConfigModelCatalog` + `map_llm_config_error_to_openai` (an `OpenAiCompatModelCatalog` implementation over a `product_contracts` service), `product_surface_caller_from_openai_scope`, `OpenAiCompatRuntimeProjectionStreamer` + `decode_product_outbound_events` (`ProductSurface` only), and the router-state assembly re-expressed as a factory over a ports params struct. Re-scope this clause to that residue. project filesystem reader → `identity::projects` — ✎ **blocked, measured 2026-08-04 (WS6), and the blocker is upstream of the move.** The composition-resident reader is `support/fs/mount_filesystem_reader.rs` (505 LOC), implementing `ironclaw_product::FilesystemBrowseReader` over `ironclaw_product` DTOs; `ironclaw_projects` is `substrates` and may not name a `products` crate. The **second hop** this entry asks for below is blocked one step earlier still: `RebornProjectService` imports only `crate::…`, `host_api` and `ironclaw_projects` — it would move cleanly — but the port it implements, `ProjectService`, is declared in `ironclaw_product/src/reborn_services/projects.rs`, **not** in `product_contracts` as §6.4.11's "the product port stays in `product_contracts`" assumes; and §6.4.11's companion claim that "identity's pinned allowlist is unchanged — `{host_api, filesystem}` already covers the merged crate verbatim" is **false** for an adapter implementing a product-tier port, since `reborn_identity_allowed` is an armed allowlist of exactly `{reborn_identity, host_api, filesystem}`. Two prerequisites, both decisions: hoist `ProjectService` + its ~18 DTOs into `product_contracts`, and widen the identity allowlist by that one entry. `project_create_capability.rs` has a third blocker of its own — it names `ironclaw_loop_host` (`loops`), which no `substrates` crate may hold, so its `product → loop_host` edge cannot be shed by this hop at all. ~~blocked-auth resume fan-out → `assistant`/`auth`~~ — **done 2026-08-04 (WS6)**: `ironclaw_product::blocked_auth_resume` (574 LOC). The `auth` half of the disjunction is not available and should be struck: `ironclaw_auth` is `substrates`, the fan-out queries `ironclaw_processes` and drives `ironclaw_turns` (both `kernel`), and both crates are on `ironclaw_auth`'s own forbidden list. `process_gate_turn_view.rs` (56 LOC) travelled with it — `turn_scope_from_process_gate` has no other consumer, and the two aggregators composition still calls are the same projection, so duplicating them would have been the only alternative. Google OAuth secret store and the NEAR-AI MCP module → package/auth recipes — ✎ **reported, not moved, 2026-08-04 (WS6); one is mis-routed and one has no destination.** (a) `GoogleOauthSecretStore` (155 LOC) is a one-handle wrapper over `SecretStorePort`; its live consumer is `ironclaw_reborn_cli`'s `config set google.client_secret`, and §6.10.3's own 2026-08-04 amendment already rules the Google half moves "with §6.10.2's CLI shed or not at all". This clause and that one are the same slice; keeping both open double-counts it. (b) `llm_admin/nearai_mcp.rs` (341 LOC) is first-boot provisioning — project the extension, decide reuse-vs-submit against existing credential accounts, submit the manual token, install, then activate behind the credential gate. **No package-owned mechanism exists to receive it.** The v3 manifest has no bootstrap/auto-activate recipe (`rg 'auto_activate|auto_install|bootstrap' crates/ironclaw_extension_contracts/src` returns nothing), and `[admin_configuration]` governs an *installed* extension's live configuration, not provisioning that runs before an installation exists. Building that recipe is a feature with its own design and security surface, not an eviction; this clause owes a mechanism decision before it can name a destination. Env reads consolidate behind `ironclaw_config`. The re-export wall shrinks to the composition-boundary snapshot (every survivor keeps its consumer+test doc, per the house rule). Why a crate: the assembly root — criterion 1 by definition (the only crate allowed to see everything), with `composition_public_api_is_service_shaped` + the mass ratchet keeping it honest. - **6.10.2 `ironclaw_cli`** (directory renamed from `ironclaw_reborn_cli`; **package name stays `ironclaw`**) — retain. The binary: command surface, serve wiring, binding tables (`native_extensions.rs` — the sanctioned concrete-extension linker), first-party registrars, credential-visibility policy, token minter (`AdminApiTokenMinter` impl — the sanctioned inversion). Sheds: the ~200-line Google-OAuth resolution + `reject_legacy_slack_config` → package-owned config/migration steps surfaced through generic seams. Why a crate: the shipped artifact; DEL-7 rule anchors here. - **6.10.3 `ironclaw_config`** — retain, rename (from `ironclaw_reborn_config`), narrow. Boot config contracts: home/profile/boot, `config.toml` schema, seeding, budget env defaults, inline-secret rejection — **minus vendor sections** (`SlackSection`/`TelegramSection`/`GoogleSection`, the Google update pipeline, `update_slack_enabled`) and **minus `capability_remediation.rs`** (100% Google copy) — both become package-owned admin-config schema/data flowing through the manifest `[admin_configuration]` model that already works for Slack. Compatibility window for existing operator `config.toml` files is a named constraint (§12.3). Keeps its zero-workspace-dep rule. Why a crate: the operator-facing boot contract with a machine-enforced no-deps rule. @@ -866,6 +878,8 @@ Reading rules (these, plus the matrix, are the whole model): > ✎ **Amended 2026-08-03 (WS3) — what "kernel: ✗ (ports only)" means for `extension_support`, stated because §6.8.4 read the other way.** The cell is **not** satisfied by naming a kernel trait: `ironclaw_extension_support`'s `BoundaryRule` forbids `ironclaw_host_runtime` outright, and WS3's first-party-tool row keeps that rule intact rather than widening it. "Ports only" here means *contracts-layer* ports the kernel also consumes — `ironclaw_host_api::http::RuntimeHttpEgress`, `ironclaw_filesystem::RootFilesystem`, `ResourceScope`/`ResourceUsage`/`CapabilityId` — handed in per invocation by whoever adapts the host's dispatch input. A tool that moves here brings its executor and leaves its `FirstPartyCapabilityHandler` behind; the manifests it is declared by stay with the declaring package, because `ironclaw_extensions` is on this crate's forbidden list too. Same shape as `extensions/packages/*` one row up, and the reason both rows read `✗ (ports only)`. +> ✎ **Amended 2026-08-04 (WS3 closeout) — the note above is unchanged and correct; what it did not say is that it settles this crate's *layer*, and the layer it was declared at contradicted it.** If a tool leaves its `FirstPartyCapabilityHandler` in `ironclaw_host_runtime`, then `host_runtime → extension_support` is a **designed** edge, not a transitional one — and `ironclaw_extension_support` was declaring `layer = "loops"`, two rungs above the kernel that is designed to call it. That contradiction is what `LAYER_MATRIX_EXCEPTIONS`' last surviving entry had been recording as "activation wiring" since 2026-07-09. The declaration is the half that was wrong: the crate is now **`runtimes`**, the least demotion that legalizes a kernel consumer, and the layer its row in this very table already describes in posture (mediated services by injection, kernel ✗, invoked only through capability dispatch — the `lanes/` cell verbatim). Measured both directions first: all seven normal dependencies (`auth`, `extractors`, `filesystem`, `observability`, `safety`, `skills` — `substrates`; `host_api` — `contracts`) **and** every domain its charter reserves (`memory`, `traces`, `triggers` — `substrates`) fit the narrower row, and all five consumers (`host_runtime` kernel; `extension_host`, `extension_manager` products; `reborn_composition`, `ironclaw` app) are `kernel` or above, so the move forbids no existing edge and creates **zero** same-layer edges — `substrates`, the demotion its two family siblings took, would instead have hidden six of its seven dependencies from the matrix. **None of the ✗ cells in this row moves**: the crate still may not name `ironclaw_host_runtime`, `ironclaw_extensions`, `ironclaw_product` or `ironclaw_product_contracts`, and the reach the demotion buys is frozen by a `DowngradePin` (§11.2.2's companion, issue #7149). With that entry gone the register is **empty** — §11.2.2's end state and CHECKLIST WS12's gate condition — which is why this is recorded here rather than only in the CHECKLIST row. What it does **not** close is the `first_party_tools` shed itself: five of that row's six executor families are still host-side, and it stays open on its own terms. + Plus the retained named rules: no crate outside the provider packages and the binary names a memory provider (amended 2026-07-29 from "only composition names `memory_mem0`" — composition consumes the contract only; the binary links providers); no substrate depends on the composition root; product-API crates never bind sockets **except `ironclaw_webui`, which is the host transport and owns the listener** (amended 2026-08-02, see below); untrusted-ingress paths never construct trusted trigger submitters; concrete extension crates link only from the binary. > ✎ **Amended 2026-08-02 (delegated authority — §12.11 D-H; issue #6999).** The retained-rule line above previously read, flatly: *"product-API crates never bind sockets"*. That contradicted **this same section's `product/` row three lines above it** ("webui owns axum"), and five other § texts besides — `families/product.md:42` ("`ironclaw_webui` **alone** is the crate meant to own a web framework as a listener-binding concern"), §6.9.4's *Owns* list ("serve loop"), §11's transition diagram and T1, and the crate's own guidance. The exception is now stated in the rule. Mechanically: `crates/ironclaw_webui/src` joins `reborn_product_api_crates_do_not_bind_http_ingress`'s roots with an `exempt` for `src/lib.rs` (the 43-line `serve_webui_v2` helper at `:212-254`), and `collect_forbidden_uses` routes through the file's existing `source_without_cfg_test_modules` so the seven test-only hits clear without per-file exemptions. The rule's purpose is unchanged and unweakened: it keeps the *lower* product/API tier socket-free and pushes lifecycle up to the host. Two findings recorded with the amendment: `ironclaw_operator/src` is **not** covered by this rule despite an in-tree comment asserting it is (`llm_admin/provider_admin.rs:1009-1018`) and should join the roots; and `ironclaw_llm/src/gemini_oauth.rs:576` binds a production loopback listener for the Gemini OAuth redirect, covered by no root and wanting its own assessment. @@ -944,7 +958,7 @@ Every current workspace package (66) plus excluded packages. Disposition vocabul | 24 | `ironclaw_auth` | **retain-narrow + move** → `domains/` | §6.4.8; delete `loopback_oauth`; gate `fakes.rs`; drop turns dep via port | | 25 | `ironclaw_attachments` | **retain-widen + move** → `domains/` | §6.4.9; absorbs its product ports + composition impls (ends a 3-crate accidental seam) | | 26 | `ironclaw_extractors` | **retain + move** → `domains/` | §6.4.10; typed error; guidance file | -| 27 | `ironclaw_projects` | **merge** → `domains/ironclaw_identity` (module `projects`; decided 2026-07-30) | §6.4.11; absorbs its composition service adapter; identity allowlist widens verbatim | +| 27 | `ironclaw_projects` | **merge** → `domains/ironclaw_identity` (module `projects`; decided 2026-07-30) | §6.4.11; absorbs its service adapter — ✎ 2026-08-04: the adapter is in **`ironclaw_product`**, not composition (#6691), and the identity allowlist does **not** widen verbatim — the `ProjectService` port must move to `product_contracts` first. See §6.4.11's dated correction. | | 28 | `ironclaw_reborn_identity` | **rename + move** → `domains/ironclaw_identity` | §6.4.12; absorbs host_api user-identity store ports; resolve dual-binding-store ambiguity | | 29 | `ironclaw_llm` | **retain-narrow + move** → `domains/` | §6.4.13; delete `reasoning.rs` (dead); fix providers.json reach; add boundary rule | | 30 | `ironclaw_reborn_traces` | **rename + move + restructure** → `domains/ironclaw_trace_commons` (amended 2026-07-30) | §6.4.14; ~~split 17.5k-line file~~ ✎ **done 2026-08-04 (WS6)**; drop re-export laundering (blocked — all 3 call sites in `ironclaw_reborn_cli`); adopt ScopedFilesystem (**adoption, not removal** — see §6.4.14 amendment) | @@ -997,7 +1011,7 @@ Every current workspace package (66) plus excluded packages. Disposition vocabul **Legacy-v1 classification:** with the enclave already deleted from `main`, the only v1 remnants are *inside* live crates and are handled as deletions above: `auth::loopback_oauth`, `llm::reasoning`, `skills::{registry,catalog,v2,gating}` + its v1 lib.rs doc, `ironclaw_embeddings`, and the stale v1 references across guidance (§11.5). Nothing else qualifies. -**Explicitly identified anti-pattern inventory (per the deliverable checklist):** compatibility shims — `dispatcher`, `turns::{ids,scope,product_adapter}` re-exports, memory_native's six path shims, traces' two re-export modules, product's ~120-symbol facade; transitional bridges — ~~`run_state`~~ (✎ deleted 2026-07-29 with #6696), `first_party_extension_ports` (until W7 shed), ~~config's parse-only `SlackSection`~~ (✎ 2026-08-04: deleted with `TelegramSection`/`SlackChannelRouteSection`; the parse-only shim is now a generic retired-section table, §6.10.3); god-crate modules — composition `runtime.rs`/`factory.rs`/✎`runtime/capability_host/**` (ex `local_dev/**`), extension_host's #6616/#6669 arrivals, host_runtime ~~`obligations.rs`~~ (✎ split into its three owners 2026-08-03, WS3)/`first_party_tools/`, runner ✎`subagent/await_edge`+`model_gateway`+`tool_disclosure`, product `reborn_services/**`, loop_host `capability_port.rs`, ~~traces `contribution.rs`~~ (✎ split 2026-08-04, WS6 — 13 chartered submodules, waiver deleted), webui `handlers.rs`; backend duplication — product/openai-compat LibSql/Postgres newtype wrappers over the already-backend-neutral fabric (collapse to the generic form), triggers/hooks hand-written SQL (ADR-or-converge); vendor fragmentation — slack across 3 locations, telegram across 2 crates + CLI googlisms + config vendor sections (all resolved into `packages/`); accidental trait/DTO seams — the ~17 single-impl product ports (relocated, not deleted — they are real inversions in the wrong crate), `ToolPermissionOverrideStorePort` & `RouteCurrentRunFinalReply` & memory-native `EmbeddingProvider` (deleted — no inversion), the `ExternalActorRef`/`ExternalConversationRef`/`AttachmentRef`/`SessionThreadService`/`EventStreamManager` name collisions (renamed/unified). +**Explicitly identified anti-pattern inventory (per the deliverable checklist):** compatibility shims — `dispatcher`, `turns::{ids,scope,product_adapter}` re-exports, memory_native's six path shims, traces' two re-export modules, product's ~120-symbol facade; transitional bridges — ~~`run_state`~~ (✎ deleted 2026-07-29 with #6696), `first_party_extension_ports` (until W7 shed), ~~config's parse-only `SlackSection`~~ (✎ 2026-08-04: deleted with `TelegramSection`/`SlackChannelRouteSection`; the parse-only shim is now a generic retired-section table, §6.10.3); god-crate modules — composition `runtime.rs`/`factory.rs`/✎`runtime/capability_host/**` (ex `local_dev/**`), extension_host's #6616/#6669 arrivals, host_runtime ~~`obligations.rs`~~ (✎ split into its three owners 2026-08-03, WS3)/`first_party_tools/`, runner ✎`subagent/await_edge`+`model_gateway`+`tool_disclosure`, product `reborn_services/**`, loop_host `capability_port.rs`, ~~traces `contribution.rs`~~ (✎ split 2026-08-04, WS6 — 13 chartered submodules, waiver deleted), webui `handlers.rs`; backend duplication — ~~product/openai-compat LibSql/Postgres newtype wrappers over the already-backend-neutral fabric (collapse to the generic form)~~ (✎ **collapsed 2026-08-04, WS6**: both pairs were byte-identical modulo the concrete filesystem type; openai-compat's had **zero** construction sites repo-wide and were strictly less capable than the generic form, product's had zero *production* sites and 26 test-only ones. 229 lines deleted; the product half needed three delegating root constructors first, because the generic type exposed only the *scoped* family. Both pairs arrived with #5540 as fossilized per-backend sub-crate boundaries), triggers/hooks hand-written SQL (ADR-or-converge); vendor fragmentation — slack across 3 locations, telegram across 2 crates + CLI googlisms + config vendor sections (all resolved into `packages/`); accidental trait/DTO seams — the ~17 single-impl product ports (relocated, not deleted — they are real inversions in the wrong crate), `ToolPermissionOverrideStorePort` & `RouteCurrentRunFinalReply` & memory-native `EmbeddingProvider` (deleted — no inversion), the `ExternalActorRef`/`ExternalConversationRef`/`AttachmentRef`/`SessionThreadService`/`EventStreamManager` name collisions (renamed/unified). --- @@ -1139,8 +1153,8 @@ Root `CLAUDE.md`/`crates/AGENTS.md`/`crates/Architecture.md` rewritten to the fa - trust's inert `SignedRegistry`/`DevTrustOverride` — commit (signed-package roadmap) or delete; - the three-OAuth-stacks question (auth engine ∣ webui login ∣ llm provider sessions) — deliberate today, consolidation unscoped; - `openai_compat` modeled as an installed extension vs a product surface (today: hardcoded adapter id); - - `identity`'s dual binding-store (host_api `RebornUserIdentityBindingStore` vs identity's resolver) — one must become canonical (issue #5618); - - trigger/hook SQL convergence vs ADR; + - ~~`identity`'s dual binding-store (host_api `RebornUserIdentityBindingStore` vs identity's resolver) — one must become canonical (issue #5618);~~ ✎ **RESOLVED 2026-08-04 (§12.12 D-N).** Neither becomes canonical, because they are not the same thing: the `host_api` ports front **post-OAuth channel binding** and the resolver fronts **principal identity**. What was genuinely duplicated — the resolver's `lookup`/`bind` + `ExternalIdentityKey`, with zero production callers — is deleted, closing #5618. + - ~~trigger/hook SQL convergence vs ADR;~~ ✎ **RESOLVED 2026-08-04 — both ADR'd, both keep (§12.12 D-L, D-M; `docs/adr/0003`, `docs/adr/0004`).** Note the two are *not* one case, as this list and §12 item 6 implied: `triggers` ships both backends by profile, `hooks` ships **neither** (composition hard-codes the in-memory backend), so they keep their SQL for different reasons. - `silk_decoder` wiring-or-removal; - layer-name cosmetics (`loops`→`hosting`) — zero mechanical benefit, pure vocabulary, default is keep; - renames are **decided, no longer severable** (2026-07-29 owner review + 2026-07-30 naming audit): the three stutter kills (`ironclaw_events`→`ironclaw_event_log`, `ironclaw_extensions`→`ironclaw_extension_registry`, `ironclaw_product`→`ironclaw_assistant`); the full `reborn_` batch (composition/config/cli-dir/openai_compat/event_store/identity + `ironclaw_reborn_traces`→`ironclaw_trace_commons` + root `ironclaw_reborn_integration_tests`→`ironclaw_integration_tests`) — the naming rule (§5.1) cannot be stated while a discriminator word discriminates nothing; and four fidelity renames from the audit (`ironclaw_architecture`→`ironclaw_architecture_tests`, `ironclaw_first_party_extensions`→`ironclaw_extension_support`, `ironclaw_runner`→`ironclaw_turn_runner`, plus the trace_commons retarget above). Explicitly rejected despite friction: `host_api`, `common`, `capabilities`, `outbound` renames (each trades one distortion for a worse one or costs ~42-consumer churn). @@ -1347,6 +1361,40 @@ The delegated-authority pass investigated this row fully and **declined to rule* **Confidence: high (~85/15) on evicting it; moderate (~70/30) on *localize* over *`ironclaw_common`*.** The other side: a reviewer who weighs "one implementation of a byte counter" above "the narrowing direction of `ironclaw_common`" gets a defensible, cheaper answer, and if a *third* consumer ever appears the duplication argument flips — three copies is where this ruling should be revisited, and the two `AGENTS.md` files say so. +#### D-L. `triggers` keeps its hand-written SQL (CHECKLIST WS6 clause (f); §6.4.3, §11.2.6, §12 item 10) + +**Ruling: ADR, do not converge — [`docs/adr/0003-triggers-keeps-hand-written-sql.md`](../../adr/0003-triggers-keeps-hand-written-sql.md).** The crate is a permanent, documented exception to the `ScopedFilesystem` floor and stays on the §11.2.6 shrink-only driver allowlist. + +**What decides it is the shape of the SQL, not its volume.** The hand-SQL body is 3,372 lines (§6.4.3's "3,347" has drifted +25; the ADR corrects it) carrying 46 libSQL / 40 PostgreSQL distinct statements, 26 / 25 of them on the runtime path. The load-bearing ones are the concurrency control for a work queue whose contract (`docs/reborn/contracts/triggers.md:202-204`) requires `max_concurrent_fires_per_trigger = 1` *"through an atomic repository claim/lease operation that covers read, eligibility check, active-fire check, and claim write"*. Three patterns the `RootFilesystem` contract does not express: **(1)** a five-predicate single-statement CAS inside `BEGIN IMMEDIATE` (`libsql.rs:754`) — per-document CAS detects a superseded version after the fact, it does not test five columns and write in one indivisible step; **(2)** `SELECT … FOR UPDATE` (`postgres.rs:515`), which serializes *other readers* of the row, a thing no document API offers; **(3)** a transaction spanning `trigger_records` **and** `trigger_run_history` with per-column `ON CONFLICT` merge precedence. A lost claim race is not a retry — it is two agent turns and two threads for one scheduled fire, each minting its own trusted-inbound request. + +**Two costs the row did not name.** Convergence would have to delete `ironclaw_filesystem` from this crate's `forbidden` boundary list (`reborn_dependency_boundaries.rs:3968`), i.e. legalize a new substrate→substrate edge *on top of* the persistence rewrite. And unlike hooks (D-M), **both trigger backends are genuinely wired** by profile (`backend_store_assembly.rs:89`/`:99`; `production_backend_assembly.rs:1330`/`:1373`), so converging deletes a shipped deployment shape — a product decision this restructure has no mandate to make. + +**Parity was verified rather than asserted, and one fail-open was found.** `tests/repository_contract.rs` is 4,710 lines / 51 tests over 31 shared `assert_*` helpers; all 21 `TriggerRepository` methods are exercised by shared helpers, and claim atomicity is raced per durable backend. But all five PostgreSQL skip paths returned `None` after an `eprintln!`, so on a Docker-less runner the PostgreSQL half of the matrix **skipped silently and reported green** — the parity claim this ADR rests on was only true where Docker happened to exist. Fixed in the same PR by honouring `IRONCLAW_REQUIRE_POSTGRES=1`, the switch `ironclaw_hooks/tests/parity_matrix.rs` already carried for the same hazard. Recorded-not-fixed: the suite is private helpers in one test binary, not an exported `pub mod contract`, so an out-of-crate backend cannot run it — costless while both backends live in-crate, a prerequisite for a third. + +#### D-M. `hooks` keeps its predicate-state backends — **and the WS4 row's stated premise is false** (CHECKLIST WS4 `hooks` row; §6.7.4, §11.2.6) + +**Ruling: keep both durable backends — [`docs/adr/0004-hooks-keeps-its-predicate-state-backends.md`](../../adr/0004-hooks-keeps-its-predicate-state-backends.md) — but on different reasoning than the row recorded, because the row's reasoning does not survive measurement.** + +**The correction.** The WS4 row's 2026-08-04 decision text says *"composition chooses PostgreSQL or libSQL by profile through the `RootFilesystem` mount catalog"* and rejects convergence because it would delete a shipped deployment shape. **Neither hooks backend is wired.** `crates/ironclaw_reborn_composition/src/observability/hooks/factory.rs:325` hard-codes `InMemoryPredicateStateBackend` — the only `with_state_backend` call site outside the owning crate — and searching the workspace for either durable backend type outside `crates/ironclaw_hooks/` returns zero hits. `warn_in_memory_backend_active_in_production()` exists because that is the state. **§12 item 6's "the two hand-SQL crates keep their own parity suites" is right about the suites and wrong to treat the two crates as one case:** `triggers` ships both shapes, `hooks` ships neither. + +**Why the decision survives anyway.** The real question is whether 1,803 lines of unwired-but-complete backend earn their keep. They do: **(1)** they close a gap in-memory *structurally cannot* — its replay dedup is process-local (`predicate_state.rs:357`), so rate and value caps, which are a security control, are bypassable the moment a second host serves one tenant; `tests/multi_host_adversarial.rs` exists for exactly that. **(2)** They are proven interchangeable rather than quietly rotting: `tests/parity_matrix.rs` cross-asserts all three backends against each other *and* against an independent hand-computed oracle, so a bug two backends share still fails. **(3)** The swap is one line, so deletion is the expensive option — it converts a one-line change into re-deriving 1,803 lines plus both migration sets. The ADR states plainly that it keeps unexecuted code, and names the revisit conditions rather than leaving them implied. + +**#6945 discharged with it.** The row's ✎ note warned that the cross-run dispatcher-isolation semantic had no regression test and that guidance had once claimed one that never existed. `poisoned_hook_slot_does_not_leak_into_the_next_run` now extends `tests/integration/hooks.rs` at the tier and through the caller #6945 named, and its red-ability was verified by pointing `ironclaw_runner::runtime` at the legacy shared-dispatcher adapter and watching it fail on the fire count (sabotage reverted). Predicate counter state is deliberately **not** asserted isolated — it is tenant-scoped by design, and pinning isolation for it would pin a rate-cap bypass. This is why the ADR carries both halves: the counters it keeps a durable backend for are exactly the state the isolation test must leave alone. + +#### D-N. `identity` does **not** absorb `host_api::user_identity`; the "dual binding-store" is nominal (CHECKLIST WS6 clause (g); §12 item 10, issue #5618) + +**Ruling: the ports stay in `ironclaw_host_api`. The two stores are distinct concerns and both survive. The one genuine deletion — #5618's dead `ExternalIdentityKey` + `lookup`/`bind` — is taken.** + +**Provenance note:** this clause was independently refuted first by **#7152**, which measured it and amended its own copy of the row. That measurement was re-derived against this branch and **agrees in every particular**, so this entry is a confirmation and an execution, not a competing ruling. Where the two PRs both touch the row text, #7152's amendment and this one say the same thing. + +**Why the absorption fails.** `host_api::user_identity` is 160 lines (3 traits, 2 newtypes, 1 DTO, 2 errors, 1 pure fn). Its sole production implementor is `ironclaw_extension_host::channel_identity_store::FilesystemChannelIdentityStore`; `ironclaw_reborn_identity` implements **none** of the three traits and consumes none of them. The move is layer-matrix-*legal* — `products → substrates` is in the allowed set, no exception needed — which is exactly why "is it legal" is the wrong test: because `ironclaw_reborn_identity` depends on `ironclaw_host_api` and not the reverse, relocating the ports would force `extension_host` to take a **new** crate dependency purely to name a port it implements, separating a port from its implementor and adding an edge in a restructure whose purpose is removing them. **Do not cite CHECKLIST finding 5 (`AdapterInstallationId`) as the blocker** — that blocks moving `user_identity` *up* into a contracts tier; moving *down* inverts the direction and the type comes for free. The real blocker is the new edge. + +**The ambiguity resolves as nominal.** `ironclaw_reborn_identity::identity_store` owns **principal identity** — it is the only user-minting path in the stack, and owns the profile and verified-email index, keyed on five path segments including `surface_kind`. `extension_host::channel_identity_store` owns **post-OAuth channel binding** — it never mints, keys on `(provider, provider_user_id)`, and fixes one tenant per instance. Neither subsumes the other; consolidating their durable records would migrate user-visible rows. What was owed is that the charters say so, now landed in `ironclaw_reborn_identity/CONTRACT.md` and mirrored in `channel_identity_store.rs`'s module doc. + +**What actually was duplicated, and is now gone.** `ExternalIdentityKey` and `RebornIdentityResolver::{lookup, bind}` had **zero production callers**, and the key was deliberately absent from the composition facade so downstream could not construct one — a third binding surface that never bound anything. Deleted under the un-masking discipline: roster 39 → 34, exactly the five tests that drove the removed methods, with `different_provider_instance_does_not_collide` repointed onto `resolve_or_create` and kept because its key axis is not part of the dead slice. #5618 and #5615 both close. Two consequences are recorded rather than buried: the retired `bind` was an upsert that re-pointed a key — the **opposite** of the shipped `ProviderIdentityAlreadyBound` contract, so no production semantic was lost — and it took the tenant per call, so a future multi-tenant channel binding must revisit the *channel store's* shape, not this crate's. + +**Filed, not fixed here:** `installation_scoped_provider_user_id` (`host_api/src/user_identity.rs:155`) flattens `(installation, actor)` into one `:`-joined string that reverse lookup matches with `starts_with` (`channel_identity_store.rs:558`), so an actor id containing `:`, or an installation id that is a prefix of another, can satisfy a prefix check it should not. The principal store avoids this by keeping the parts in separate path segments. Live property gap in the surviving store, surfaced by this measurement. + --- ## 13. Final validation checklist diff --git a/docs/reborn/target-architecture/explorer.html b/docs/reborn/target-architecture/explorer.html index efee8e4acde..68ed787e6cb 100644 --- a/docs/reborn/target-architecture/explorer.html +++ b/docs/reborn/target-architecture/explorer.html @@ -391,7 +391,7 @@

Getting there, in one line

const FAMILY_META = {"events": {"tag": "evidence, never authority — projections are rebuildable, streams never send.", "strip": [{"id": "event_log", "step": "record", "gloss": "redacted vocabulary"}, {"id": "event_store", "step": "persist", "gloss": "durable backends"}, {"id": "event_projections", "step": "derive", "gloss": "read models"}, {"id": "event_streams", "step": "deliver", "gloss": "authorized delivery"}]}, "kernel": {"bracket": [{"id": "turns", "gloss": "admit"}, {"id": "processes", "gloss": "lifecycle"}], "strip": [{"id": "trust", "gloss": "ceiling"}, {"id": "authorization", "gloss": "allow/deny"}, {"id": "approvals", "gloss": "consent"}, {"id": "resources", "gloss": "reserve"}, {"id": "runtime_policy", "gloss": "plan"}, {"id": "capabilities", "gloss": "seal"}, {"id": "host_runtime", "gloss": "execute"}]}, "extensions": {"strip": [{"id": "extension_contracts", "step": "vocabulary"}, {"id": "extension_registry", "step": "records"}, {"id": "extension_host", "step": "hosting"}, {"id": "extension_manager", "step": "management"}, {"id": "pkg_slack", "step": "packages", "gloss": "vendor code"}]}, "loop": {"ports": ["LoopCapabilityPort", "LoopModelPort", "LoopPromptPort", "LoopTranscriptPort", "LoopContextPort", "LoopInputPort", "LoopRunInfoPort", "LoopCancellationPort", "LoopCompactionPort", "LoopProgressPort", "LoopCheckpointPort"]}}; const MANIFESTS = {"pkg_github": {"src": "crates/ironclaw_first_party_extensions/assets/github/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"github\"\nname = \"GitHub\"\nversion = \"0.2.7\"\ndescription = \"GitHub repository, issue, pull request, search, branch, file, release, workflow, fork, and webhook capabilities.\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"wasm\"\nmodule = \"wasm/github_tool.wasm\"\n\n[[tools]]\norigin_gate_matrix = { loop_run = \"gated_unless_granted\", product = \"forbidden\", automation = \"forbidden\" }\nid = \"github.merge_pull_request\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\ndefault_permission = \"ask\"\n[[tools.credentials]]\nhandle = \"github_runtime_token\"\nvendor = \"github\"\naudience = { scheme = \"https\", host = \"api.github.com\" }\ninjection = { type = \"header\", name = \"authorization\", prefix = \"Bearer \" }\n# … 48 more [[tools]] (visibility, schema/prompt refs elided) — repos, issues, PRs + reviews, search, branches, files, releases, workflows; reads \"allow\", writes \"ask\"\n\n[auth.github]\nmethod = \"api_key\"\nfields = [ { handle = \"github_runtime_token\", label = \"Personal access token\", secret = true } ]\nvalidation = { method = \"GET\", url = \"https://api.github.com/user\", success_status = [200], inject = { handle = \"github_runtime_token\", type = \"header\", name = \"authorization\", prefix = \"Bearer \" } }", "stats": "49 tools · wasm runtime · auth: github (api_key) · 1 credential handle (github_runtime_token) · no channel", "provenance": "repo"}, "pkg_gmail": {"src": "crates/ironclaw_first_party_extensions/assets/gmail/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"gmail\"\nname = \"Gmail\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"first_party\"\nservice = \"gmail\"\n\n[[tools]]\nid = \"gmail.send_message\"\ndescription = \"Send a Gmail message as the user — a side effect inside the current job (the email comes from their account). …\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\ndefault_permission = \"ask\"\n# … origin_gate_matrix, network_targets (4 Google hosts), max_egress_bytes = 10485760 elided\n[[tools.credentials]]\nhandle = \"gmail_account\"\nscopes = [\"https://www.googleapis.com/auth/gmail.send\"]\ninjection = { type = \"header\", name = \"authorization\", prefix = \"Bearer \" }\n# … (vendor = \"google\", audience elided); 5 more [[tools]]: list_messages, get_message, create_draft, reply_to_message, trash_message\n\n[auth.google]\nmethod = \"oauth2_code\"\nauthorization_endpoint = \"https://accounts.google.com/o/oauth2/v2/auth\"\npkce = \"s256\"\nclient_credentials = { client_id_handle = \"google_oauth_client_id\", client_secret_handle = \"google_oauth_client_secret\" }\n# … token_endpoint, [admin_configuration] \"vendor.google\", union scopes, [auth.google.refresh] keepalive_idle_seconds = 604800, token_response pointers elided", "stats": "6 tools · first_party runtime (service \"gmail\") · auth: google (oauth2_code, shared provider) · per-tool gmail.* scopes · no channel", "provenance": "repo"}, "pkg_google": {"src": "crates/ironclaw_first_party_extensions/assets/google-drive/manifest.toml (+ google-calendar, google-docs, google-sheets, google-slides)", "toml": "# google-drive/manifest.toml — one of five Google-vendor extensions\nschema_version = \"reborn.extension_manifest.v3\"\nid = \"google-drive\"\nname = \"Google Drive\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"wasm\"\nmodule = \"wasm/google_drive_tool.wasm\"\n\n[admin_configuration]\ngroup_id = \"vendor.google\"\nfields = [\n { handle = \"google_oauth_client_id\", label = \"Google OAuth client ID\", secret = false, required = true },\n { handle = \"google_oauth_client_secret\", label = \"Google OAuth client secret\", secret = true, required = true },\n]\n\n[[tools]]\nid = \"google-drive.download_file\"\neffects = [\"network\", \"use_secret\"]\ndefault_permission = \"ask\"\n[[tools.credentials]]\nhandle = \"google_runtime_token\"\nscopes = [\"https://www.googleapis.com/auth/drive.readonly\"]\n# … vendor/audience/injection as in gmail; 11 more [[tools]]: list/get/upload/update, create_folder, share, permissions, trash/delete, shared drives — all \"ask\"\n# … [auth.google] oauth2_code — same shared recipe + keepalive refresh shown in the gmail excerpt\n\n# google-calendar/, google-docs/, google-sheets/, google-slides/ — same shape, same [auth.google]\n# (docs/sheets/slides ship their own wasm modules; google-calendar runs kind = \"first_party\")", "stats": "5 extensions · 57 tools (drive 12, calendar 9, docs 11, sheets 11, slides 14) · wasm ×4 + first_party (calendar) · one shared auth provider: google (oauth2_code) + vendor.google admin config · no channel", "provenance": "repo"}, "pkg_nearai": {"src": "crates/ironclaw_first_party_extensions/assets/nearai-mcp/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"nearai\"\nname = \"NEAR AI\"\ndescription = \"NEAR AI MCP tools for web search and hosted agent capabilities.\"\ntrust = \"first_party_requested\"\n\n[mcp]\norigin_gate_matrix = { loop_run = \"gated_unless_granted\", product = \"forbidden\", automation = \"forbidden\" }\nserver = \"https://private.near.ai/mcp\"\nnamespace = \"nearai\"\nmax_tools = 64\ndefault_permission = \"ask\"\neffects = [\"network\", \"use_secret\"]\n# The connection credential reuses the assistant's host-managed NEAR AI LLM key — no separate account setup.\n[[mcp.credentials]]\nhandle = \"llm_nearai_api_key\"\ninjection = { type = \"header\", name = \"authorization\", prefix = \"Bearer \" }\n\n# Statically pinned tool: model-visible from first boot; live tools/list discovery replaces the static set.\n[[tools]]\nid = \"nearai.web_search\"\ndescription = \"Search through the NEAR AI MCP server.\"\ndefault_permission = \"ask\"\n\n[auth.nearai]\nmethod = \"api_key\"\n# … vendor/scopes, gate matrix + schema/prompt refs, api-key field (llm_nearai_api_key) elided", "stats": "1 pinned tool + live MCP discovery (max_tools 64) · mcp runtime (private.near.ai) · auth: nearai (api_key, reuses host-managed LLM key) · no channel", "provenance": "repo"}, "pkg_notion": {"src": "crates/ironclaw_first_party_extensions/assets/notion-mcp/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"notion\"\nname = \"Notion\"\ndescription = \"Notion MCP tools for creating, searching, fetching, querying, and managing Notion workspace content.\"\ntrust = \"third_party\"\n\n[mcp]\norigin_gate_matrix = { loop_run = \"gated_unless_granted\", product = \"forbidden\", automation = \"forbidden\" }\nserver = \"https://mcp.notion.com/mcp\"\nnamespace = \"notion\"\nmax_tools = 256\ndefault_permission = \"ask\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\n[[mcp.credentials]]\nhandle = \"mcp_notion_access_token\"\ninjection = { type = \"header\", name = \"authorization\", prefix = \"Bearer \" }\n\n# Hosted-MCP Notion uses dynamic client registration (RFC 7591): the absence\n# of a client_credentials block declares exactly that.\n[auth.notion]\nmethod = \"oauth2_code\"\nauthorization_endpoint = \"https://mcp.notion.com/authorize\"\ntoken_endpoint = \"https://mcp.notion.com/token\"\n\n[auth.notion.refresh]\nrotates_refresh_token = true\n# … (credential vendor = \"notion\"); [auth.notion.token_response] JSON-pointer captures elided", "stats": "0 pinned tools — live MCP discovery (max_tools 256) · mcp runtime (mcp.notion.com) · trust: third_party (only one in the fleet) · auth: notion (oauth2_code, dynamic client registration, rotating refresh) · no channel", "provenance": "repo"}, "pkg_slack": {"src": "crates/ironclaw_first_party_extensions/assets/slack/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"slack\"\nname = \"Slack\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"wasm\"\nmodule = \"wasm/slack_user_tool.wasm\"\n# … [admin_configuration] \"extension.slack\" — 11 deployment handles (bot token, signing secret, team/app/bot ids, OAuth client, routing) elided\n[[tools]]\nid = \"slack.send_message\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\ndefault_permission = \"ask\"\n[[tools.credentials]]\nhandle = \"slack_user_token\"\n# … user scopes (search:read … chat:write) + 7 read-side [[tools]]: search_messages, list_conversations, get_conversation_info/_history, get_thread_replies, get_user_info, whoami\n\n[channel]\nid = \"messages\"\ninbound = true\noutbound = true\n[channel.ingress.verification]\nkind = \"hmac_sha256\"\nsecret_handle = \"slack_signing_secret\"\n[[channel.egress]]\nhost = \"slack.com\"\ncredential_handle = \"slack_bot_token\"\n\n[auth.slack]\nmethod = \"oauth2_code\"\nauthorization_endpoint = \"https://slack.com/oauth/v2/authorize\"\nscope_param = \"user_scope\"\n# … X-Slack-Signature header recipe, [channel.connection] OAuth pairing + notices, [channel.presentation], pkce, client_credentials handles, token_response/identity captures elided", "stats": "8 tools · wasm runtime · channel \"messages\" (inbound+outbound, hmac_sha256 ingress, bot-token egress) · auth: slack (oauth2_code user token) · dual credentials: slack_user_token (tools) / slack_bot_token (channel)", "provenance": "repo"}, "pkg_telegram": {"src": "crates/ironclaw_first_party_extensions/assets/telegram/manifest.toml", "toml": "# The Telegram messaging channel … a pure channel extension whose entire vendor\n# surface is one manifest plus the channel adapter crate. No tools, no WASM module.\nschema_version = \"reborn.extension_manifest.v3\"\nid = \"telegram\"\nname = \"Telegram\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"first_party\"\nservice = \"telegram.extension/v1\"\n# … [admin_configuration]: telegram_bot_token, telegram_webhook_secret, telegram_webhook_url, bot_username\n\n[channel]\nid = \"messages\"\ninbound = true\noutbound = true\n[channel.connection]\nstrategy = \"web_generated_code\"\ndeep_link_template = \"https://t.me/{bot_username}?start={code}\"\n# … inbound_code_prefixes = [\"/start\"], pairing instructions + notices elided\n[channel.ingress.verification]\nkind = \"shared_secret_header\"\nsecret_handle = \"telegram_webhook_secret\"\nheader = \"X-Telegram-Bot-Api-Secret-Token\"\n[[channel.egress]]\nhost = \"api.telegram.org\"\ninjection = { type = \"path_placeholder\", placeholder = \"telegram_bot_token\" }\n# … body_credentials (webhook secret → /secret_token) + [channel.presentation] (4096 chars, no markdown) elided; no [auth] recipe", "stats": "0 tools · first_party runtime · pure channel \"messages\" (inbound+outbound, shared_secret_header ingress, path_placeholder token egress) · web_generated_code pairing · no auth recipe", "provenance": "repo"}, "pkg_web_access": {"src": "crates/ironclaw_first_party_extensions/assets/web-access/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"web-access\"\nname = \"Web Access\"\ndescription = \"Zero-config web search through Exa MCP for Reborn.\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"first_party\"\nservice = \"web-access\"\n\n[[tools]]\norigin_gate_matrix = { loop_run = \"gated_unless_granted\", product = \"forbidden\", automation = \"forbidden\" }\nid = \"web-access.search\"\neffects = [\"network\"]\ndefault_permission = \"allow\"\n# Zero-config Exa MCP endpoint; no credential is injected. Egress is capped to\n# bound DoS exposure on the keyless path.\nnetwork_targets = [\n { scheme = \"https\", host_pattern = \"mcp.exa.ai\" },\n]\nmax_egress_bytes = 2097152\n# … second tool web-access.get_content (same shape) + descriptions/schema refs elided; no [auth] section", "stats": "2 tools · first_party runtime · keyless (no auth, no credentials) · egress pinned to mcp.exa.ai with 2 MiB cap · no channel", "provenance": "repo"}, "pkg_memory_native": {"src": "crates/ironclaw_host_runtime/assets/memory_native/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"ironclaw.memory\"\nname = \"Reborn Memory\"\ndescription = \"Host-bundled native memory backend (issue #3537). Provides the always-on memory adapter surface: …\"\ntrust = \"first_party_requested\"\n# reserved ironclaw.* id — accepted only for the loader-supplied HostBundled source\n\n[runtime]\nkind = \"first_party\"\nservice = \"native_memory_provider\"\n\n# The [memory] surface marks this extension a backend for the host memory adapter;\n# `lifecycle` declares the host-initiated hooks (an undeclared hook is never called).\n[memory]\nlifecycle = [\"read_long_term\", \"read_short_term\", \"record_interaction\", \"profile_read\"]\n\n[[tools]]\nid = \"ironclaw.memory.write\"\neffects = [\"read_filesystem\", \"write_filesystem\"]\ndefault_permission = \"allow\"\norigin_gate_matrix = { loop_run = \"gated_unless_granted\", product = \"forbidden\", automation = \"forbidden\" }\ninput_schema_ref = \"schemas/memory/document-write.input.v1.json\"\n# … 4 more [[tools]]: ironclaw.memory.read / .search / .tree / .profile_set — reads run loop_run = \"ungated\"\n# (host-bundled always-on lane: no [auth] section, no install step)", "stats": "5 tools (ironclaw.memory.*) · first_party runtime (native_memory_provider) · [memory] surface with 4 lifecycle hooks · host-bundled always-on · no auth, no channel", "provenance": "repo"}, "pkg_mem0": {"src": "crates/ironclaw_host_runtime/assets/memory_mem0/manifest.toml", "toml": "schema_version = \"reborn.extension_manifest.v3\"\nid = \"mem0.local.memory\"\nname = \"mem0 Memory\"\ndescription = \"Self-hosted mem0 OSS backend for the always-on memory adapter (issue #5264). …\"\ntrust = \"first_party_requested\"\n\n[runtime]\nkind = \"first_party\"\nservice = \"mem0_memory_provider\"\n\n# The lifecycle set is honest: mem0 implements the long-term retrieval lane and\n# profile reads; no short-term lane, no interaction recording — undeclared\n# hooks are never called by the host.\n[memory]\nlifecycle = [\"read_long_term\", \"profile_read\"]\n\n[[tools]]\nid = \"ironclaw.memory.read\"\neffects = [\"read_filesystem\"]\ndefault_permission = \"allow\"\norigin_gate_matrix = { loop_run = \"ungated\", product = \"forbidden\", automation = \"forbidden\" }\n# … 4 more [[tools]] under the same stable ironclaw.memory.* ids as the native backend\n# (write stays gated_unless_granted; a backend swap never renames the model's tools)\n# (off by default: compiled under the `memory-mem0` feature; requires a self-hosted base URL via the compose-time [memory] binding)", "stats": "5 tools (same stable ironclaw.memory.* ids) · first_party runtime (mem0_memory_provider) · [memory] surface with 2 lifecycle hooks · feature-gated (memory-mem0), off by default · no auth, no channel", "provenance": "repo"}}; const SEALED = {"host_api": ["Authorized", "TrustClass"], "extension_contracts": ["VerifiedInbound"], "loop_contracts": ["LoopExit"], "trust": ["EffectiveTrust"], "outbound": ["AccessGrant", "DeliveryBinding"]}; -const TYPES = {"host_api": [{"n": "Authorized", "k": "struct", "d": "sealed witness every privileged effect must carry", "was": null}, {"n": "CapabilityAuthorizer", "k": "trait", "d": "kernel-implemented port that mints Authorized", "was": null}, {"n": "CapabilityDispatcher", "k": "trait", "d": "the dispatch port; implemented by the kernel membrane", "was": null}, {"n": "RuntimeLane", "k": "enum", "d": "closed lane set an Authorized witness is bound to", "was": null}, {"n": "TrustClass", "k": "enum", "d": "trust vocabulary with serde-sealed privileged variants", "was": null}, {"n": "CapabilityDescriptor", "k": "struct", "d": "requested-effect shape flowing through every capability invocation", "was": null}, {"n": "MountView", "k": "struct", "d": "the mount-containment grant a mediated caller receives", "was": null}, {"n": "RuntimeHttpEgress", "k": "trait", "d": "host-mediated HTTP egress port", "was": null}, {"n": "IngressRouteDescriptor", "k": "struct", "d": "neutral ingress route vocabulary transports mount against", "was": null}, {"n": "TurnRunId", "k": "struct", "d": "canonical run identity in the turn vocabulary", "was": null}], "common": [{"n": "CredentialName", "k": "struct", "d": "backend secret identity newtype", "was": null}, {"n": "ExtensionName", "k": "struct", "d": "user-facing installed extension identity newtype", "was": null}, {"n": "McpServerName", "k": "struct", "d": "mcp server identity newtype", "was": null}, {"n": "ExternalThreadId", "k": "struct", "d": "external thread identity carrying the wire-compat exception", "was": null}, {"n": "AttachmentRef", "k": "struct", "d": "generic attachment reference, distinct from channel vendor refs", "was": null}, {"n": "AttachmentFormat", "k": "struct", "d": "attachment format and extractor vocabulary", "was": null}, {"n": "s256_challenge", "k": "fn", "d": "pkce code-challenge helper", "was": null}], "prompt_envelope": [{"n": "wrap_untrusted", "k": "fn", "d": "wraps untrusted snippets with trust markers before model exposure", "was": null}, {"n": "wrap_untrusted_with_limit", "k": "fn", "d": "bounded variant enforcing the envelope byte budget", "was": null}, {"n": "EnvelopeSource", "k": "enum", "d": "closed source vocabulary: memory, hook, skill", "was": null}, {"n": "EnvelopeTrust", "k": "enum", "d": "trusted/untrusted classification on enveloped content", "was": null}, {"n": "EnvelopedContent", "k": "struct", "d": "the wrapped, marker-fenced model-visible snippet", "was": null}], "loop_contracts": [{"n": "AgentLoopDriver", "k": "trait", "d": "the replaceable loop strategy a host drives", "was": null}, {"n": "AgentLoopDriverHost", "k": "trait", "d": "blanket host trait exposing all Loop*Port ports together", "was": null}, {"n": "LoopCapabilityPort", "k": "trait", "d": "capability port of the eleven-trait Loop*Port membrane", "was": null}, {"n": "LoopModelPort", "k": "trait", "d": "model port the loop calls instead of a gateway", "was": null}, {"n": "LoopTranscriptPort", "k": "trait", "d": "transcript access port for loop userland", "was": null}, {"n": "LoopExit", "k": "enum", "d": "the loop's exit claim; only the kernel validates it", "was": null}, {"n": "ResolvedRunProfile", "k": "struct", "d": "resolved run-profile snapshot a loop executes under", "was": null}, {"n": "RunProfileResolver", "k": "trait", "d": "resolves a profile request into a resolved profile", "was": null}, {"n": "CheckpointStateStorePort", "k": "trait", "d": "loop checkpoint persistence port", "was": null}, {"n": "AgentLoopHostError", "k": "struct", "d": "bounded loop-side host error vocabulary", "was": null}], "extension_contracts": [{"n": "ChannelAdapter", "k": "trait", "d": "per-package normalize, render, deliver, resolve channel trait", "was": null}, {"n": "ToolAdapter", "k": "trait", "d": "model-callable tool counterpart to ChannelAdapter", "was": null}, {"n": "ExtensionEntrypoint", "k": "trait", "d": "manifest-bound entrypoint every extension package exposes", "was": null}, {"n": "VerifiedInbound", "k": "struct", "d": "sealed inbound-verification evidence; minted by ingress verifier only", "was": null}, {"n": "NormalizedInboundMessage", "k": "struct", "d": "vendor-neutral normalized inbound message", "was": null}, {"n": "OutboundEnvelope", "k": "struct", "d": "outbound delivery envelope with typed parts", "was": null}, {"n": "ChannelDescriptor", "k": "struct", "d": "manifest channel-surface descriptor", "was": null}, {"n": "VendorAuthRecipe", "k": "enum", "d": "declarative auth recipe schema manifests compile into", "was": null}, {"n": "LifecyclePublicState", "k": "enum", "d": "caller-visible three-state lifecycle vocabulary", "was": null}, {"n": "VendorAttachmentRef", "k": "struct", "d": "channel-facing vendor attachment reference", "was": "AttachmentRef"}], "product_contracts": [{"n": "ProductSurface", "k": "trait", "d": "the single generic membrane every transport invokes", "was": null}, {"n": "BoundProductSurface", "k": "struct", "d": "caller-bound handle over the surface", "was": null}, {"n": "ProductSurfaceCaller", "k": "struct", "d": "caller identity and scope crossing the membrane", "was": null}, {"n": "ChannelInboundProductSurface", "k": "trait", "d": "channel-inbound admission port beside the membrane", "was": null}, {"n": "AppEvent", "k": "enum", "d": "the full product event wire enumeration transports stream", "was": null}, {"n": "ProductSurfaceCommandDescriptor", "k": "struct", "d": "the descriptor type concrete product commands instantiate", "was": null}, {"n": "ProductView", "k": "struct", "d": "the descriptor type concrete product views instantiate", "was": null}, {"n": "ChannelDeliveryResolver", "k": "trait", "d": "delivery-resolution port implemented beside the extension host", "was": null}, {"n": "LifecycleProductService", "k": "trait", "d": "extension lifecycle product service port", "was": null}, {"n": "LlmConfigService", "k": "trait", "d": "operator LLM-config port implemented by operator", "was": null}], "filesystem": [{"n": "RootFilesystem", "k": "trait", "d": "the universal storage-dispatch trait all backends implement", "was": null}, {"n": "ScopedFilesystem", "k": "struct", "d": "mount-checked caller view over the root trait", "was": null}, {"n": "CompositeRootFilesystem", "k": "struct", "d": "mount-catalog routing across multiple backends", "was": null}, {"n": "MountDescriptor", "k": "struct", "d": "mount catalog entry describing path placement", "was": null}, {"n": "cas_update", "k": "fn", "d": "bounded-retry compare-and-swap floor for durable records", "was": null}, {"n": "Entry", "k": "struct", "d": "versioned record entry vocabulary", "was": null}, {"n": "CasExpectation", "k": "enum", "d": "compare-and-swap precondition vocabulary", "was": null}, {"n": "IndexSpec", "k": "struct", "d": "secondary index specification", "was": null}, {"n": "PostgresRootFilesystem", "k": "struct", "d": "durable postgres backend", "was": null}, {"n": "DiskFilesystem", "k": "struct", "d": "local disk backend", "was": null}], "secrets": [{"n": "SecretStorePort", "k": "trait", "d": "lease-once/consume one-shot custody port", "was": null}, {"n": "SecretStore", "k": "struct", "d": "generic store implementation over the filesystem fabric", "was": null}, {"n": "CredentialBroker", "k": "struct", "d": "credential broker built on the store", "was": null}, {"n": "CredentialAccountStore", "k": "trait", "d": "credential account store port", "was": null}, {"n": "CredentialSessionStore", "k": "trait", "d": "credential session store port", "was": null}, {"n": "SecretLease", "k": "struct", "d": "one-shot lease handle; raw material readable once", "was": null}], "network": [{"n": "NetworkHttpEgress", "k": "trait", "d": "the outbound HTTP egress port", "was": null}, {"n": "PolicyNetworkHttpEgress", "k": "struct", "d": "policy-checked egress implementation", "was": null}, {"n": "NetworkHttpTransport", "k": "trait", "d": "transport port beneath the egress policy", "was": null}, {"n": "ReqwestNetworkTransport", "k": "struct", "d": "pinned hardened production transport", "was": null}, {"n": "NetworkResolver", "k": "trait", "d": "DNS resolution port", "was": null}, {"n": "SystemNetworkResolver", "k": "struct", "d": "resolver denying private and reserved addresses", "was": null}, {"n": "StaticNetworkPolicyEnforcer", "k": "struct", "d": "target/method policy matcher before any call", "was": null}], "safety": [{"n": "SafetyLayer", "k": "struct", "d": "unified sanitize, validate, leak-scan composition", "was": null}, {"n": "Sanitizer", "k": "struct", "d": "injection-pattern scanner over untrusted text", "was": null}, {"n": "Validator", "k": "struct", "d": "structural and size validation for provider-bound content", "was": null}, {"n": "LeakDetector", "k": "struct", "d": "credential-material leak scanner at trust boundaries", "was": null}, {"n": "InjectionScanner", "k": "trait", "d": "focused injection-scan interface", "was": null}, {"n": "LeakScanner", "k": "trait", "d": "focused leak-scan interface", "was": null}, {"n": "is_sensitive_path", "k": "fn", "d": "sensitive-path predicate filesystem redaction depends on", "was": null}], "observability": [{"n": "live_latency_trace", "k": "macro", "d": "zero-cost-when-off latency trace", "was": null}, {"n": "live_latency_trace_ok", "k": "macro", "d": "latency trace recording success outcome", "was": null}, {"n": "live_latency_trace_error", "k": "macro", "d": "latency trace recording error outcome", "was": null}, {"n": "elapsed_ms", "k": "fn", "d": "elapsed-time helper backing the macros", "was": null}, {"n": "live_latency_enabled", "k": "fn", "d": "target-enabled check the macros gate on", "was": null}], "event_log": [{"n": "RuntimeEvent", "k": "struct", "d": "redacted runtime event evidence shape", "was": null}, {"n": "RuntimeEventKind", "k": "enum", "d": "bounded event kind classification", "was": null}, {"n": "SecurityAuditEvent", "k": "struct", "d": "redacted security audit envelope", "was": null}, {"n": "EventCursor", "k": "struct", "d": "monotonic per-stream replay cursor", "was": null}, {"n": "EventSink", "k": "trait", "d": "best-effort sink; failures never alter outcomes", "was": null}, {"n": "AuditSink", "k": "trait", "d": "best-effort audit sink counterpart", "was": null}, {"n": "DurableEventLog", "k": "trait", "d": "explicit-error durable append and cursor-replay log", "was": null}, {"n": "DurableAuditLog", "k": "trait", "d": "durable audit log counterpart", "was": null}, {"n": "InMemoryDurableEventLog", "k": "struct", "d": "in-memory reference implementation", "was": null}], "event_store": [{"n": "EventStoreConfig", "k": "enum", "d": "backend selection with fail-closed production validation", "was": "RebornEventStoreConfig"}, {"n": "EventStores", "k": "struct", "d": "paired durable event and audit log handles", "was": "RebornEventStores"}, {"n": "EventStoreProfile", "k": "enum", "d": "deployment profile governing which fallbacks are legal", "was": "RebornProfile"}, {"n": "build_event_stores_from_root_filesystem", "k": "fn", "d": "the backend-selection entry point", "was": "build_reborn_event_stores_from_root_filesystem"}, {"n": "FilesystemDurableEventLog", "k": "struct", "d": "durable log adapter over the storage fabric", "was": null}, {"n": "FilesystemDurableAuditLog", "k": "struct", "d": "audit log adapter over the storage fabric", "was": null}, {"n": "JsonlDurableEventLog", "k": "struct", "d": "single-node durable JSONL backend", "was": null}, {"n": "CoalescingEventSink", "k": "struct", "d": "coalescing sink for high-frequency producers", "was": null}], "event_projections": [{"n": "EventProjectionService", "k": "trait", "d": "scoped replay-derived event read-model service", "was": null}, {"n": "AuditProjectionService", "k": "trait", "d": "audit-side projection service", "was": null}, {"n": "ReplayEventProjectionService", "k": "struct", "d": "replay-folding implementation of the event service", "was": null}, {"n": "ReplayAuditProjectionService", "k": "struct", "d": "replay-folding implementation of the audit service", "was": null}, {"n": "ThreadTimeline", "k": "struct", "d": "thread timeline read model", "was": null}, {"n": "RunStatusProjection", "k": "struct", "d": "run status read model", "was": null}, {"n": "CapabilityActivityProjection", "k": "struct", "d": "capability activity read model", "was": null}, {"n": "ProjectionScope", "k": "struct", "d": "tenant, actor, read-scope authorization vocabulary", "was": null}, {"n": "ProjectionCursor", "k": "struct", "d": "cursor with rebase semantics for incremental replay", "was": null}], "event_streams": [{"n": "EventStreamManager", "k": "struct", "d": "transport-neutral stream manager over injected collaborators", "was": null}, {"n": "ProjectionAccessPolicy", "k": "trait", "d": "actor, scope, view, target authorization check", "was": null}, {"n": "ProjectionStreamAdmissionPolicy", "k": "trait", "d": "subscription admission control port", "was": null}, {"n": "ProjectionStreamAdmissionPermit", "k": "struct", "d": "RAII admission permit releasing its slot on drop", "was": null}, {"n": "ProjectionUpdateSource", "k": "trait", "d": "live-update source port", "was": null}, {"n": "ProjectionRedactionValidator", "k": "trait", "d": "fail-closed redaction validation before delivery", "was": null}, {"n": "ProjectionSubscribeRequest", "k": "struct", "d": "subscription request vocabulary", "was": null}, {"n": "ProjectionStreamItem", "k": "enum", "d": "stitched live and replay stream item", "was": null}], "wasm": [{"n": "WitToolRuntime", "k": "struct", "d": "component loading, validation, metering, execution runtime", "was": null}, {"n": "WitToolRuntimeConfig", "k": "struct", "d": "fuel, epoch, memory, table limit configuration", "was": null}, {"n": "WasmHostHttp", "k": "trait", "d": "host-import HTTP capability; deny-by-default implementation shipped", "was": null}, {"n": "WasmHostWorkspace", "k": "trait", "d": "host-import workspace capability, deny-by-default", "was": null}, {"n": "WasmHostSecrets", "k": "trait", "d": "host-import secrets capability, deny-by-default", "was": null}, {"n": "WasmHostTools", "k": "trait", "d": "host-import tool-invocation capability, deny-by-default", "was": null}, {"n": "WasmHostClock", "k": "trait", "d": "host-import clock capability, deny-by-default", "was": null}, {"n": "SandboxLimits", "k": "struct", "d": "domain-free WASM sandbox limit primitives", "was": null}, {"n": "SandboxStoreCore", "k": "struct", "d": "shared store core other WASM hosts reuse", "was": null}], "wasm_limiter": [{"n": "WasmResourceLimiter", "k": "struct", "d": "the shared wasmtime resource limiter both hosts wire", "was": null}], "mcp": [{"n": "McpRuntime", "k": "struct", "d": "the MCP lane runtime over a generic client", "was": null}, {"n": "McpRuntimeConfig", "k": "struct", "d": "runtime configuration composition wires", "was": null}, {"n": "McpClient", "k": "trait", "d": "JSON-RPC client port", "was": null}, {"n": "McpHostHttp", "k": "trait", "d": "host-mediated HTTP port; no lane-owned client", "was": null}, {"n": "McpHostHttpEgressPlanner", "k": "trait", "d": "plans egress before any outbound call", "was": null}, {"n": "McpHostHttpClient", "k": "struct", "d": "client over injected host HTTP and planner", "was": null}, {"n": "McpExecutor", "k": "trait", "d": "execution port the kernel invokes", "was": null}], "sandbox": [{"n": "SandboxProcessPlan", "k": "struct", "d": "typed two-phase plan for a sandboxed process invocation", "was": null}, {"n": "ValidatedSandboxProcessPlan", "k": "struct", "d": "validation witness the transport alone accepts", "was": null}, {"n": "ScopedSandboxCommandTransport", "k": "struct", "d": "container-backed implementation of the kernel transport port", "was": "RebornScopedSandboxCommandTransport"}, {"n": "SandboxConfig", "k": "struct", "d": "lane configuration for the container backend", "was": "RebornSandboxConfig"}, {"n": "SandboxCertificateAuthority", "k": "struct", "d": "per-tenant CA; root key never leaves memory", "was": null}, {"n": "SandboxCredentialFirewall", "k": "struct", "d": "staged one-shot credential obligation chokepoint", "was": null}, {"n": "SandboxNetworkBroker", "k": "struct", "d": "host-mediated egress brokering for containers", "was": "RebornSandboxNetworkBroker"}, {"n": "SandboxSecretBroker", "k": "struct", "d": "host-mediated secret brokering for containers", "was": "RebornSandboxSecretBroker"}, {"n": "SandboxContainerIdentity", "k": "struct", "d": "per-tenant container identity", "was": "RebornSandboxContainerIdentity"}, {"n": "SandboxCredentialBinding", "k": "struct", "d": "typed credential binding in the plan vocabulary", "was": null}], "threads": [{"n": "SessionThreadService", "k": "trait", "d": "append, finalize, and read canonical transcripts", "was": null}, {"n": "FilesystemSessionThreadService", "k": "struct", "d": "durable transcript service over ScopedFilesystem", "was": null}, {"n": "InMemorySessionThreadService", "k": "struct", "d": "deterministic in-memory transcript service for tests", "was": null}, {"n": "SessionThreadRecord", "k": "struct", "d": "canonical session-thread record", "was": null}, {"n": "ThreadMessageRecord", "k": "struct", "d": "canonical transcript message record", "was": null}, {"n": "ThreadScope", "k": "struct", "d": "tenant/user/agent scope key for thread isolation", "was": null}, {"n": "ToolResultReferenceEnvelope", "k": "struct", "d": "tool-result reference stored inside the transcript", "was": null}, {"n": "SummaryArtifact", "k": "struct", "d": "presentation summary projected from transcript, not second truth", "was": null}], "conversations": [{"n": "InboundConversationService", "k": "trait", "d": "accepts inbound messages with idempotent turn submission", "was": "SessionThreadService"}, {"n": "ConversationBindingService", "k": "trait", "d": "resolves external conversation refs to canonical bindings", "was": null}, {"n": "ConversationActorPairingService", "k": "trait", "d": "pairs/unpairs external actors to canonical users", "was": null}, {"n": "ConversationStateStore", "k": "struct", "d": "durable conversation-state store over ScopedFilesystem", "was": null}, {"n": "InboundTurnService", "k": "struct", "d": "production inbound resolver submitting to the turn coordinator", "was": null}, {"n": "FilesystemConversationServices", "k": "struct", "d": "bundle wiring the filesystem-backed conversation services", "was": "RebornFilesystemConversationServices"}, {"n": "InMemoryConversationServices", "k": "struct", "d": "real in-memory services used inside tests", "was": null}, {"n": "ExternalActorRef", "k": "struct", "d": "external actor identity; unify with host_api twin", "was": null}], "triggers": [{"n": "TriggerRepository", "k": "trait", "d": "trigger record and fire persistence port", "was": null}, {"n": "TriggerRecord", "k": "struct", "d": "scheduled-trigger record grammar with validation", "was": null}, {"n": "TriggerSchedule", "k": "enum", "d": "cron/interval schedule with timezone validation", "was": null}, {"n": "TriggerFireIdentity", "k": "struct", "d": "deterministic fire identity for idempotent submission", "was": null}, {"n": "TriggerPollerWorker", "k": "struct", "d": "per-tick due-fire evaluation via tick_once", "was": null}, {"n": "TriggerTrustedInboundBinding", "k": "struct", "d": "host-trusted submission binding for poller fires", "was": null}, {"n": "TrustedTriggerFireSubmitter", "k": "trait", "d": "submitter port carrying sealed trusted fires inbound", "was": null}, {"n": "TriggerPromptMaterializer", "k": "trait", "d": "materializer port turning fires into prompts", "was": null}, {"n": "TriggerActiveRunLookup", "k": "trait", "d": "state-lookup port for active-run hold decisions", "was": null}], "memory": [{"n": "MemoryService", "k": "trait", "d": "provider-neutral memory contract every provider implements", "was": null}, {"n": "MemoryDocumentScope", "k": "struct", "d": "tenant/user/agent/project scope for memory documents", "was": null}, {"n": "MemoryDocumentPath", "k": "struct", "d": "validated memory document path value type", "was": null}, {"n": "PromptWriteSafetyPolicy", "k": "trait", "d": "gate providers enforce before model-authored memory writes", "was": null}, {"n": "PromptProtectedPathRegistry", "k": "struct", "d": "protected-path classes for prompt write safety", "was": null}, {"n": "MemorySignificantEventSink", "k": "trait", "d": "audit sink for significant memory events", "was": null}, {"n": "MemorySignificantEvent", "k": "struct", "d": "significant-event audit record for memory writes", "was": null}, {"n": "memory_service_contract_full", "k": "macro", "d": "conformance suite each provider wires and must pass", "was": null}], "skills": [{"n": "SkillInferencePort", "k": "trait", "d": "learning inversion port implemented by hosting tier", "was": null}, {"n": "LoadedSkill", "k": "struct", "d": "parsed, validated skill with compiled activation patterns", "was": null}, {"n": "SkillManifest", "k": "struct", "d": "skill grammar: metadata, activation criteria, requirements", "was": null}, {"n": "parse_skill_md", "k": "fn", "d": "parses SKILL.md content into a skill", "was": null}, {"n": "ScopedSkillManagementPort", "k": "struct", "d": "filesystem-backed scoped skill install/remove management", "was": null}, {"n": "SelectionOutcome", "k": "struct", "d": "deterministic selection-scoring result under token budget", "was": null}, {"n": "SkillTrust", "k": "enum", "d": "trusted versus installed skill trust level", "was": null}, {"n": "SkillActivationObserver", "k": "trait", "d": "activation observer; moves in from first_party_extension_ports", "was": null}], "auth": [{"n": "AuthEngine", "k": "struct", "d": "recipe-driven token exchange, refresh, and client registration", "was": null}, {"n": "AuthRecipeResolver", "k": "trait", "d": "resolves vendor auth recipes; extension host implements", "was": null}, {"n": "AuthFlowManager", "k": "trait", "d": "durable auth-flow lifecycle contract", "was": null}, {"n": "CredentialAccountService", "k": "trait", "d": "credential-account records and lifecycle contract", "was": null}, {"n": "AuthInteractionService", "k": "trait", "d": "pending auth interaction contract for product surfaces", "was": null}, {"n": "SecretCleanupService", "k": "trait", "d": "credential cleanup contract on removal", "was": null}, {"n": "ProductAuthServices", "k": "struct", "d": "caller-facing product-auth service bundle", "was": "RebornProductAuthServices"}, {"n": "RuntimeCredentialAccountSelectionService", "k": "trait", "d": "runtime credential selection with refresh locking", "was": null}, {"n": "AuthContinuationDispatcher", "k": "trait", "d": "resumes gated work after auth completes", "was": "RebornAuthContinuationDispatcher"}, {"n": "ManualTokenFlowService", "k": "trait", "d": "manual token setup/submit flow contract", "was": "RebornManualTokenFlowService"}], "attachments": [{"n": "AttachmentLanding", "k": "struct", "d": "channel-agnostic routine landing attachment bytes into storage", "was": null}, {"n": "InboundAttachment", "k": "struct", "d": "normalized inbound attachment an adapter hands over", "was": null}, {"n": "InboundAttachmentLander", "k": "trait", "d": "landing port; moves in from ironclaw_product", "was": null}, {"n": "InboundAttachmentReader", "k": "trait", "d": "read-back port; moves in from ironclaw_product", "was": null}, {"n": "attachment_scoped_path", "k": "fn", "d": "scoped virtual path attachments land under", "was": null}], "extractors": [{"n": "extract_document", "k": "fn", "d": "typed MIME-dispatch extraction entry point", "was": null}, {"n": "DocumentExtraction", "k": "enum", "d": "structured extraction outcome replacing stringly errors", "was": null}, {"n": "extract_document_text_by_filename", "k": "fn", "d": "extension-based dispatch fallback", "was": null}, {"n": "truncate_to_chars", "k": "fn", "d": "char-safe truncation helper for extracted text", "was": null}], "identity": [{"n": "IdentityResolver", "k": "trait", "d": "mints, links, or looks up stable user identity", "was": "RebornIdentityResolver"}, {"n": "UserDirectory", "k": "trait", "d": "administrative user enumeration kept apart from minting", "was": "RebornUserDirectory"}, {"n": "IdentityStore", "k": "struct", "d": "filesystem-backed identity binding and profile store", "was": "RebornIdentityStore"}, {"n": "ExternalIdentityKey", "k": "struct", "d": "tenant/surface/provider/subject key for resolution", "was": null}, {"n": "ResolveExternalIdentity", "k": "struct", "d": "resolution request; channel actors barred from minting", "was": null}, {"n": "SurfaceKind", "k": "enum", "d": "browser-OAuth versus channel surface; gates minting rights", "was": null}, {"n": "UserRecord", "k": "struct", "d": "minimal durable user profile", "was": "RebornUser"}, {"n": "UserIdentityBindingStore", "k": "trait", "d": "binding store port absorbed from host_api::user_identity", "was": "RebornUserIdentityBindingStore"}, {"n": "ProjectRepository", "k": "trait", "d": "project and membership persistence contract", "was": null}, {"n": "ProjectMemberRecord", "k": "struct", "d": "membership record backing the access ACL", "was": null}], "llm": [{"n": "LlmProvider", "k": "trait", "d": "the model-provider contract; one adapter per vendor", "was": null}, {"n": "CompletionRequest", "k": "struct", "d": "provider-neutral completion request shape", "was": null}, {"n": "LlmError", "k": "enum", "d": "shared contract error every provider maps into", "was": null}, {"n": "ProviderRegistry", "k": "struct", "d": "provider registry and selection layer", "was": null}, {"n": "RetryProvider", "k": "struct", "d": "retry reliability decorator around any provider", "was": null}, {"n": "FailoverProvider", "k": "struct", "d": "failover decorator with cooldown", "was": null}, {"n": "CircuitBreakerProvider", "k": "struct", "d": "circuit-breaking decorator", "was": null}, {"n": "HttpInterceptor", "k": "trait", "d": "recording seam capturing provider HTTP exchanges", "was": null}, {"n": "TraceFile", "k": "struct", "d": "recording vocabulary reused by trace_commons", "was": null}], "trace_commons": [{"n": "TraceClientHost", "k": "struct", "d": "host-facing Trace Commons client", "was": null}, {"n": "TraceContributionEnvelope", "k": "struct", "d": "contribution envelope schema for external submission", "was": null}, {"n": "redact_sensitive_json", "k": "fn", "d": "deterministic redaction before anything leaves the process", "was": null}, {"n": "StandingTraceContributionPolicy", "k": "struct", "d": "standing consent and contribution policy", "was": null}, {"n": "ContributionHttpSink", "k": "trait", "d": "HTTP submission seam for the external service", "was": null}, {"n": "PrivacyFilterAdapter", "k": "trait", "d": "pluggable privacy-filter sidecar seam", "was": null}, {"n": "TraceQueueHold", "k": "struct", "d": "submission-queue hold record", "was": null}, {"n": "TraceCreditEvent", "k": "struct", "d": "credits ledger event for accepted contributions", "was": null}, {"n": "DeviceKeypair", "k": "struct", "d": "device-key onboarding identity material", "was": null}], "outbound": [{"n": "OutboundPolicyService", "k": "struct", "d": "sole minter of the crate's sealed trust types", "was": null}, {"n": "ThreadProjectionAccessGrant", "k": "struct", "d": "sealed watch-authorization grant; pub(crate) construction verified", "was": null}, {"n": "ValidatedReplyTargetBinding", "k": "struct", "d": "sealed push binding preventing validator target substitution", "was": null}, {"n": "OutboundDeliveryAttempt", "k": "struct", "d": "at-most-once attempt; CAS Prepared to Sending", "was": null}, {"n": "OutboundStateStorePort", "k": "trait", "d": "outbound durable state-store port", "was": null}, {"n": "OutboundStateStore", "k": "struct", "d": "filesystem-backed state store implementation", "was": null}, {"n": "ThreadProjectionAccessPolicy", "k": "trait", "d": "untrusted policy returning claims, never grants", "was": null}, {"n": "CommunicationDeliveryResolution", "k": "enum", "d": "resolution-engine output turning intent into targets", "was": null}, {"n": "CommunicationPreferenceRepository", "k": "trait", "d": "notification opt-in preference records", "was": null}], "trust": [{"n": "EffectiveTrustClass", "k": "struct", "d": "sealed ceiling; privileged variants crate-private, no Deserialize", "was": null}, {"n": "TrustPolicy", "k": "trait", "d": "requested-to-effective trust evaluation contract", "was": null}, {"n": "HostTrustPolicy", "k": "struct", "d": "layered policy engine over policy sources", "was": null}, {"n": "TrustDecision", "k": "struct", "d": "policy-validated decision authorization consumes", "was": null}, {"n": "AuthorityCeiling", "k": "struct", "d": "resource and sandbox ceiling bound to trust", "was": null}, {"n": "PolicySource", "k": "trait", "d": "layered source seam: bundled, admin, signer", "was": null}, {"n": "InvalidationBus", "k": "struct", "d": "synchronous invalidation before superseded-ceiling side effects", "was": null}, {"n": "TrustChangeListener", "k": "trait", "d": "downgrade/upgrade notification contract", "was": null}], "authorization": [{"n": "GrantAuthorizer", "k": "struct", "d": "default-deny grant matching under the trust ceiling", "was": null}, {"n": "LeaseBackedAuthorizer", "k": "struct", "d": "authorizer honoring fingerprinted leases on resume", "was": null}, {"n": "CapabilityLease", "k": "struct", "d": "fingerprinted lease; open struct, seal is contract-level", "was": null}, {"n": "CapabilityLeaseStatus", "k": "enum", "d": "single-winner claim/dispatch transitions callers coordinate through", "was": null}, {"n": "CapabilityLeaseStorePort", "k": "trait", "d": "lease persistence port", "was": null}, {"n": "CapabilityLeaseStore", "k": "struct", "d": "filesystem-backed lease store", "was": null}, {"n": "CapabilityDispatchAuthorizer", "k": "trait", "d": "authorization decision contract the membrane calls", "was": null}, {"n": "TrustAwareCapabilityDispatchAuthorizer", "k": "trait", "d": "decision contract consuming the trust decision", "was": null}], "approvals": [{"n": "ApprovalResolver", "k": "struct", "d": "records decision durably, then issues lease, fail-closed", "was": null}, {"n": "LeaseApproval", "k": "struct", "d": "approve outcome handing a fingerprinted lease", "was": null}, {"n": "DenyApproval", "k": "struct", "d": "durable, final denial for that request", "was": null}, {"n": "PersistentApprovalPolicyStore", "k": "struct", "d": "scope-bounded always-allow policy store", "was": null}, {"n": "AutoApproveSettingStore", "k": "struct", "d": "reusable-approval settings distinct from one-shot leases", "was": null}, {"n": "CapabilityPermissionOverrideStorePort", "k": "trait", "d": "permission-override store port", "was": null}, {"n": "ApprovalRequestStore", "k": "struct", "d": "approval-request records \u2014 the durable half of consent, owned here", "was": null}, {"n": "GateRecordStore", "k": "struct", "d": "gate records for blocked invocations, resolved by this crate alone", "was": null}], "resources": [{"n": "ResourceGovernor", "k": "trait", "d": "reserve, reconcile-or-release protocol; three production impls", "was": null}, {"n": "InMemoryResourceGovernor", "k": "struct", "d": "in-memory governor implementation", "was": null}, {"n": "PersistentResourceGovernor", "k": "struct", "d": "durable governor over a store port", "was": null}, {"n": "FilesystemResourceGovernor", "k": "struct", "d": "ScopedFilesystem-backed governor", "was": null}, {"n": "ResourceLimits", "k": "struct", "d": "budget dimensions: cost, tokens, wall-clock, egress, concurrency", "was": null}, {"n": "ReservationOutcome", "k": "struct", "d": "reservation receipt closing estimate-versus-actual loop", "was": null}, {"n": "BudgetApprovalGate", "k": "struct", "d": "pause-threshold gate, deliberately distinct from capability approval", "was": null}, {"n": "BudgetEventSink", "k": "trait", "d": "budget event emission seam", "was": null}], "runtime_policy": [{"n": "resolve", "k": "fn", "d": "pure (mode, profile, org policy) to EffectiveRuntimePolicy", "was": null}, {"n": "plan_capability", "k": "fn", "d": "per-capability lane planning inside authorize reach", "was": null}, {"n": "ExecutionPlan", "k": "struct", "d": "selected lane and enforcement posture", "was": null}, {"n": "ResolveRequest", "k": "struct", "d": "resolution input; monotone authority reduction only", "was": null}, {"n": "OrgPolicyConstraints", "k": "struct", "d": "organization ceiling constraints", "was": null}], "capabilities": [{"n": "CapabilityHost", "k": "struct", "d": "the membrane; six workflows minting host_api::Authorized", "was": null}, {"n": "CapabilityObligationHandler", "k": "trait", "d": "obligation seam preparing mounts and reservations", "was": null}, {"n": "RuntimeDispatcher", "k": "struct", "d": "sole CapabilityDispatcher impl; rejects witness-lane mismatch", "was": null}, {"n": "CapabilityDispatchRegistry", "k": "struct", "d": "capability registration and binding resolution", "was": null}, {"n": "ReplayPayloadStore", "k": "struct", "d": "durable replay payloads for resumed invocations", "was": null}, {"n": "ProcessAuthorizationRemintPort", "k": "trait", "d": "re-mints authorization for background process resume", "was": null}, {"n": "ToolResolver", "k": "trait", "d": "resolves invocation to a bound capability", "was": null}, {"n": "HostPolicyFacts", "k": "trait", "d": "policy facts the fold consults", "was": null}], "processes": [{"n": "ProcessSupervisor", "k": "struct", "d": "journal supervisor: claim, lease, heartbeat, recover, contain", "was": null}, {"n": "ProcessKind", "k": "enum", "d": "registered kinds: an executor registers against one", "was": null}, {"n": "ProcessExecutor", "k": "trait", "d": "executor port a registering crate implements", "was": null}, {"n": "ProcessStorePort", "k": "trait", "d": "durable process record store port", "was": null}, {"n": "ProcessStore", "k": "struct", "d": "filesystem-backed process store", "was": null}, {"n": "ProcessRecord", "k": "struct", "d": "process identity, lineage, and status record", "was": null}, {"n": "ProcessStatus", "k": "enum", "d": "lifecycle states; terminal written once", "was": null}, {"n": "BackgroundProcessManager", "k": "struct", "d": "background-capability lifecycle over the journal", "was": null}, {"n": "ProcessHost", "k": "struct", "d": "caller-facing process spawn/track service", "was": null}], "turns": [{"n": "TurnCoordinator", "k": "trait", "d": "accept/resume/cancel; one active run per thread", "was": null}, {"n": "DefaultTurnCoordinator", "k": "struct", "d": "production coordinator implementation", "was": null}, {"n": "LoopExitApplier", "k": "struct", "d": "validates loop exit claims before durable truth", "was": null}, {"n": "LoopExitEvidencePort", "k": "trait", "d": "exit-evidence port; spec's ExitEvidencePort name not in code", "was": null}, {"n": "TurnStateRowStore", "k": "struct", "d": "durable turn state rows; becomes process-journal projection", "was": null}, {"n": "TurnStatus", "k": "enum", "d": "turn lifecycle including blocked-on-gate states", "was": null}, {"n": "SubmitTurnRequest", "k": "struct", "d": "admission request with idempotency key", "was": null}], "host_runtime": [{"n": "HostRuntime", "k": "trait", "d": "kernel-services port upper tiers consume", "was": null}, {"n": "DefaultHostRuntime", "k": "struct", "d": "production kernel service graph and membrane composition", "was": null}, {"n": "BuiltinObligationHandler", "k": "struct", "d": "audit, staging, mount, ceiling, redaction obligations engine", "was": null}, {"n": "RuntimeLaneExecutor", "k": "struct", "d": "closed lane executor; deliberately crate-private (pub(super))", "was": null}, {"n": "HostHttpEgressService", "k": "struct", "d": "mediated egress: policy, secret staging, sanitize", "was": null}, {"n": "RuntimeSecretMaterialStager", "k": "struct", "d": "one-shot secret staging and consumption", "was": null}, {"n": "InvocationServices", "k": "struct", "d": "per-invocation mediated service set", "was": null}, {"n": "RuntimeProcessPort", "k": "trait", "d": "process-lane execution port", "was": null}, {"n": "MemoryServiceResolver", "k": "struct", "d": "provider-neutral memory service resolution from assembly", "was": null}], "agent_loop": [{"n": "CanonicalAgentLoopExecutor", "k": "struct", "d": "canonical sealed executor with ordered lifecycle stages", "was": null}, {"n": "AgentLoopExecutor", "k": "trait", "d": "executor contract the planned driver invokes", "was": null}, {"n": "AgentLoopPlanner", "k": "trait", "d": "sealed planner deciding a turn's next action", "was": null}, {"n": "LoopFamilyRegistry", "k": "struct", "d": "loop-family identity and strategy registry", "was": null}, {"n": "LoopFamily", "k": "struct", "d": "one named, sealed strategy composition", "was": null}, {"n": "LoopExecutionState", "k": "struct", "d": "resumable state: refs, cursors, counters only", "was": null}, {"n": "DefaultModelStrategy", "k": "struct", "d": "exemplar of the built-in Default* strategy set", "was": null}], "loop_host": [{"n": "HostRuntimeLoopCapabilityPort", "k": "struct", "d": "base kernel-facing LoopCapabilityPort adapter", "was": null}, {"n": "ThreadBackedLoopContextPort", "k": "struct", "d": "thread-backed context port adapter", "was": null}, {"n": "ThreadBackedLoopModelPort", "k": "struct", "d": "thread-backed model port adapter", "was": null}, {"n": "CheckpointStateStore", "k": "struct", "d": "checkpoint-state store behind the checkpoint port", "was": null}, {"n": "GovernorBackedAccountant", "k": "struct", "d": "budget accountant over the resource governor", "was": null}, {"n": "HostInputQueue", "k": "trait", "d": "input-queue seam behind the input port", "was": null}, {"n": "HostManagedModelGateway", "k": "trait", "d": "model-gateway port over host-managed routes", "was": null}, {"n": "SubagentSpawnCapabilityPort", "k": "struct", "d": "subagent-spawn port implementation", "was": null}], "turn_runner": [{"n": "AgentTurnExecutor", "k": "struct", "d": "the ProcessKind::AgentTurn executor; submits claimed exits", "was": "RebornTurnRunExecutor"}, {"n": "TurnRunExecutor", "k": "trait", "d": "executor port; target: kernel-defined, runner-implemented", "was": null}, {"n": "DriverRegistry", "k": "struct", "d": "driver registry with readiness validation", "was": null}, {"n": "PlannedDriver", "k": "struct", "d": "adapts agent_loop executor to the driver contract", "was": null}, {"n": "TextOnlyModelReplyDriver", "k": "struct", "d": "smallest supported text-only driver", "was": null}, {"n": "LoopDriverHostFactory", "k": "struct", "d": "composes a claimed run's scoped port set", "was": "RebornLoopDriverHostFactory"}, {"n": "HostFactory", "k": "trait", "d": "loop-host factory seam", "was": null}, {"n": "TurnRunScheduler", "k": "struct", "d": "agent-turn projection over the process supervisor", "was": null}], "hooks": [{"n": "HookDispatcher", "k": "struct", "d": "orders and runs hooks per decision point", "was": null}, {"n": "HookRegistry", "k": "struct", "d": "registered hooks by trust tier", "was": null}, {"n": "HookTrustClass", "k": "enum", "d": "four source-fixed, never-declarable trust classes", "was": null}, {"n": "HookedLoopCapabilityPort", "k": "struct", "d": "outermost port decorator; one per Loop*Port", "was": null}, {"n": "PredicateEvaluator", "k": "struct", "d": "declarative predicate language evaluator", "was": null}, {"n": "WasmHookRuntime", "k": "struct", "d": "sandboxed engine for portable hook code", "was": null}, {"n": "PredicateStateBackend", "k": "trait", "d": "predicate persistence seam (documented idiom exception)", "was": null}], "extension_registry": [{"n": "ExtensionRegistry", "k": "struct", "d": "deterministic in-memory manifest catalog", "was": null}, {"n": "SharedExtensionRegistry", "k": "struct", "d": "shared registry handle", "was": null}, {"n": "ExtensionManifest", "k": "struct", "d": "wire manifest schema", "was": null}, {"n": "ExtensionManifestV2", "k": "struct", "d": "internal normal-form manifest", "was": null}, {"n": "ResolvedExtensionManifest", "k": "struct", "d": "resolved, digested form consumers read", "was": null}, {"n": "ManifestHash", "k": "struct", "d": "manifest content digest", "was": null}, {"n": "ExtensionInstallationStore", "k": "struct", "d": "durable installation, membership, credential-binding records", "was": null}, {"n": "ExtensionInstallationStorePort", "k": "trait", "d": "storage port behind the record store", "was": null}], "extension_host": [{"n": "ExtensionHost", "k": "struct", "d": "sole lifecycle writer: install, activate, bind, remove", "was": null}, {"n": "ActiveSnapshot", "k": "struct", "d": "active-installation snapshot with generations", "was": null}, {"n": "ExtensionIngressRouter", "k": "struct", "d": "vendor-blind inbound router", "was": null}, {"n": "verify_recipe", "k": "fn", "d": "manifest-recipe verifier; mints verified-inbound evidence", "was": null}, {"n": "ExtensionLoader", "k": "trait", "d": "loader contract: native, WASM, MCP", "was": null}, {"n": "NativeExtensionFactory", "k": "trait", "d": "binary-supplied native entrypoint factory", "was": null}, {"n": "ChannelEgressTransport", "k": "trait", "d": "host-mediated egress transport", "was": null}, {"n": "ReplyContextStore", "k": "trait", "d": "reply-context persistence seam", "was": null}, {"n": "ChannelPairingService", "k": "struct", "d": "generic pairing service core", "was": null}], "extension_manager": [{"n": "ExtensionManagementSurface", "k": "struct", "d": "extension-management ProductSurface implementation", "was": "SharedCommandSurface", "new": true}, {"n": "AvailableExtensionCatalog", "k": "struct", "d": "available-extension catalog and import path", "was": null}, {"n": "ExtensionLifecycleWorkflow", "k": "struct", "d": "lifecycle-command orchestration; calls host authority only", "was": "ExtensionLifecycleManager"}, {"n": "ProductChannelConfigService", "k": "struct", "d": "channel-configuration product service", "was": "RebornChannelConfigProductService"}, {"n": "ExtensionLifecycleProductService", "k": "struct", "d": "LifecycleProductService port implementation", "was": "ExtensionHostLifecycleProductService"}, {"n": "ProductAuthExtensionCredentialSetup", "k": "struct", "d": "credential setup and credential views", "was": null}, {"n": "ComposedAdminConfigurationService", "k": "type", "d": "admin-configuration capability service composition", "was": null}], "extension_support": [{"n": "bundled_packages", "k": "fn", "d": "the shipped package inventory", "was": null}, {"n": "PackageBundle", "k": "struct", "d": "one package's manifest and assets", "was": null}, {"n": "PackageOnboarding", "k": "struct", "d": "per-package onboarding metadata", "was": null}, {"n": "GsuiteExecutor", "k": "struct", "d": "native gsuite tool executor", "was": null}, {"n": "WebAccessExecutor", "k": "struct", "d": "native web-access tool executor", "was": null}, {"n": "GoogleCredentialResolver", "k": "struct", "d": "google credential staging resolver", "was": null}, {"n": "first_party_tool_handlers", "k": "type", "d": "builtin http/shell/time/memory/trigger/skill handlers, absorbed from host_runtime", "was": null, "new": true}], "pkg_slack": [{"n": "SlackChannelAdapter", "k": "struct", "d": "ChannelAdapter impl: parse, render, deliver", "was": null}, {"n": "SlackPreferenceTargetCodec", "k": "struct", "d": "preference-target encoding", "was": null}, {"n": "SlackInboundEvent", "k": "enum", "d": "parsed inbound payload shapes", "was": null}, {"n": "SlackUrlVerificationChallenge", "k": "struct", "d": "URL-verification handshake payload", "was": null}], "pkg_telegram": [{"n": "TelegramChannelAdapter", "k": "struct", "d": "ChannelAdapter impl: parse, render, deliver", "was": null}, {"n": "TelegramPreferenceTargetCodec", "k": "struct", "d": "preference-target encoding", "was": null}, {"n": "TelegramInboundEvent", "k": "enum", "d": "inbound payload shapes (absorbed from telegram_v2_adapter)", "was": null}, {"n": "TelegramReplyTarget", "k": "struct", "d": "reply-target binding (absorbed from telegram_v2_adapter)", "was": null}], "pkg_memory_native": [{"n": "NativeMemoryService", "k": "struct", "d": "MemoryService implementation over the backend abstraction", "was": null}, {"n": "MemoryBackend", "k": "trait", "d": "provider backend abstraction", "was": null}, {"n": "RepositoryMemoryBackend", "k": "struct", "d": "repository-composed backend", "was": null}, {"n": "FilesystemMemoryDocumentRepository", "k": "struct", "d": "filesystem document repository", "was": null}, {"n": "ChunkingMemoryDocumentIndexer", "k": "struct", "d": "chunking full-text indexer", "was": null}, {"n": "DefaultPromptWriteSafetyPolicy", "k": "struct", "d": "prompt-write-safety enforcement engine", "was": null}], "pkg_mem0": [{"n": "Mem0MemoryService", "k": "struct", "d": "MemoryService implementation over mem0 REST", "was": null}, {"n": "Mem0Transport", "k": "trait", "d": "transport seam; mock-testable without network", "was": null}, {"n": "Mem0HttpTransport", "k": "struct", "d": "hardened transport: timeout, no redirects, URL validation", "was": null}, {"n": "Mem0Config", "k": "struct", "d": "external-service configuration", "was": null}], "assistant": [{"n": "AssistantServices", "k": "struct", "d": "the canonical ProductSurface implementation (service aggregate)", "was": "RebornServices"}, {"n": "DefaultProductSurface", "k": "struct", "d": "ChannelInboundProductSurface impl: admission workflow", "was": null}, {"n": "IdempotencyLedger", "k": "trait", "d": "begin-or-replay inbound idempotency by action fingerprint", "was": null}, {"n": "FilesystemIdempotencyLedger", "k": "struct", "d": "durable ledger implementation", "was": "RebornFilesystemIdempotencyLedger"}, {"n": "DeliveryCoordinator", "k": "struct", "d": "decides delivery target, retries, and reply context", "was": null}, {"n": "ProductCommand", "k": "enum", "d": "the product command grammar", "was": null}, {"n": "ProductCommandAdmission", "k": "enum", "d": "admission decision vocabulary", "was": null}, {"n": "DefaultApprovalInteractionService", "k": "struct", "d": "click-approval service over redacted read models", "was": null}, {"n": "DefaultAuthInteractionService", "k": "struct", "d": "click-auth service over redacted read models", "was": null}], "operator": [{"n": "ProviderAdmin", "k": "struct", "d": "LLM provider registry administration", "was": "RebornProviderAdmin"}, {"n": "OperatorLlmConfigService", "k": "struct", "d": "LlmConfigService port implementation", "was": "RebornLlmConfigService"}, {"n": "ProviderActiveModelReader", "k": "struct", "d": "active-model selection reader", "was": null}, {"n": "LlmKeyStore", "k": "struct", "d": "provider key management", "was": null}, {"n": "ProviderRepo", "k": "struct", "d": "provider registry write-side", "was": null}, {"n": "OperatorLogBuffer", "k": "struct", "d": "operator log ring", "was": null}, {"n": "OperatorServiceLifecycle", "k": "struct", "d": "platform service-lifecycle abstraction", "was": null}, {"n": "LlmReloadAdapter", "k": "struct", "d": "provider hot-reload trigger", "was": "RebornLlmReloadAdapter"}], "openai_compat": [{"n": "OpenAiChatCompletionsWorkflow", "k": "struct", "d": "chat-completions workflow over BoundProductSurface", "was": null}, {"n": "OpenAiCompatRouteSurface", "k": "enum", "d": "route descriptor surface", "was": null}, {"n": "OpenAiCompatError", "k": "struct", "d": "sanitized error envelope", "was": null}, {"n": "OpenAiCompatRefStore", "k": "struct", "d": "surface-scoped ref and idempotency store", "was": null}, {"n": "OpenAiCompatIdempotencyKey", "k": "struct", "d": "request idempotency key", "was": null}, {"n": "OpenAiChatCompletionRequest", "k": "struct", "d": "wire DTO for chat completions", "was": null}], "webui": [{"n": "WebuiAuthenticator", "k": "trait", "d": "host-authenticator contract at the listener", "was": null}, {"n": "CompositeAuthenticator", "k": "struct", "d": "bearer, session, OIDC authenticator composition", "was": null}, {"n": "SessionAuthenticator", "k": "struct", "d": "session-backed authenticator", "was": null}, {"n": "OidcAuthenticator", "k": "struct", "d": "OIDC authenticator", "was": null}, {"n": "WebuiServeConfig", "k": "struct", "d": "gateway assembly: middleware order and routes", "was": null}, {"n": "WebuiServeOptions", "k": "struct", "d": "serve-entry options", "was": "RebornWebuiServeOptions"}, {"n": "serve_webui", "k": "fn", "d": "the serve loop entry", "was": "serve_webui_v2"}, {"n": "WebChatEvent", "k": "enum", "d": "WebChat stream event vocabulary", "was": "WebChatV2Event"}, {"n": "OAuthRouterConfig", "k": "struct", "d": "OAuth login stack (permitted vendor exception)", "was": null}], "host_ingress": [{"n": "PublicRouteMount", "k": "struct", "d": "public router plus policy descriptor carrier", "was": null}, {"n": "ProtectedRouteMount", "k": "struct", "d": "authenticated route carrier", "was": null}, {"n": "SplitRouteMount", "k": "struct", "d": "combined public and protected carrier", "was": null}, {"n": "PublicRouteDrain", "k": "trait", "d": "shutdown drain hook", "was": null}], "composition": [{"n": "HostBindings", "k": "struct", "d": "binary-supplied opaque adapter binding input", "was": "RebornHostBindings"}, {"n": "RuntimeInput", "k": "struct", "d": "assembly input: config, bindings, backends", "was": "RebornRuntimeInput"}, {"n": "ServiceGraph", "k": "struct", "d": "assembled service-graph handle: product, auth, readiness methods", "was": "RebornRuntime"}, {"n": "CompositionProfile", "k": "enum", "d": "closed deployment-profile selection", "was": "RebornCompositionProfile"}, {"n": "DeploymentConfig", "k": "struct", "d": "deployment configuration as data", "was": null}, {"n": "StorageShape", "k": "enum", "d": "storage backend selection", "was": null}, {"n": "ChannelExtensionBinding", "k": "struct", "d": "typed extension identity paired with adapter handle", "was": null}, {"n": "AdminApiTokenMinter", "k": "trait", "d": "token-minting port only the binary satisfies", "was": null}, {"n": "ReadinessState", "k": "enum", "d": "fail-closed readiness state", "was": "RebornReadinessState"}, {"n": "build_runtime", "k": "fn", "d": "owner-factory assembly entry", "was": "build_reborn_runtime"}], "cli": [{"n": "ServeInvocation", "k": "struct", "d": "parsed serve command invocation", "was": null}, {"n": "serve_invocation", "k": "fn", "d": "serve command entry", "was": null}, {"n": "SignedSessionTokenMinter", "k": "struct", "d": "sole AdminApiTokenMinter implementation (crate-private by design)", "was": null}, {"n": "TraceChannelArg", "k": "enum", "d": "trace command channel selector", "was": null}], "config": [{"n": "ConfigFile", "k": "struct", "d": "config.toml schema", "was": "RebornConfigFile"}, {"n": "BootConfig", "k": "struct", "d": "resolved boot configuration", "was": "RebornBootConfig"}, {"n": "Home", "k": "struct", "d": "resolved ironclaw home directory", "was": "RebornHome"}, {"n": "Profile", "k": "enum", "d": "deployment profile", "was": "RebornProfile"}, {"n": "StorageBackend", "k": "enum", "d": "storage backend choice", "was": null}, {"n": "InlineSecretError", "k": "struct", "d": "parse-time inline-secret rejection", "was": null}, {"n": "BudgetDefaults", "k": "struct", "d": "budget environment defaults", "was": null}], "architecture_tests": [{"n": "dependency_boundaries", "k": "fn", "d": "layer and family dependency matrix enforcement", "was": "reborn_dependency_boundaries"}, {"n": "retired_taxonomy", "k": "fn", "d": "pins retired vocabulary at zero", "was": "reborn_retired_taxonomy"}, {"n": "service_method_freeze_ratchet", "k": "fn", "d": "ProductSurface frozen-method-set ratchet", "was": "reborn_service_method_freeze_ratchet"}, {"n": "naming_rule_assertions", "k": "fn", "d": "the section 5.1 and 11.2.11 naming-rule enforcement", "was": null, "new": true}], "libsql_runtime": [{"n": "LibSqlRuntime", "k": "struct", "d": "one read pool + one write lane for a single libSQL database", "was": null}, {"n": "LibSqlReadConnectionLease", "k": "struct", "d": "read-only checkout; exposes queries, never the connection", "was": null}, {"n": "LibSqlWriteConnectionLease", "k": "struct", "d": "the single writer slot; non-reentrant by construction", "was": null}, {"n": "LibSqlLane", "k": "enum", "d": "read or write — names the admission lane without naming a target", "was": null}, {"n": "LibSqlCheckoutFailureReason", "k": "enum", "d": "typed checkout failure so adapters classify without parsing text", "was": null}, {"n": "LibSqlRuntimeError", "k": "enum", "d": "redacted runtime failures safe to map at a storage boundary", "was": null}]}; +const TYPES = {"host_api": [{"n": "Authorized", "k": "struct", "d": "sealed witness every privileged effect must carry", "was": null}, {"n": "CapabilityAuthorizer", "k": "trait", "d": "kernel-implemented port that mints Authorized", "was": null}, {"n": "CapabilityDispatcher", "k": "trait", "d": "the dispatch port; implemented by the kernel membrane", "was": null}, {"n": "RuntimeLane", "k": "enum", "d": "closed lane set an Authorized witness is bound to", "was": null}, {"n": "TrustClass", "k": "enum", "d": "trust vocabulary with serde-sealed privileged variants", "was": null}, {"n": "CapabilityDescriptor", "k": "struct", "d": "requested-effect shape flowing through every capability invocation", "was": null}, {"n": "MountView", "k": "struct", "d": "the mount-containment grant a mediated caller receives", "was": null}, {"n": "RuntimeHttpEgress", "k": "trait", "d": "host-mediated HTTP egress port", "was": null}, {"n": "IngressRouteDescriptor", "k": "struct", "d": "neutral ingress route vocabulary transports mount against", "was": null}, {"n": "TurnRunId", "k": "struct", "d": "canonical run identity in the turn vocabulary", "was": null}], "common": [{"n": "CredentialName", "k": "struct", "d": "backend secret identity newtype", "was": null}, {"n": "ExtensionName", "k": "struct", "d": "user-facing installed extension identity newtype", "was": null}, {"n": "McpServerName", "k": "struct", "d": "mcp server identity newtype", "was": null}, {"n": "ExternalThreadId", "k": "struct", "d": "external thread identity carrying the wire-compat exception", "was": null}, {"n": "AttachmentRef", "k": "struct", "d": "generic attachment reference, distinct from channel vendor refs", "was": null}, {"n": "AttachmentFormat", "k": "struct", "d": "attachment format and extractor vocabulary", "was": null}, {"n": "s256_challenge", "k": "fn", "d": "pkce code-challenge helper", "was": null}], "prompt_envelope": [{"n": "wrap_untrusted", "k": "fn", "d": "wraps untrusted snippets with trust markers before model exposure", "was": null}, {"n": "wrap_untrusted_with_limit", "k": "fn", "d": "bounded variant enforcing the envelope byte budget", "was": null}, {"n": "EnvelopeSource", "k": "enum", "d": "closed source vocabulary: memory, hook, skill", "was": null}, {"n": "EnvelopeTrust", "k": "enum", "d": "trusted/untrusted classification on enveloped content", "was": null}, {"n": "EnvelopedContent", "k": "struct", "d": "the wrapped, marker-fenced model-visible snippet", "was": null}], "loop_contracts": [{"n": "AgentLoopDriver", "k": "trait", "d": "the replaceable loop strategy a host drives", "was": null}, {"n": "AgentLoopDriverHost", "k": "trait", "d": "blanket host trait exposing all Loop*Port ports together", "was": null}, {"n": "LoopCapabilityPort", "k": "trait", "d": "capability port of the eleven-trait Loop*Port membrane", "was": null}, {"n": "LoopModelPort", "k": "trait", "d": "model port the loop calls instead of a gateway", "was": null}, {"n": "LoopTranscriptPort", "k": "trait", "d": "transcript access port for loop userland", "was": null}, {"n": "LoopExit", "k": "enum", "d": "the loop's exit claim; only the kernel validates it", "was": null}, {"n": "ResolvedRunProfile", "k": "struct", "d": "resolved run-profile snapshot a loop executes under", "was": null}, {"n": "RunProfileResolver", "k": "trait", "d": "resolves a profile request into a resolved profile", "was": null}, {"n": "CheckpointStateStorePort", "k": "trait", "d": "loop checkpoint persistence port", "was": null}, {"n": "AgentLoopHostError", "k": "struct", "d": "bounded loop-side host error vocabulary", "was": null}], "extension_contracts": [{"n": "ChannelAdapter", "k": "trait", "d": "per-package normalize, render, deliver, resolve channel trait", "was": null}, {"n": "ToolAdapter", "k": "trait", "d": "model-callable tool counterpart to ChannelAdapter", "was": null}, {"n": "ExtensionEntrypoint", "k": "trait", "d": "manifest-bound entrypoint every extension package exposes", "was": null}, {"n": "VerifiedInbound", "k": "struct", "d": "sealed inbound-verification evidence; minted by ingress verifier only", "was": null}, {"n": "NormalizedInboundMessage", "k": "struct", "d": "vendor-neutral normalized inbound message", "was": null}, {"n": "OutboundEnvelope", "k": "struct", "d": "outbound delivery envelope with typed parts", "was": null}, {"n": "ChannelDescriptor", "k": "struct", "d": "manifest channel-surface descriptor", "was": null}, {"n": "VendorAuthRecipe", "k": "enum", "d": "declarative auth recipe schema manifests compile into", "was": null}, {"n": "LifecyclePublicState", "k": "enum", "d": "caller-visible three-state lifecycle vocabulary", "was": null}, {"n": "VendorAttachmentRef", "k": "struct", "d": "channel-facing vendor attachment reference", "was": "AttachmentRef"}], "product_contracts": [{"n": "ProductSurface", "k": "trait", "d": "the single generic membrane every transport invokes", "was": null}, {"n": "BoundProductSurface", "k": "struct", "d": "caller-bound handle over the surface", "was": null}, {"n": "ProductSurfaceCaller", "k": "struct", "d": "caller identity and scope crossing the membrane", "was": null}, {"n": "ChannelInboundProductSurface", "k": "trait", "d": "channel-inbound admission port beside the membrane", "was": null}, {"n": "AppEvent", "k": "enum", "d": "the full product event wire enumeration transports stream", "was": null}, {"n": "ProductSurfaceCommandDescriptor", "k": "struct", "d": "the descriptor type concrete product commands instantiate", "was": null}, {"n": "ProductView", "k": "struct", "d": "the descriptor type concrete product views instantiate", "was": null}, {"n": "ChannelDeliveryResolver", "k": "trait", "d": "delivery-resolution port implemented beside the extension host", "was": null}, {"n": "LifecycleProductService", "k": "trait", "d": "extension lifecycle product service port", "was": null}, {"n": "LlmConfigService", "k": "trait", "d": "operator LLM-config port implemented by operator", "was": null}], "filesystem": [{"n": "RootFilesystem", "k": "trait", "d": "the universal storage-dispatch trait all backends implement", "was": null}, {"n": "ScopedFilesystem", "k": "struct", "d": "mount-checked caller view over the root trait", "was": null}, {"n": "CompositeRootFilesystem", "k": "struct", "d": "mount-catalog routing across multiple backends", "was": null}, {"n": "MountDescriptor", "k": "struct", "d": "mount catalog entry describing path placement", "was": null}, {"n": "cas_update", "k": "fn", "d": "bounded-retry compare-and-swap floor for durable records", "was": null}, {"n": "Entry", "k": "struct", "d": "versioned record entry vocabulary", "was": null}, {"n": "CasExpectation", "k": "enum", "d": "compare-and-swap precondition vocabulary", "was": null}, {"n": "IndexSpec", "k": "struct", "d": "secondary index specification", "was": null}, {"n": "PostgresRootFilesystem", "k": "struct", "d": "durable postgres backend", "was": null}, {"n": "DiskFilesystem", "k": "struct", "d": "local disk backend", "was": null}], "secrets": [{"n": "SecretStorePort", "k": "trait", "d": "lease-once/consume one-shot custody port", "was": null}, {"n": "SecretStore", "k": "struct", "d": "generic store implementation over the filesystem fabric", "was": null}, {"n": "CredentialBroker", "k": "struct", "d": "credential broker built on the store", "was": null}, {"n": "CredentialAccountStore", "k": "trait", "d": "credential account store port", "was": null}, {"n": "CredentialSessionStore", "k": "trait", "d": "credential session store port", "was": null}, {"n": "SecretLease", "k": "struct", "d": "one-shot lease handle; raw material readable once", "was": null}], "network": [{"n": "NetworkHttpEgress", "k": "trait", "d": "the outbound HTTP egress port", "was": null}, {"n": "PolicyNetworkHttpEgress", "k": "struct", "d": "policy-checked egress implementation", "was": null}, {"n": "NetworkHttpTransport", "k": "trait", "d": "transport port beneath the egress policy", "was": null}, {"n": "ReqwestNetworkTransport", "k": "struct", "d": "pinned hardened production transport", "was": null}, {"n": "NetworkResolver", "k": "trait", "d": "DNS resolution port", "was": null}, {"n": "SystemNetworkResolver", "k": "struct", "d": "resolver denying private and reserved addresses", "was": null}, {"n": "StaticNetworkPolicyEnforcer", "k": "struct", "d": "target/method policy matcher before any call", "was": null}], "safety": [{"n": "SafetyLayer", "k": "struct", "d": "unified sanitize, validate, leak-scan composition", "was": null}, {"n": "Sanitizer", "k": "struct", "d": "injection-pattern scanner over untrusted text", "was": null}, {"n": "Validator", "k": "struct", "d": "structural and size validation for provider-bound content", "was": null}, {"n": "LeakDetector", "k": "struct", "d": "credential-material leak scanner at trust boundaries", "was": null}, {"n": "InjectionScanner", "k": "trait", "d": "focused injection-scan interface", "was": null}, {"n": "LeakScanner", "k": "trait", "d": "focused leak-scan interface", "was": null}, {"n": "is_sensitive_path", "k": "fn", "d": "sensitive-path predicate filesystem redaction depends on", "was": null}], "observability": [{"n": "live_latency_trace", "k": "macro", "d": "zero-cost-when-off latency trace", "was": null}, {"n": "live_latency_trace_ok", "k": "macro", "d": "latency trace recording success outcome", "was": null}, {"n": "live_latency_trace_error", "k": "macro", "d": "latency trace recording error outcome", "was": null}, {"n": "elapsed_ms", "k": "fn", "d": "elapsed-time helper backing the macros", "was": null}, {"n": "live_latency_enabled", "k": "fn", "d": "target-enabled check the macros gate on", "was": null}], "event_log": [{"n": "RuntimeEvent", "k": "struct", "d": "redacted runtime event evidence shape", "was": null}, {"n": "RuntimeEventKind", "k": "enum", "d": "bounded event kind classification", "was": null}, {"n": "SecurityAuditEvent", "k": "struct", "d": "redacted security audit envelope", "was": null}, {"n": "EventCursor", "k": "struct", "d": "monotonic per-stream replay cursor", "was": null}, {"n": "EventSink", "k": "trait", "d": "best-effort sink; failures never alter outcomes", "was": null}, {"n": "AuditSink", "k": "trait", "d": "best-effort audit sink counterpart", "was": null}, {"n": "DurableEventLog", "k": "trait", "d": "explicit-error durable append and cursor-replay log", "was": null}, {"n": "DurableAuditLog", "k": "trait", "d": "durable audit log counterpart", "was": null}, {"n": "InMemoryDurableEventLog", "k": "struct", "d": "in-memory reference implementation", "was": null}], "event_store": [{"n": "EventStoreConfig", "k": "enum", "d": "backend selection with fail-closed production validation", "was": "RebornEventStoreConfig"}, {"n": "EventStores", "k": "struct", "d": "paired durable event and audit log handles", "was": "RebornEventStores"}, {"n": "EventStoreProfile", "k": "enum", "d": "deployment profile governing which fallbacks are legal", "was": "RebornProfile"}, {"n": "build_event_stores_from_root_filesystem", "k": "fn", "d": "the backend-selection entry point", "was": "build_reborn_event_stores_from_root_filesystem"}, {"n": "FilesystemDurableEventLog", "k": "struct", "d": "durable log adapter over the storage fabric", "was": null}, {"n": "FilesystemDurableAuditLog", "k": "struct", "d": "audit log adapter over the storage fabric", "was": null}, {"n": "JsonlDurableEventLog", "k": "struct", "d": "single-node durable JSONL backend", "was": null}, {"n": "CoalescingEventSink", "k": "struct", "d": "coalescing sink for high-frequency producers", "was": null}], "event_projections": [{"n": "EventProjectionService", "k": "trait", "d": "scoped replay-derived event read-model service", "was": null}, {"n": "AuditProjectionService", "k": "trait", "d": "audit-side projection service", "was": null}, {"n": "ReplayEventProjectionService", "k": "struct", "d": "replay-folding implementation of the event service", "was": null}, {"n": "ReplayAuditProjectionService", "k": "struct", "d": "replay-folding implementation of the audit service", "was": null}, {"n": "ThreadTimeline", "k": "struct", "d": "thread timeline read model", "was": null}, {"n": "RunStatusProjection", "k": "struct", "d": "run status read model", "was": null}, {"n": "CapabilityActivityProjection", "k": "struct", "d": "capability activity read model", "was": null}, {"n": "ProjectionScope", "k": "struct", "d": "tenant, actor, read-scope authorization vocabulary", "was": null}, {"n": "ProjectionCursor", "k": "struct", "d": "cursor with rebase semantics for incremental replay", "was": null}], "event_streams": [{"n": "EventStreamManager", "k": "struct", "d": "transport-neutral stream manager over injected collaborators", "was": null}, {"n": "ProjectionAccessPolicy", "k": "trait", "d": "actor, scope, view, target authorization check", "was": null}, {"n": "ProjectionStreamAdmissionPolicy", "k": "trait", "d": "subscription admission control port", "was": null}, {"n": "ProjectionStreamAdmissionPermit", "k": "struct", "d": "RAII admission permit releasing its slot on drop", "was": null}, {"n": "ProjectionUpdateSource", "k": "trait", "d": "live-update source port", "was": null}, {"n": "ProjectionRedactionValidator", "k": "trait", "d": "fail-closed redaction validation before delivery", "was": null}, {"n": "ProjectionSubscribeRequest", "k": "struct", "d": "subscription request vocabulary", "was": null}, {"n": "ProjectionStreamItem", "k": "enum", "d": "stitched live and replay stream item", "was": null}], "wasm": [{"n": "WitToolRuntime", "k": "struct", "d": "component loading, validation, metering, execution runtime", "was": null}, {"n": "WitToolRuntimeConfig", "k": "struct", "d": "fuel, epoch, memory, table limit configuration", "was": null}, {"n": "WasmHostHttp", "k": "trait", "d": "host-import HTTP capability; deny-by-default implementation shipped", "was": null}, {"n": "WasmHostWorkspace", "k": "trait", "d": "host-import workspace capability, deny-by-default", "was": null}, {"n": "WasmHostSecrets", "k": "trait", "d": "host-import secrets capability, deny-by-default", "was": null}, {"n": "WasmHostTools", "k": "trait", "d": "host-import tool-invocation capability, deny-by-default", "was": null}, {"n": "WasmHostClock", "k": "trait", "d": "host-import clock capability, deny-by-default", "was": null}, {"n": "SandboxLimits", "k": "struct", "d": "domain-free WASM sandbox limit primitives", "was": null}, {"n": "SandboxStoreCore", "k": "struct", "d": "shared store core other WASM hosts reuse", "was": null}], "wasm_limiter": [{"n": "WasmResourceLimiter", "k": "struct", "d": "the shared wasmtime resource limiter both hosts wire", "was": null}], "mcp": [{"n": "McpRuntime", "k": "struct", "d": "the MCP lane runtime over a generic client", "was": null}, {"n": "McpRuntimeConfig", "k": "struct", "d": "runtime configuration composition wires", "was": null}, {"n": "McpClient", "k": "trait", "d": "JSON-RPC client port", "was": null}, {"n": "McpHostHttp", "k": "trait", "d": "host-mediated HTTP port; no lane-owned client", "was": null}, {"n": "McpHostHttpEgressPlanner", "k": "trait", "d": "plans egress before any outbound call", "was": null}, {"n": "McpHostHttpClient", "k": "struct", "d": "client over injected host HTTP and planner", "was": null}, {"n": "McpExecutor", "k": "trait", "d": "execution port the kernel invokes", "was": null}], "sandbox": [{"n": "SandboxProcessPlan", "k": "struct", "d": "typed two-phase plan for a sandboxed process invocation", "was": null}, {"n": "ValidatedSandboxProcessPlan", "k": "struct", "d": "validation witness the transport alone accepts", "was": null}, {"n": "ScopedSandboxCommandTransport", "k": "struct", "d": "container-backed implementation of the kernel transport port", "was": "RebornScopedSandboxCommandTransport"}, {"n": "SandboxConfig", "k": "struct", "d": "lane configuration for the container backend", "was": "RebornSandboxConfig"}, {"n": "SandboxCertificateAuthority", "k": "struct", "d": "per-tenant CA; root key never leaves memory", "was": null}, {"n": "SandboxCredentialFirewall", "k": "struct", "d": "staged one-shot credential obligation chokepoint", "was": null}, {"n": "SandboxNetworkBroker", "k": "struct", "d": "host-mediated egress brokering for containers", "was": "RebornSandboxNetworkBroker"}, {"n": "SandboxSecretBroker", "k": "struct", "d": "host-mediated secret brokering for containers", "was": "RebornSandboxSecretBroker"}, {"n": "SandboxContainerIdentity", "k": "struct", "d": "per-tenant container identity", "was": "RebornSandboxContainerIdentity"}, {"n": "SandboxCredentialBinding", "k": "struct", "d": "typed credential binding in the plan vocabulary", "was": null}], "threads": [{"n": "SessionThreadService", "k": "trait", "d": "append, finalize, and read canonical transcripts", "was": null}, {"n": "FilesystemSessionThreadService", "k": "struct", "d": "durable transcript service over ScopedFilesystem", "was": null}, {"n": "InMemorySessionThreadService", "k": "struct", "d": "deterministic in-memory transcript service for tests", "was": null}, {"n": "SessionThreadRecord", "k": "struct", "d": "canonical session-thread record", "was": null}, {"n": "ThreadMessageRecord", "k": "struct", "d": "canonical transcript message record", "was": null}, {"n": "ThreadScope", "k": "struct", "d": "tenant/user/agent scope key for thread isolation", "was": null}, {"n": "ToolResultReferenceEnvelope", "k": "struct", "d": "tool-result reference stored inside the transcript", "was": null}, {"n": "SummaryArtifact", "k": "struct", "d": "presentation summary projected from transcript, not second truth", "was": null}], "conversations": [{"n": "InboundConversationService", "k": "trait", "d": "accepts inbound messages with idempotent turn submission", "was": "SessionThreadService"}, {"n": "ConversationBindingService", "k": "trait", "d": "resolves external conversation refs to canonical bindings", "was": null}, {"n": "ConversationActorPairingService", "k": "trait", "d": "pairs/unpairs external actors to canonical users", "was": null}, {"n": "ConversationStateStore", "k": "struct", "d": "durable conversation-state store over ScopedFilesystem", "was": null}, {"n": "InboundTurnService", "k": "struct", "d": "production inbound resolver submitting to the turn coordinator", "was": null}, {"n": "FilesystemConversationServices", "k": "struct", "d": "bundle wiring the filesystem-backed conversation services", "was": "RebornFilesystemConversationServices"}, {"n": "InMemoryConversationServices", "k": "struct", "d": "real in-memory services used inside tests", "was": null}, {"n": "ExternalActorRef", "k": "struct", "d": "external actor identity; unify with host_api twin", "was": null}], "triggers": [{"n": "TriggerRepository", "k": "trait", "d": "trigger record and fire persistence port", "was": null}, {"n": "TriggerRecord", "k": "struct", "d": "scheduled-trigger record grammar with validation", "was": null}, {"n": "TriggerSchedule", "k": "enum", "d": "cron/interval schedule with timezone validation", "was": null}, {"n": "TriggerFireIdentity", "k": "struct", "d": "deterministic fire identity for idempotent submission", "was": null}, {"n": "TriggerPollerWorker", "k": "struct", "d": "per-tick due-fire evaluation via tick_once", "was": null}, {"n": "TriggerTrustedInboundBinding", "k": "struct", "d": "host-trusted submission binding for poller fires", "was": null}, {"n": "TrustedTriggerFireSubmitter", "k": "trait", "d": "submitter port carrying sealed trusted fires inbound", "was": null}, {"n": "TriggerPromptMaterializer", "k": "trait", "d": "materializer port turning fires into prompts", "was": null}, {"n": "TriggerActiveRunLookup", "k": "trait", "d": "state-lookup port for active-run hold decisions", "was": null}], "memory": [{"n": "MemoryService", "k": "trait", "d": "provider-neutral memory contract every provider implements", "was": null}, {"n": "MemoryDocumentScope", "k": "struct", "d": "tenant/user/agent/project scope for memory documents", "was": null}, {"n": "MemoryDocumentPath", "k": "struct", "d": "validated memory document path value type", "was": null}, {"n": "PromptWriteSafetyPolicy", "k": "trait", "d": "gate providers enforce before model-authored memory writes", "was": null}, {"n": "PromptProtectedPathRegistry", "k": "struct", "d": "protected-path classes for prompt write safety", "was": null}, {"n": "MemorySignificantEventSink", "k": "trait", "d": "audit sink for significant memory events", "was": null}, {"n": "MemorySignificantEvent", "k": "struct", "d": "significant-event audit record for memory writes", "was": null}, {"n": "memory_service_contract_full", "k": "macro", "d": "conformance suite each provider wires and must pass", "was": null}], "skills": [{"n": "SkillInferencePort", "k": "trait", "d": "learning inversion port implemented by hosting tier", "was": null}, {"n": "LoadedSkill", "k": "struct", "d": "parsed, validated skill with compiled activation patterns", "was": null}, {"n": "SkillManifest", "k": "struct", "d": "skill grammar: metadata, activation criteria, requirements", "was": null}, {"n": "parse_skill_md", "k": "fn", "d": "parses SKILL.md content into a skill", "was": null}, {"n": "ScopedSkillManagementPort", "k": "struct", "d": "filesystem-backed scoped skill install/remove management", "was": null}, {"n": "SelectionOutcome", "k": "struct", "d": "deterministic selection-scoring result under token budget", "was": null}, {"n": "SkillTrust", "k": "enum", "d": "trusted versus installed skill trust level", "was": null}, {"n": "SkillActivationObserver", "k": "trait", "d": "activation observer; moves in from first_party_extension_ports", "was": null}], "auth": [{"n": "AuthEngine", "k": "struct", "d": "recipe-driven token exchange, refresh, and client registration", "was": null}, {"n": "AuthRecipeResolver", "k": "trait", "d": "resolves vendor auth recipes; extension host implements", "was": null}, {"n": "AuthFlowManager", "k": "trait", "d": "durable auth-flow lifecycle contract", "was": null}, {"n": "CredentialAccountService", "k": "trait", "d": "credential-account records and lifecycle contract", "was": null}, {"n": "AuthInteractionService", "k": "trait", "d": "pending auth interaction contract for product surfaces", "was": null}, {"n": "SecretCleanupService", "k": "trait", "d": "credential cleanup contract on removal", "was": null}, {"n": "ProductAuthServices", "k": "struct", "d": "caller-facing product-auth service bundle", "was": "RebornProductAuthServices"}, {"n": "RuntimeCredentialAccountSelectionService", "k": "trait", "d": "runtime credential selection with refresh locking", "was": null}, {"n": "AuthContinuationDispatcher", "k": "trait", "d": "resumes gated work after auth completes", "was": "RebornAuthContinuationDispatcher"}, {"n": "ManualTokenFlowService", "k": "trait", "d": "manual token setup/submit flow contract", "was": "RebornManualTokenFlowService"}], "attachments": [{"n": "AttachmentLanding", "k": "struct", "d": "channel-agnostic routine landing attachment bytes into storage", "was": null}, {"n": "InboundAttachment", "k": "struct", "d": "normalized inbound attachment an adapter hands over", "was": null}, {"n": "InboundAttachmentLander", "k": "trait", "d": "landing port; moves in from ironclaw_product", "was": null}, {"n": "InboundAttachmentReader", "k": "trait", "d": "read-back port; moves in from ironclaw_product", "was": null}, {"n": "attachment_scoped_path", "k": "fn", "d": "scoped virtual path attachments land under", "was": null}], "extractors": [{"n": "extract_document", "k": "fn", "d": "typed MIME-dispatch extraction entry point", "was": null}, {"n": "DocumentExtraction", "k": "enum", "d": "structured extraction outcome replacing stringly errors", "was": null}, {"n": "extract_document_text_by_filename", "k": "fn", "d": "extension-based dispatch fallback", "was": null}, {"n": "truncate_to_chars", "k": "fn", "d": "char-safe truncation helper for extracted text", "was": null}], "identity": [{"n": "IdentityResolver", "k": "trait", "d": "mints, links, or looks up stable user identity", "was": "RebornIdentityResolver"}, {"n": "UserDirectory", "k": "trait", "d": "administrative user enumeration kept apart from minting", "was": "RebornUserDirectory"}, {"n": "IdentityStore", "k": "struct", "d": "filesystem-backed principal-identity and profile store (channel binding lives in extension_host)", "was": "RebornIdentityStore"}, {"n": "ResolveExternalIdentity", "k": "struct", "d": "resolution request; channel actors barred from minting", "was": null}, {"n": "SurfaceKind", "k": "enum", "d": "browser-OAuth versus channel surface; gates minting rights", "was": null}, {"n": "UserRecord", "k": "struct", "d": "minimal durable user profile", "was": "RebornUser"}, {"n": "UserIdentityBindingStore", "k": "trait", "d": "NOT absorbed — refuted 2026-08-04; the port stays in host_api::user_identity, implemented by extension_host", "was": "RebornUserIdentityBindingStore"}, {"n": "ProjectRepository", "k": "trait", "d": "project and membership persistence contract", "was": null}, {"n": "ProjectMemberRecord", "k": "struct", "d": "membership record backing the access ACL", "was": null}], "llm": [{"n": "LlmProvider", "k": "trait", "d": "the model-provider contract; one adapter per vendor", "was": null}, {"n": "CompletionRequest", "k": "struct", "d": "provider-neutral completion request shape", "was": null}, {"n": "LlmError", "k": "enum", "d": "shared contract error every provider maps into", "was": null}, {"n": "ProviderRegistry", "k": "struct", "d": "provider registry and selection layer", "was": null}, {"n": "RetryProvider", "k": "struct", "d": "retry reliability decorator around any provider", "was": null}, {"n": "FailoverProvider", "k": "struct", "d": "failover decorator with cooldown", "was": null}, {"n": "CircuitBreakerProvider", "k": "struct", "d": "circuit-breaking decorator", "was": null}, {"n": "HttpInterceptor", "k": "trait", "d": "recording seam capturing provider HTTP exchanges", "was": null}, {"n": "TraceFile", "k": "struct", "d": "recording vocabulary reused by trace_commons", "was": null}], "trace_commons": [{"n": "TraceClientHost", "k": "struct", "d": "host-facing Trace Commons client", "was": null}, {"n": "TraceContributionEnvelope", "k": "struct", "d": "contribution envelope schema for external submission", "was": null}, {"n": "redact_sensitive_json", "k": "fn", "d": "deterministic redaction before anything leaves the process", "was": null}, {"n": "StandingTraceContributionPolicy", "k": "struct", "d": "standing consent and contribution policy", "was": null}, {"n": "ContributionHttpSink", "k": "trait", "d": "HTTP submission seam for the external service", "was": null}, {"n": "PrivacyFilterAdapter", "k": "trait", "d": "pluggable privacy-filter sidecar seam", "was": null}, {"n": "TraceQueueHold", "k": "struct", "d": "submission-queue hold record", "was": null}, {"n": "TraceCreditEvent", "k": "struct", "d": "credits ledger event for accepted contributions", "was": null}, {"n": "DeviceKeypair", "k": "struct", "d": "device-key onboarding identity material", "was": null}], "outbound": [{"n": "OutboundPolicyService", "k": "struct", "d": "sole minter of the crate's sealed trust types", "was": null}, {"n": "ThreadProjectionAccessGrant", "k": "struct", "d": "sealed watch-authorization grant; pub(crate) construction verified", "was": null}, {"n": "ValidatedReplyTargetBinding", "k": "struct", "d": "sealed push binding preventing validator target substitution", "was": null}, {"n": "OutboundDeliveryAttempt", "k": "struct", "d": "at-most-once attempt; CAS Prepared to Sending", "was": null}, {"n": "OutboundStateStorePort", "k": "trait", "d": "outbound durable state-store port", "was": null}, {"n": "OutboundStateStore", "k": "struct", "d": "filesystem-backed state store implementation", "was": null}, {"n": "ThreadProjectionAccessPolicy", "k": "trait", "d": "untrusted policy returning claims, never grants", "was": null}, {"n": "CommunicationDeliveryResolution", "k": "enum", "d": "resolution-engine output turning intent into targets", "was": null}, {"n": "CommunicationPreferenceRepository", "k": "trait", "d": "notification opt-in preference records", "was": null}], "trust": [{"n": "EffectiveTrustClass", "k": "struct", "d": "sealed ceiling; privileged variants crate-private, no Deserialize", "was": null}, {"n": "TrustPolicy", "k": "trait", "d": "requested-to-effective trust evaluation contract", "was": null}, {"n": "HostTrustPolicy", "k": "struct", "d": "layered policy engine over policy sources", "was": null}, {"n": "TrustDecision", "k": "struct", "d": "policy-validated decision authorization consumes", "was": null}, {"n": "AuthorityCeiling", "k": "struct", "d": "resource and sandbox ceiling bound to trust", "was": null}, {"n": "PolicySource", "k": "trait", "d": "layered source seam: bundled, admin, signer", "was": null}, {"n": "InvalidationBus", "k": "struct", "d": "synchronous invalidation before superseded-ceiling side effects", "was": null}, {"n": "TrustChangeListener", "k": "trait", "d": "downgrade/upgrade notification contract", "was": null}], "authorization": [{"n": "GrantAuthorizer", "k": "struct", "d": "default-deny grant matching under the trust ceiling", "was": null}, {"n": "LeaseBackedAuthorizer", "k": "struct", "d": "authorizer honoring fingerprinted leases on resume", "was": null}, {"n": "CapabilityLease", "k": "struct", "d": "fingerprinted lease; open struct, seal is contract-level", "was": null}, {"n": "CapabilityLeaseStatus", "k": "enum", "d": "single-winner claim/dispatch transitions callers coordinate through", "was": null}, {"n": "CapabilityLeaseStorePort", "k": "trait", "d": "lease persistence port", "was": null}, {"n": "CapabilityLeaseStore", "k": "struct", "d": "filesystem-backed lease store", "was": null}, {"n": "CapabilityDispatchAuthorizer", "k": "trait", "d": "authorization decision contract the membrane calls", "was": null}, {"n": "TrustAwareCapabilityDispatchAuthorizer", "k": "trait", "d": "decision contract consuming the trust decision", "was": null}], "approvals": [{"n": "ApprovalResolver", "k": "struct", "d": "records decision durably, then issues lease, fail-closed", "was": null}, {"n": "LeaseApproval", "k": "struct", "d": "approve outcome handing a fingerprinted lease", "was": null}, {"n": "DenyApproval", "k": "struct", "d": "durable, final denial for that request", "was": null}, {"n": "PersistentApprovalPolicyStore", "k": "struct", "d": "scope-bounded always-allow policy store", "was": null}, {"n": "AutoApproveSettingStore", "k": "struct", "d": "reusable-approval settings distinct from one-shot leases", "was": null}, {"n": "CapabilityPermissionOverrideStorePort", "k": "trait", "d": "permission-override store port", "was": null}, {"n": "ApprovalRequestStore", "k": "struct", "d": "approval-request records \u2014 the durable half of consent, owned here", "was": null}, {"n": "GateRecordStore", "k": "struct", "d": "gate records for blocked invocations, resolved by this crate alone", "was": null}], "resources": [{"n": "ResourceGovernor", "k": "trait", "d": "reserve, reconcile-or-release protocol; three production impls", "was": null}, {"n": "InMemoryResourceGovernor", "k": "struct", "d": "in-memory governor implementation", "was": null}, {"n": "PersistentResourceGovernor", "k": "struct", "d": "durable governor over a store port", "was": null}, {"n": "FilesystemResourceGovernor", "k": "struct", "d": "ScopedFilesystem-backed governor", "was": null}, {"n": "ResourceLimits", "k": "struct", "d": "budget dimensions: cost, tokens, wall-clock, egress, concurrency", "was": null}, {"n": "ReservationOutcome", "k": "struct", "d": "reservation receipt closing estimate-versus-actual loop", "was": null}, {"n": "BudgetApprovalGate", "k": "struct", "d": "pause-threshold gate, deliberately distinct from capability approval", "was": null}, {"n": "BudgetEventSink", "k": "trait", "d": "budget event emission seam", "was": null}], "runtime_policy": [{"n": "resolve", "k": "fn", "d": "pure (mode, profile, org policy) to EffectiveRuntimePolicy", "was": null}, {"n": "plan_capability", "k": "fn", "d": "per-capability lane planning inside authorize reach", "was": null}, {"n": "ExecutionPlan", "k": "struct", "d": "selected lane and enforcement posture", "was": null}, {"n": "ResolveRequest", "k": "struct", "d": "resolution input; monotone authority reduction only", "was": null}, {"n": "OrgPolicyConstraints", "k": "struct", "d": "organization ceiling constraints", "was": null}], "capabilities": [{"n": "CapabilityHost", "k": "struct", "d": "the membrane; six workflows minting host_api::Authorized", "was": null}, {"n": "CapabilityObligationHandler", "k": "trait", "d": "obligation seam preparing mounts and reservations", "was": null}, {"n": "RuntimeDispatcher", "k": "struct", "d": "sole CapabilityDispatcher impl; rejects witness-lane mismatch", "was": null}, {"n": "CapabilityDispatchRegistry", "k": "struct", "d": "capability registration and binding resolution", "was": null}, {"n": "ReplayPayloadStore", "k": "struct", "d": "durable replay payloads for resumed invocations", "was": null}, {"n": "ProcessAuthorizationRemintPort", "k": "trait", "d": "re-mints authorization for background process resume", "was": null}, {"n": "ToolResolver", "k": "trait", "d": "resolves invocation to a bound capability", "was": null}, {"n": "HostPolicyFacts", "k": "trait", "d": "policy facts the fold consults", "was": null}], "processes": [{"n": "ProcessSupervisor", "k": "struct", "d": "journal supervisor: claim, lease, heartbeat, recover, contain", "was": null}, {"n": "ProcessKind", "k": "enum", "d": "registered kinds: an executor registers against one", "was": null}, {"n": "ProcessExecutor", "k": "trait", "d": "executor port a registering crate implements", "was": null}, {"n": "ProcessStorePort", "k": "trait", "d": "durable process record store port", "was": null}, {"n": "ProcessStore", "k": "struct", "d": "filesystem-backed process store", "was": null}, {"n": "ProcessRecord", "k": "struct", "d": "process identity, lineage, and status record", "was": null}, {"n": "ProcessStatus", "k": "enum", "d": "lifecycle states; terminal written once", "was": null}, {"n": "BackgroundProcessManager", "k": "struct", "d": "background-capability lifecycle over the journal", "was": null}, {"n": "ProcessHost", "k": "struct", "d": "caller-facing process spawn/track service", "was": null}], "turns": [{"n": "TurnCoordinator", "k": "trait", "d": "accept/resume/cancel; one active run per thread", "was": null}, {"n": "DefaultTurnCoordinator", "k": "struct", "d": "production coordinator implementation", "was": null}, {"n": "LoopExitApplier", "k": "struct", "d": "validates loop exit claims before durable truth", "was": null}, {"n": "LoopExitEvidencePort", "k": "trait", "d": "exit-evidence port; spec's ExitEvidencePort name not in code", "was": null}, {"n": "TurnStateRowStore", "k": "struct", "d": "durable turn state rows; becomes process-journal projection", "was": null}, {"n": "TurnStatus", "k": "enum", "d": "turn lifecycle including blocked-on-gate states", "was": null}, {"n": "SubmitTurnRequest", "k": "struct", "d": "admission request with idempotency key", "was": null}], "host_runtime": [{"n": "HostRuntime", "k": "trait", "d": "kernel-services port upper tiers consume", "was": null}, {"n": "DefaultHostRuntime", "k": "struct", "d": "production kernel service graph and membrane composition", "was": null}, {"n": "BuiltinObligationHandler", "k": "struct", "d": "audit, staging, mount, ceiling, redaction obligations engine", "was": null}, {"n": "RuntimeLaneExecutor", "k": "struct", "d": "closed lane executor; deliberately crate-private (pub(super))", "was": null}, {"n": "HostHttpEgressService", "k": "struct", "d": "mediated egress: policy, secret staging, sanitize", "was": null}, {"n": "RuntimeSecretMaterialStager", "k": "struct", "d": "one-shot secret staging and consumption", "was": null}, {"n": "InvocationServices", "k": "struct", "d": "per-invocation mediated service set", "was": null}, {"n": "RuntimeProcessPort", "k": "trait", "d": "process-lane execution port", "was": null}, {"n": "MemoryServiceResolver", "k": "struct", "d": "provider-neutral memory service resolution from assembly", "was": null}], "agent_loop": [{"n": "CanonicalAgentLoopExecutor", "k": "struct", "d": "canonical sealed executor with ordered lifecycle stages", "was": null}, {"n": "AgentLoopExecutor", "k": "trait", "d": "executor contract the planned driver invokes", "was": null}, {"n": "AgentLoopPlanner", "k": "trait", "d": "sealed planner deciding a turn's next action", "was": null}, {"n": "LoopFamilyRegistry", "k": "struct", "d": "loop-family identity and strategy registry", "was": null}, {"n": "LoopFamily", "k": "struct", "d": "one named, sealed strategy composition", "was": null}, {"n": "LoopExecutionState", "k": "struct", "d": "resumable state: refs, cursors, counters only", "was": null}, {"n": "DefaultModelStrategy", "k": "struct", "d": "exemplar of the built-in Default* strategy set", "was": null}], "loop_host": [{"n": "HostRuntimeLoopCapabilityPort", "k": "struct", "d": "base kernel-facing LoopCapabilityPort adapter", "was": null}, {"n": "ThreadBackedLoopContextPort", "k": "struct", "d": "thread-backed context port adapter", "was": null}, {"n": "ThreadBackedLoopModelPort", "k": "struct", "d": "thread-backed model port adapter", "was": null}, {"n": "CheckpointStateStore", "k": "struct", "d": "checkpoint-state store behind the checkpoint port", "was": null}, {"n": "GovernorBackedAccountant", "k": "struct", "d": "budget accountant over the resource governor", "was": null}, {"n": "HostInputQueue", "k": "trait", "d": "input-queue seam behind the input port", "was": null}, {"n": "HostManagedModelGateway", "k": "trait", "d": "model-gateway port over host-managed routes", "was": null}, {"n": "SubagentSpawnCapabilityPort", "k": "struct", "d": "subagent-spawn port implementation", "was": null}], "turn_runner": [{"n": "AgentTurnExecutor", "k": "struct", "d": "the ProcessKind::AgentTurn executor; submits claimed exits", "was": "RebornTurnRunExecutor"}, {"n": "TurnRunExecutor", "k": "trait", "d": "executor port; target: kernel-defined, runner-implemented", "was": null}, {"n": "DriverRegistry", "k": "struct", "d": "driver registry with readiness validation", "was": null}, {"n": "PlannedDriver", "k": "struct", "d": "adapts agent_loop executor to the driver contract", "was": null}, {"n": "TextOnlyModelReplyDriver", "k": "struct", "d": "smallest supported text-only driver", "was": null}, {"n": "LoopDriverHostFactory", "k": "struct", "d": "composes a claimed run's scoped port set", "was": "RebornLoopDriverHostFactory"}, {"n": "HostFactory", "k": "trait", "d": "loop-host factory seam", "was": null}, {"n": "TurnRunScheduler", "k": "struct", "d": "agent-turn projection over the process supervisor", "was": null}], "hooks": [{"n": "HookDispatcher", "k": "struct", "d": "orders and runs hooks per decision point", "was": null}, {"n": "HookRegistry", "k": "struct", "d": "registered hooks by trust tier", "was": null}, {"n": "HookTrustClass", "k": "enum", "d": "four source-fixed, never-declarable trust classes", "was": null}, {"n": "HookedLoopCapabilityPort", "k": "struct", "d": "outermost port decorator; one per Loop*Port", "was": null}, {"n": "PredicateEvaluator", "k": "struct", "d": "declarative predicate language evaluator", "was": null}, {"n": "WasmHookRuntime", "k": "struct", "d": "sandboxed engine for portable hook code", "was": null}, {"n": "PredicateStateBackend", "k": "trait", "d": "predicate persistence seam (documented idiom exception)", "was": null}], "extension_registry": [{"n": "ExtensionRegistry", "k": "struct", "d": "deterministic in-memory manifest catalog", "was": null}, {"n": "SharedExtensionRegistry", "k": "struct", "d": "shared registry handle", "was": null}, {"n": "ExtensionManifest", "k": "struct", "d": "wire manifest schema", "was": null}, {"n": "ExtensionManifestV2", "k": "struct", "d": "internal normal-form manifest", "was": null}, {"n": "ResolvedExtensionManifest", "k": "struct", "d": "resolved, digested form consumers read", "was": null}, {"n": "ManifestHash", "k": "struct", "d": "manifest content digest", "was": null}, {"n": "ExtensionInstallationStore", "k": "struct", "d": "durable installation, membership, credential-binding records", "was": null}, {"n": "ExtensionInstallationStorePort", "k": "trait", "d": "storage port behind the record store", "was": null}], "extension_host": [{"n": "ExtensionHost", "k": "struct", "d": "sole lifecycle writer: install, activate, bind, remove", "was": null}, {"n": "ActiveSnapshot", "k": "struct", "d": "active-installation snapshot with generations", "was": null}, {"n": "ExtensionIngressRouter", "k": "struct", "d": "vendor-blind inbound router", "was": null}, {"n": "verify_recipe", "k": "fn", "d": "manifest-recipe verifier; mints verified-inbound evidence", "was": null}, {"n": "ExtensionLoader", "k": "trait", "d": "loader contract: native, WASM, MCP", "was": null}, {"n": "NativeExtensionFactory", "k": "trait", "d": "binary-supplied native entrypoint factory", "was": null}, {"n": "ChannelEgressTransport", "k": "trait", "d": "host-mediated egress transport", "was": null}, {"n": "ReplyContextStore", "k": "trait", "d": "reply-context persistence seam", "was": null}, {"n": "ChannelPairingService", "k": "struct", "d": "generic pairing service core", "was": null}], "extension_manager": [{"n": "ExtensionManagementSurface", "k": "struct", "d": "extension-management ProductSurface implementation", "was": "SharedCommandSurface", "new": true}, {"n": "AvailableExtensionCatalog", "k": "struct", "d": "available-extension catalog and import path", "was": null}, {"n": "ExtensionLifecycleWorkflow", "k": "struct", "d": "lifecycle-command orchestration; calls host authority only", "was": "ExtensionLifecycleManager"}, {"n": "ProductChannelConfigService", "k": "struct", "d": "channel-configuration product service", "was": "RebornChannelConfigProductService"}, {"n": "ExtensionLifecycleProductService", "k": "struct", "d": "LifecycleProductService port implementation", "was": "ExtensionHostLifecycleProductService"}, {"n": "ProductAuthExtensionCredentialSetup", "k": "struct", "d": "credential setup and credential views", "was": null}, {"n": "ComposedAdminConfigurationService", "k": "type", "d": "admin-configuration capability service composition", "was": null}], "extension_support": [{"n": "bundled_packages", "k": "fn", "d": "the shipped package inventory", "was": null}, {"n": "PackageBundle", "k": "struct", "d": "one package's manifest and assets", "was": null}, {"n": "PackageOnboarding", "k": "struct", "d": "per-package onboarding metadata", "was": null}, {"n": "GsuiteExecutor", "k": "struct", "d": "native gsuite tool executor", "was": null}, {"n": "WebAccessExecutor", "k": "struct", "d": "native web-access tool executor", "was": null}, {"n": "GoogleCredentialResolver", "k": "struct", "d": "google credential staging resolver", "was": null}, {"n": "first_party_tool_handlers", "k": "type", "d": "builtin http/shell/time/memory/trigger/skill handlers, absorbed from host_runtime", "was": null, "new": true}], "pkg_slack": [{"n": "SlackChannelAdapter", "k": "struct", "d": "ChannelAdapter impl: parse, render, deliver", "was": null}, {"n": "SlackPreferenceTargetCodec", "k": "struct", "d": "preference-target encoding", "was": null}, {"n": "SlackInboundEvent", "k": "enum", "d": "parsed inbound payload shapes", "was": null}, {"n": "SlackUrlVerificationChallenge", "k": "struct", "d": "URL-verification handshake payload", "was": null}], "pkg_telegram": [{"n": "TelegramChannelAdapter", "k": "struct", "d": "ChannelAdapter impl: parse, render, deliver", "was": null}, {"n": "TelegramPreferenceTargetCodec", "k": "struct", "d": "preference-target encoding", "was": null}, {"n": "TelegramInboundEvent", "k": "enum", "d": "inbound payload shapes (absorbed from telegram_v2_adapter)", "was": null}, {"n": "TelegramReplyTarget", "k": "struct", "d": "reply-target binding (absorbed from telegram_v2_adapter)", "was": null}], "pkg_memory_native": [{"n": "NativeMemoryService", "k": "struct", "d": "MemoryService implementation over the backend abstraction", "was": null}, {"n": "MemoryBackend", "k": "trait", "d": "provider backend abstraction", "was": null}, {"n": "RepositoryMemoryBackend", "k": "struct", "d": "repository-composed backend", "was": null}, {"n": "FilesystemMemoryDocumentRepository", "k": "struct", "d": "filesystem document repository", "was": null}, {"n": "ChunkingMemoryDocumentIndexer", "k": "struct", "d": "chunking full-text indexer", "was": null}, {"n": "DefaultPromptWriteSafetyPolicy", "k": "struct", "d": "prompt-write-safety enforcement engine", "was": null}], "pkg_mem0": [{"n": "Mem0MemoryService", "k": "struct", "d": "MemoryService implementation over mem0 REST", "was": null}, {"n": "Mem0Transport", "k": "trait", "d": "transport seam; mock-testable without network", "was": null}, {"n": "Mem0HttpTransport", "k": "struct", "d": "hardened transport: timeout, no redirects, URL validation", "was": null}, {"n": "Mem0Config", "k": "struct", "d": "external-service configuration", "was": null}], "assistant": [{"n": "AssistantServices", "k": "struct", "d": "the canonical ProductSurface implementation (service aggregate)", "was": "RebornServices"}, {"n": "DefaultProductSurface", "k": "struct", "d": "ChannelInboundProductSurface impl: admission workflow", "was": null}, {"n": "IdempotencyLedger", "k": "trait", "d": "begin-or-replay inbound idempotency by action fingerprint", "was": null}, {"n": "FilesystemIdempotencyLedger", "k": "struct", "d": "durable ledger implementation", "was": "RebornFilesystemIdempotencyLedger"}, {"n": "DeliveryCoordinator", "k": "struct", "d": "decides delivery target, retries, and reply context", "was": null}, {"n": "ProductCommand", "k": "enum", "d": "the product command grammar", "was": null}, {"n": "ProductCommandAdmission", "k": "enum", "d": "admission decision vocabulary", "was": null}, {"n": "DefaultApprovalInteractionService", "k": "struct", "d": "click-approval service over redacted read models", "was": null}, {"n": "DefaultAuthInteractionService", "k": "struct", "d": "click-auth service over redacted read models", "was": null}], "operator": [{"n": "ProviderAdmin", "k": "struct", "d": "LLM provider registry administration", "was": "RebornProviderAdmin"}, {"n": "OperatorLlmConfigService", "k": "struct", "d": "LlmConfigService port implementation", "was": "RebornLlmConfigService"}, {"n": "ProviderActiveModelReader", "k": "struct", "d": "active-model selection reader", "was": null}, {"n": "LlmKeyStore", "k": "struct", "d": "provider key management", "was": null}, {"n": "ProviderRepo", "k": "struct", "d": "provider registry write-side", "was": null}, {"n": "OperatorLogBuffer", "k": "struct", "d": "operator log ring", "was": null}, {"n": "OperatorServiceLifecycle", "k": "struct", "d": "platform service-lifecycle abstraction", "was": null}, {"n": "LlmReloadAdapter", "k": "struct", "d": "provider hot-reload trigger", "was": "RebornLlmReloadAdapter"}], "openai_compat": [{"n": "OpenAiChatCompletionsWorkflow", "k": "struct", "d": "chat-completions workflow over BoundProductSurface", "was": null}, {"n": "OpenAiCompatRouteSurface", "k": "enum", "d": "route descriptor surface", "was": null}, {"n": "OpenAiCompatError", "k": "struct", "d": "sanitized error envelope", "was": null}, {"n": "OpenAiCompatRefStore", "k": "struct", "d": "surface-scoped ref and idempotency store", "was": null}, {"n": "OpenAiCompatIdempotencyKey", "k": "struct", "d": "request idempotency key", "was": null}, {"n": "OpenAiChatCompletionRequest", "k": "struct", "d": "wire DTO for chat completions", "was": null}], "webui": [{"n": "WebuiAuthenticator", "k": "trait", "d": "host-authenticator contract at the listener", "was": null}, {"n": "CompositeAuthenticator", "k": "struct", "d": "bearer, session, OIDC authenticator composition", "was": null}, {"n": "SessionAuthenticator", "k": "struct", "d": "session-backed authenticator", "was": null}, {"n": "OidcAuthenticator", "k": "struct", "d": "OIDC authenticator", "was": null}, {"n": "WebuiServeConfig", "k": "struct", "d": "gateway assembly: middleware order and routes", "was": null}, {"n": "WebuiServeOptions", "k": "struct", "d": "serve-entry options", "was": "RebornWebuiServeOptions"}, {"n": "serve_webui", "k": "fn", "d": "the serve loop entry", "was": "serve_webui_v2"}, {"n": "WebChatEvent", "k": "enum", "d": "WebChat stream event vocabulary", "was": "WebChatV2Event"}, {"n": "OAuthRouterConfig", "k": "struct", "d": "OAuth login stack (permitted vendor exception)", "was": null}], "host_ingress": [{"n": "PublicRouteMount", "k": "struct", "d": "public router plus policy descriptor carrier", "was": null}, {"n": "ProtectedRouteMount", "k": "struct", "d": "authenticated route carrier", "was": null}, {"n": "SplitRouteMount", "k": "struct", "d": "combined public and protected carrier", "was": null}, {"n": "PublicRouteDrain", "k": "trait", "d": "shutdown drain hook", "was": null}], "composition": [{"n": "HostBindings", "k": "struct", "d": "binary-supplied opaque adapter binding input", "was": "RebornHostBindings"}, {"n": "RuntimeInput", "k": "struct", "d": "assembly input: config, bindings, backends", "was": "RebornRuntimeInput"}, {"n": "ServiceGraph", "k": "struct", "d": "assembled service-graph handle: product, auth, readiness methods", "was": "RebornRuntime"}, {"n": "CompositionProfile", "k": "enum", "d": "closed deployment-profile selection", "was": "RebornCompositionProfile"}, {"n": "DeploymentConfig", "k": "struct", "d": "deployment configuration as data", "was": null}, {"n": "StorageShape", "k": "enum", "d": "storage backend selection", "was": null}, {"n": "ChannelExtensionBinding", "k": "struct", "d": "typed extension identity paired with adapter handle", "was": null}, {"n": "AdminApiTokenMinter", "k": "trait", "d": "token-minting port only the binary satisfies", "was": null}, {"n": "ReadinessState", "k": "enum", "d": "fail-closed readiness state", "was": "RebornReadinessState"}, {"n": "build_runtime", "k": "fn", "d": "owner-factory assembly entry", "was": "build_reborn_runtime"}], "cli": [{"n": "ServeInvocation", "k": "struct", "d": "parsed serve command invocation", "was": null}, {"n": "serve_invocation", "k": "fn", "d": "serve command entry", "was": null}, {"n": "SignedSessionTokenMinter", "k": "struct", "d": "sole AdminApiTokenMinter implementation (crate-private by design)", "was": null}, {"n": "TraceChannelArg", "k": "enum", "d": "trace command channel selector", "was": null}], "config": [{"n": "ConfigFile", "k": "struct", "d": "config.toml schema", "was": "RebornConfigFile"}, {"n": "BootConfig", "k": "struct", "d": "resolved boot configuration", "was": "RebornBootConfig"}, {"n": "Home", "k": "struct", "d": "resolved ironclaw home directory", "was": "RebornHome"}, {"n": "Profile", "k": "enum", "d": "deployment profile", "was": "RebornProfile"}, {"n": "StorageBackend", "k": "enum", "d": "storage backend choice", "was": null}, {"n": "InlineSecretError", "k": "struct", "d": "parse-time inline-secret rejection", "was": null}, {"n": "BudgetDefaults", "k": "struct", "d": "budget environment defaults", "was": null}], "architecture_tests": [{"n": "dependency_boundaries", "k": "fn", "d": "layer and family dependency matrix enforcement", "was": "reborn_dependency_boundaries"}, {"n": "retired_taxonomy", "k": "fn", "d": "pins retired vocabulary at zero", "was": "reborn_retired_taxonomy"}, {"n": "service_method_freeze_ratchet", "k": "fn", "d": "ProductSurface frozen-method-set ratchet", "was": "reborn_service_method_freeze_ratchet"}, {"n": "naming_rule_assertions", "k": "fn", "d": "the section 5.1 and 11.2.11 naming-rule enforcement", "was": null, "new": true}], "libsql_runtime": [{"n": "LibSqlRuntime", "k": "struct", "d": "one read pool + one write lane for a single libSQL database", "was": null}, {"n": "LibSqlReadConnectionLease", "k": "struct", "d": "read-only checkout; exposes queries, never the connection", "was": null}, {"n": "LibSqlWriteConnectionLease", "k": "struct", "d": "the single writer slot; non-reentrant by construction", "was": null}, {"n": "LibSqlLane", "k": "enum", "d": "read or write — names the admission lane without naming a target", "was": null}, {"n": "LibSqlCheckoutFailureReason", "k": "enum", "d": "typed checkout failure so adapters classify without parsing text", "was": null}, {"n": "LibSqlRuntimeError", "k": "enum", "d": "redacted runtime failures safe to map at a storage boundary", "was": null}]}; const DETAIL = {"host_api": {"about": "The zero-dependency vocabulary crate the whole workspace is built on. It defines the identity, scope, path, and mount types; the capability, action, decision, and approval shapes that describe a requested effect and the host's verdict on it; the closed RuntimeLane enum naming the four execution mechanisms; and the complete canonical turn vocabulary any crate touching a turn needs. It also declares the system's two most privileged types — the sealed Authorized witness proving the kernel approved an invocation, and the CapabilityDispatcher port that witness is handed to — while constructing and executing nothing itself. Every other crate in the system resolves to this one somewhere in its dependency graph.", "sig": "// zero-dependency authority vocabulary — declares, never executes\npub struct Authorized { .. } // sealed witness; kernel-minted only\npub trait CapabilityDispatcher { fn dispatch(&self, auth: Authorized, ..) -> ..; }\npub enum RuntimeLane { FirstParty, Wasm, Mcp, Process } // closed set\npub enum TrustClass { .. } // privileged variants serde-sealed\npub trait RuntimeHttpEgress { .. } // host-mediated outbound HTTP port\npub mod turn { /* TurnStatus, turn/run ids, reply-target refs */ }\npub mod ingress { /* IngressRouteDescriptor, IngressPolicy, ListenerClass */ }", "security": "Holds the sealed constructors for Authorized, the privileged TrustClass variants, and bearer/session evidence — the types every privileged path is built on — so forging authority is a compile error rather than a review finding.", "why": "Nearly every crate in the workspace depends on it, and its zero-dependency posture is what makes that safe — any dependency added here becomes a dependency of the entire system."}, "common": {"about": "A small crate of domain-free primitives shared across every layer: the identity newtypes (CredentialName, ExtensionName, McpServerName, ExternalThreadId) that keep string identities strongly typed, plus PKCE, hashing, path, and timezone helpers and a generic attachment-reference vocabulary. It is data, not behavior — it defines almost no traits and does no I/O. It is also the one sanctioned home for a documented wire-compatibility exception on persisted identity formats, so that exception can never quietly reappear anywhere else.", "sig": "pub struct CredentialName(..); // backend secret identity\npub struct ExtensionName(..); // installed-extension identity\npub struct McpServerName(..);\npub struct ExternalThreadId(..); // channel-supplied thread id\n// the identity newtypes carry the documented persisted-compat exception\npub mod pkce; pub mod hashing; pub mod paths; pub mod timezone;\npub struct AttachmentRef { .. } // generic attachment + format vocabulary", "security": "Domain-ownership boundary for cross-domain primitives, and the sole crate permitted to carry a documented persisted-wire-compatibility exception rather than a clean invariant.", "why": "Genuinely domain-free primitives with consumers in every layer need one shared leaf home, and the persisted-compatibility exception needs exactly one place to live."}, "prompt_envelope": {"about": "A tiny, dependency-free crate with one job: wrapping untrusted text in an explicit trust envelope before it is shown to a model. Content from memory, hooks, or skills passes through wrap_untrusted, which tags it with a closed EnvelopeSource, rejects known instruction-hijack markers, and caps its byte size — so the model always sees where a snippet came from and prompt-injection attempts are fenced at the source. It is pure functions over closed enums; adding a new source is a reviewed, security-relevant API change, never a routine edit.", "sig": "pub enum EnvelopeSource { Memory, Hook, Skill } // closed — new source = contract review\npub enum EnvelopeTrust { Trusted, Untrusted }\npub fn wrap_untrusted(text, source: EnvelopeSource) -> EnvelopedContent;\npub fn wrap_untrusted_with_limit(text, source, max_bytes) -> EnvelopedContent;\n// instruction-hijack marker denylist + byte budget applied inside", "security": "It is the prompt-injection fence: every path that hands untrusted, source-attributed text to a model must pass through this envelope.", "why": "Its three consumers each own exactly one EnvelopeSource variant, and folding it into the larger safety crate would hand each of them a heavy pattern-matching dependency cone for one small security-critical function."}, "loop_contracts": {"about": "The contract between the agent loop — the replaceable userland code that decides what to do next in a turn — and the turn kernel that supervises it. It defines the eleven Loop*Port traits (capability, model, prompt, transcript, context, input, run-info, cancellation, compaction, progress, checkpoint) a host implements to expose services to a loop, the AgentLoopDriver a loop implements, the run-profile vocabulary describing what a run may use, and LoopExit — the loop's claim about how its turn ended, which only the kernel may validate into a durable transition. Because a loop may depend on this crate and nothing else, a loop implementation never has a reason to import kernel internals.", "sig": "pub trait LoopCapabilityPort { .. } // + Model, Prompt, Transcript, Context,\npub trait LoopInputPort { .. } // RunInfo, Cancellation, Compaction,\npub trait LoopCheckpointPort { .. } // Progress — 11 Loop*Port traits total\npub trait AgentLoopDriverHost { .. } // blanket: all ports exposed together\npub trait AgentLoopDriver { fn run(&self, host, profile: ResolvedRunProfile) -> LoopExit; }\npub struct LoopExit { .. } // a claim; only the kernel validates it\npub trait CheckpointStateStorePort { .. }", "security": "It is the typed membrane between untrusted, replaceable loop userland and the kernel — every privileged effect a loop wants crosses one of these ports, never a direct kernel call.", "why": "The loop-hosting tier, the hook framework, and the kernel crates all need exactly this vocabulary without importing the turn kernel, and keeping it separate lets the kernel evolve its state machinery without touching the loop-side contract."}, "extension_contracts": {"about": "The neutral vocabulary of what an installable extension is and exposes. It defines ChannelAdapter — the trait a channel package implements once for inbound message normalization, outbound rendering and delivery, and target resolution — plus ToolAdapter for model-callable tools, the Extension and ExtensionEntrypoint types every package exposes, the manifest-surface descriptors and auth-recipe schema a manifest compiles into, and the caller-visible lifecycle states. It also holds the sealed verified-inbound evidence: only the generic ingress verifier that actually checked a webhook's signature can mint the proof it was verified. Lanes, the extension host, packages, and product all speak this one vocabulary — and depending on it pulls in neither the registry nor product, so a channel package needs this crate and nothing else.", "sig": "pub trait ChannelAdapter {\n fn normalize_inbound(&self, inbound: VerifiedInbound) -> InboundOutcome;\n fn deliver(&self, envelope: OutboundEnvelope) -> DeliveryReport;\n fn resolve_targets(&self, query: TargetQuery) -> Vec;\n}\npub trait ToolAdapter { .. } // + RestrictedEgress\npub trait ExtensionEntrypoint { fn extension(&self) -> Extension; }\npub enum InstallationState { .. } pub enum LifecyclePublicState { .. }\n// VerifiedInbound is sealed — mintable only by the generic ingress verifier", "security": "It owns inbound-verification evidence minting exclusively, so a channel package can misreport parsed content but can never forge that a request was verified or widen its own scope.", "why": "It is the one contract that lets every lane, the generic extension host, every channel package, and the extension manager share a vocabulary with no dependency on the registry or on product."}, "product_contracts": {"about": "The vocabulary of the product boundary — everything a transport needs to talk to the product tier without importing its implementation. It defines ProductSurface, the single generic invoke/query/stream entry point the web UI, the OpenAI-compatible adapter, and channel packages all call through; the descriptor types for commands, views, and capabilities; the full AppEvent wire enum a transport streams to clients; and the product-side ports — channel delivery resolution, command admission, the operator's LLM-config, logs, status, and lifecycle services — whose implementations live beside or below product. Each port is defined once here and implemented by exactly one owning crate.", "sig": "pub trait ProductSurface {\n fn invoke(&self, caller: ProductSurfaceCaller, ..) -> ..;\n fn query(&self, ..) -> ..;\n fn stream(&self, ..) -> ..;\n}\npub struct ProductSurfaceCommandDescriptor { .. } // + view/capability descriptors\npub enum AppEvent { .. } // the full event wire enum transports stream\npub trait ChannelDeliveryResolver { .. } // implemented beside the channel, not here\npub trait LlmConfigService { .. } // operator service ports declared here", "security": "It is the compile-time enforcement that a transport consumes DTOs and descriptors, never an implementation — the discipline that keeps the web UI and every channel package out of product's internals.", "why": "Declaring the operator's, the channels', and the extension host's ports here, once, removes every reason for a transport or collaborator to depend on product's full implementation just to see its own contract."}, "filesystem": {"about": "The universal storage fabric everything durable is built on. It defines the RootFilesystem trait — read, write, list, stat, append, and transactional operations over a virtual path space — with disk, in-memory, and durable SQL backends behind it; a ScopedFilesystem wrapper that resolves a caller's mount view before any operation, so a caller can only touch paths its mounts grant; a mount catalog that routes one composite path space across multiple backends; and a bounded-retry compare-and-swap primitive every durable record type uses for safe concurrent updates. Domain crates hold a handle to the trait, never to a concrete backend, so a backend swap never touches them.", "sig": "pub trait RootFilesystem {\n fn read(&self, path) -> ..; fn write(&self, path, ..) -> ..;\n fn list(&self, ..) -> ..; fn stat(&self, ..) -> ..;\n // append + transactional ops; backend capability negotiation\n}\npub struct ScopedFilesystem { .. } // resolves the caller's mount view first\npub struct MountDescriptor { .. } // composite mount-catalog routing\npub fn cas_update(..) -> ..; // bounded-retry compare-and-swap floor", "security": "Path containment and mount authority are enforced here on every call, and the crate isolates the storage-driver cone for everything above it — the durable event backend is the only other crate sanctioned to carry a driver of its own.", "why": "One contract with many production backends and a driver cone wide enough that no other crate should acquire it by accident."}, "secrets": {"about": "Secret custody: encrypted, scoped storage for credentials with a one-shot lease model. A caller leases a secret and consumes that lease exactly once to read the raw material — a compare-and-swap guarantee that raw values are never left sitting readable. The crate builds its store on the filesystem fabric, layers a credential broker on top, performs the authenticated encryption itself, and protects the master key through the operating-system keychain. Only the auth engine may reach it directly; every other consumer goes through a kernel-mediated port.", "sig": "pub trait SecretStorePort {\n fn lease_once(&self, ..) -> ..; // mint a one-shot lease (CAS)\n fn consume(&self, lease) -> ..; // raw material readable exactly once\n}\npub struct SecretStore { .. } // generic over the filesystem fabric\npub struct CredentialBroker { .. }\npub trait CredentialAccountStore { .. } pub trait CredentialSessionStore { .. }", "security": "Its entire reason to exist is the custody invariant that a secret's raw material is readable only at one-shot lease consumption.", "why": "A custody contract this tight needs its own crate to keep cryptography and keychain dependencies out of every other crate and to make its direct-consumer boundary enforceable."}, "network": {"about": "The outbound-network policy boundary and the hardened HTTP transport behind it. Before any call leaves the system it passes a static policy check on target and method, URL hardening with credential-in-path detection, and a DNS resolver that refuses private and reserved addresses — then runs over a pinned transport with redirect and size hardening. It exposes the egress, transport, and resolver ports with one production implementation each, keeping any HTTP client or TLS stack out of every crate above the kernel's mediated-egress seam.", "sig": "pub struct StaticNetworkPolicyEnforcer { .. } // target + method policy match\npub trait NetworkHttpEgress { .. } // policy-checked egress port\npub trait NetworkHttpTransport { .. } // pinned, hardened outbound transport\npub trait NetworkResolver { .. } // denies private/reserved addresses\n// URL hardening + credential-in-path detection before any connection opens", "security": "It is the sole owner of egress policy — no connection is opened before target, method, and resolved address pass its checks.", "why": "The sole egress-policy owner carries a real HTTP and DNS dependency cone that would otherwise land in the build graph of every crate needing even the policy types."}, "safety": {"about": "The detection-and-redaction toolkit: pattern-based scanning that answers whether a piece of text looks like a prompt-injection attempt, a leaked credential, or a sensitive path — as a typed result a caller can act on. One SafetyLayer call composes a sanitizer for untrusted text, a validator for provider-bound content, a policy engine, and a leak detector for credential material about to leave a trust boundary; display redaction produces a safe-to-show form of values that must never appear raw. It detects and redacts only — it never enforces containment, stores secrets, or makes authority decisions.", "sig": "pub struct SafetyLayer { .. } // sanitizer + validator + policy + leak detector\npub struct Sanitizer; // injection-pattern scan over untrusted text\npub struct Validator; // structural + size checks, provider-bound content\npub struct LeakDetector; // credential material leaving a trust boundary\n// + credential detection, sensitive-path classification, display redaction", "security": "It is the mechanism that turns \"does this look like an attack, a leak, or a sensitive path\" into a typed, testable answer that kernel obligations, filesystem, memory, and hooks all act on.", "why": "A pattern-matching dependency cone wide enough to isolate, serving detection needs from nearly every layer without any caller needing to know how the detection works."}, "observability": {"about": "The smallest crate in the workspace: latency-trace macros any crate can use to time an operation and record its outcome against a dedicated trace target. When that target is disabled the macros cost nothing, so instrumentation can stay in place permanently. It re-exports the tracing facade so a consumer needs no tracing import of its own, and it carries no state, policy, or sinks — its only decision is whether a trace fires.", "sig": "// zero-cost-when-off timing over the `ironclaw_latency` trace target\nlive_latency_trace!(op, { .. }); // records elapsed time + outcome\nlive_latency_trace_ok!(op, { .. });\nlive_latency_trace_error!(op, { .. });\npub use tracing; // deliberate macro-hygiene re-export", "security": "", "why": "A leaf macro surface consumed across kernel, loop, and app tiers alike — folding it into any one consumer would force every other consumer to depend on that crate just for a timing macro."}, "event_log": {"about": "The vocabulary and traits for the system's record of what happened. Producers everywhere record redacted RuntimeEvent and SecurityAuditEvent entries through the best-effort EventSink and AuditSink traits or the explicit-error DurableEventLog and DurableAuditLog traits, and consumers resume replay from a monotonic per-stream EventCursor — with an explicit replay-gap error when a cursor is older than the earliest retained entry. The crate carries no storage driver at all, and its sanitizing constructors are where redaction is enforced, so nothing unsafe can even be expressed as an event.", "sig": "pub struct EventCursor; // monotonic per-stream; replay-gap error on rebase\npub struct RuntimeEvent { kind: RuntimeEventKind, .. } // sanitizing constructors\npub struct SecurityAuditEvent { .. }\npub trait EventSink { .. } // best-effort; failure never alters outcomes\npub trait AuditSink { .. }\npub trait DurableEventLog { .. } // explicit-error append + cursor replay\npub trait DurableAuditLog { .. }", "security": "Its constructors own the redaction invariant at the point of construction — every durable or replayable entry has secrets, host paths, tokens, approval reasons, and lease material collapsed into bounded safe classifications before it exists.", "why": "It is the one neutral contract every producer needs, and keeping it driver-free is what spares every producer a database and TLS stack it never touches."}, "event_store": {"about": "Where event and audit logs actually become durable. The assembly layer hands it a backend-selection configuration; it validates that configuration fail-closed for production profiles — no silent fallback to a non-durable or ambiguous backend — and returns a paired durable event log and audit log handle backed by concrete adapters over the storage fabric, anchored at a dedicated events root. A coalescing sink absorbs high-frequency producers. It is the only crate in the events family allowed to carry a database or TLS driver, so that cone never leaks to producers or consumers.", "sig": "pub struct EventStoreConfig { .. } // fail-closed production validation\n// backend-selection entry point:\npub async fn build_event_stores(config: EventStoreConfig)\n -> (impl DurableEventLog, impl DurableAuditLog);\n// per-backend adapters over the storage fabric, events-root anchored\n// + a coalescing sink for high-frequency producers", "security": "It enforces fail-closed backend selection as policy: a production profile must explicitly accept single-node durability modes and must reject cleartext or ambiguous remote targets, with no implicit in-memory fallback.", "why": "It is the only events crate permitted a database and TLS driver cone, and isolating it means nothing that produces or consumes events ever compiles that cone."}, "event_projections": {"about": "Read models rebuilt from the event log on demand. Its EventProjectionService and AuditProjectionService replay the log into scoped, metadata-only views — a thread timeline, a run-status projection, a capability-activity projection — bounded by cursor and page size, with a rebase ceiling past which a consumer must request a fresh snapshot instead of an incremental replay. Every request is scope-checked by tenant, actor, and read scope. The crate holds no store and no write port of any kind: a projection is always derived state, never authority, and that is structural — it has nothing to write with.", "sig": "pub trait EventProjectionService {\n fn snapshot(&self, scope, ..) -> ..; // tenant/actor/read-scope checked\n fn replay(&self, from: EventCursor, ..) -> ..; // bounded page; rebase-required error\n}\npub trait AuditProjectionService { .. }\n// read-model DTOs: thread timeline, run status, capability activity\n// no write port exists anywhere in this crate — derived state only", "security": "Its dependency surface makes \"projections never write authority\" a structural fact rather than a review discipline — a projection failure can be observed but can never mutate anything.", "why": "Isolating replay folding from stream subscription means a projection failure can never touch a live subscription, and isolating it from storage drivers makes non-writing enforceable by what the crate is permitted to link."}, "event_streams": {"about": "The stream manager that decides who may watch the event record, without ever sending anything itself. EventStreamManager authorizes a subscriber by actor, scope, view, and target before returning any snapshot, replay, or live delivery; admits subscriptions under an RAII permit so an abandoned stream always frees its slot; stitches bounded live and replay delivery over the projections; and validates redaction on every value before it crosses toward a subscriber. It reads outbound push candidates through one read-only method, because watching and pushing are always two separate authorization decisions. Transport framing such as SSE or WebSocket lives with the product tier, never here.", "sig": "pub struct EventStreamManager<..> { .. } // generic over 5 injected collaborators:\n// projection access policy · subscription admission policy · live-update\n// source · redaction validator · outbound-state lookup\nfn subscribe(actor, scope, view, target) -> ..; // authorized before any delivery\n// RAII admission permit — an abandoned subscription releases its slot\n// read-only push-candidate lookup; this crate never sends", "security": "Everything crossing toward a subscriber fails closed on raw prompts, tool input or output, secrets, host paths, fingerprints, approval reasons, and lease material — and subscription authorization is always independent of push-delivery authorization.", "why": "It is the only events crate trusted to read outbound delivery state, and isolating that one dependency lets the rest of the family be reasoned about without ever considering delivery semantics."}, "wasm": {"about": "The execution lane for WebAssembly components — the sandboxed plugin format extensions ship tools in. It loads, compiles, and validates an already-selected component, then runs it in a fresh store per call under fuel, epoch, memory, table, and instance ceilings. Every capability a component can see from the host — HTTP, workspace files, secrets, tool invocation, even the clock — is a trait with a deny-by-default implementation, so a component gets exactly what the assembly layer explicitly wires and nothing by omission. The lane never decides whether work is allowed; authorization arrives sealed before it ever sees a request.", "sig": "// deny-by-default host-import trait family:\npub trait WasmHostHttp { .. }\npub trait WasmHostWorkspace { .. }\npub trait WasmHostSecrets { .. }\npub trait WasmHostTools { .. }\npub trait WasmHostClock { .. }\n// fresh store per call; fuel/epoch/memory/table/instance ceilings\n// + generated component bindings over the canonical wit/ definitions", "security": "Every host capability is deny-by-default and must be explicitly wired, and fresh-store-per-call plus resource ceilings bound a hostile component's blast radius before any host-import decision even matters.", "why": "No other crate needs a WASM engine, and executing untrusted, model-selected component code is a genuine trust boundary that deserves its own isolated dependency cone."}, "wasm_limiter": {"about": "A single shared resource limiter for every WebAssembly host in the workspace. WasmResourceLimiter tracks memory growth against a ceiling and caps tables, instances, and memory counts, implementing the WASM runtime's resource-limiter interface. Both the tool-execution lane and the hook engine install this same type, so two independent WASM hosts can never quietly drift apart on what a component is allowed to consume. It depends on nothing internal — and that emptiness is the point, since its two consumers must not depend on each other.", "sig": "pub struct WasmResourceLimiter { .. }\nimpl WasmResourceLimiter {\n pub fn new(memory_limit: u64) -> Self;\n pub fn memory_used(&self) -> u64; // usage accessors\n pub fn memory_limit(&self) -> u64;\n}\n// implements the WASM runtime's resource-limiter interface\n// shared by the tool lane and the hook engine — limits cannot diverge", "security": "It enforces the resource-ceiling half of the WASM trust boundary identically for every WASM host, closing the door on two hosts silently diverging on limits.", "why": "One behavior shared by two hosts that must not depend on each other becomes an explicit, tooling-visible edge instead of a duplicated implementation neither host owns."}, "mcp": {"about": "The execution lane for MCP servers — external tool servers speaking the Model Context Protocol over JSON-RPC. It discovers a server's tools and translates them into capabilities the system can dispatch, negotiates protocol versions, and executes calls. Its defining constraint is that it owns no network access of its own: every outbound request is planned by an egress planner and executed through an injected, host-mediated HTTP port, so the lane physically cannot originate a connection the kernel has not mediated. Like every lane, it runs only work that arrives already authorized.", "sig": "pub struct McpRuntime { .. } // the MCP lane\npub struct McpRuntimeConfig { .. }\npub trait McpClient { .. } // JSON-RPC + protocol-version handling\npub trait McpHostHttp { .. } // injected host-mediated HTTP —\npub trait McpHostHttpEgressPlanner { .. } // never a lane-owned client\npub struct McpToolDiscoveryOutput { .. } // discovered tools -> capabilities", "security": "It proves the host-mediated-HTTP-only invariant in code — every outbound MCP call routes through an injected egress port, never a lane-owned client.", "why": "A distinct protocol lane with its own discovery and JSON-RPC surface stays out of the WASM and sandbox lanes' dependency graphs, and keeps theirs out of its own."}, "sandbox": {"about": "The execution lane for real operating-system processes, run inside containers. A caller describes work as a typed SandboxProcessPlan — an install phase and a credentialed-run phase, each with its own scoped mounts, network policy, and credential bindings — and only a ValidatedSandboxProcessPlan can execute, so raw container flags, raw host paths, and raw secret material can never be smuggled through plan input. The container backend implements the kernel's SandboxCommandTransport port and carries a per-tenant certificate authority for egress interception plus a credential firewall that stages exactly the credential an invocation is entitled to. A deployment with no container backend degrades to no shell at all — never to a silently unsandboxed process.", "sig": "pub struct SandboxProcessPlan { .. } // install phase + credentialed-run\n// phase, each with scoped mounts, a network plan, credential bindings\npub struct ValidatedSandboxProcessPlan { .. } // the only form that can execute\nimpl SandboxCommandTransport for /* container backend */ { .. } // kernel's port\n// per-tenant CA: root key never leaves memory, never returned to a caller\n// credential firewall: staged one-shot entitlements; consumer sees yes/no only", "security": "It carries the lane family's most detailed containment story: only a real container boundary contains a spawned process, the per-tenant CA root key never touches disk, credentials arrive only through staged one-shot entitlements, and a missing backend degrades to no shell rather than an unsandboxed one.", "why": "The container and certificate-authority dependency cone is a genuinely different trust environment than the rest of the kernel service graph, and isolating it keeps that cone — and its elevated review scrutiny — out of every other crate's build."}, "threads": {"about": "Keeps the canonical transcript of every conversation session: the ordered messages, tool results, and supporting records that make up thread history. Everything that reads or writes that history — conversation binding, product surfaces, the extension host, composition — goes through one contract, SessionThreadService, which ships as a durable filesystem-backed implementation plus an in-memory one for deterministic tests. It also maintains derived indexes (chronological, sequence, lookup) and display-oriented projections such as summaries and attachment context, so readers get those views without rebuilding them and without the projections becoming a second source of truth.", "sig": "trait SessionThreadService {\n fn append(...) -> ...; // messages, tool-result records\n fn read(...) -> ...; // transcript views\n fn query(...) -> ...; // chronological / sequence / lookup indexes\n}\n// impls: filesystem-backed over ScopedFilesystem (durable) + in-memory (tests)\n// projections: summaries, attachment context, capability display previews", "security": "", "why": "One contract with several independent consumers and two production-shaped implementations — substantial enough on its own that folding it into a neighboring domain would make that neighbor a dumping ground."}, "conversations": {"about": "The boundary where an outside message becomes work the system can run. When a channel adapter hands over an event from an external platform, this crate resolves the external actor and conversation into canonical bindings (stable internal identities), deduplicates repeat deliveries of the same event, and submits the resulting turn for admission. It owns the durable conversation-state store and the binding value types, and it classifies turn-submission failures into retry-or-reject decisions, since it owns the inbound-turn error vocabulary those failures are expressed in. The user identity itself arrives already resolved — minting stable user ids is the identity crate's job alone.", "sig": "trait InboundConversationService {\n // external event -> canonical binding -> turn submission\n fn accept(actor: ExternalActorRef, convo: ExternalConversationRef, ...) -> ...;\n}\ntrait ConversationStateStore { ... } // durable over ScopedFilesystem; real in-memory impl for tests\n// consumes — never mints — TriggerTrustedInboundBinding from ironclaw_triggers\n// turn-submission failures -> retry-or-reject classification", "security": "Jointly guards the trusted-trigger ingress path with triggers — the one host-minted inbound path outside the generic ingress verifier — consuming the sealed binding but never minting it.", "why": "A distinct identity-and-idempotency authority consumed independently by the extension host, product, and composition."}, "triggers": {"about": "Owns scheduled triggers: durable records that say 'start this work on this schedule.' It validates cron expressions and timezones, derives a deterministic identity for every fire so the same tick can never run twice, and supplies the per-tick evaluation step the background poller runs — built against repository, materializer, submitter, and state-lookup ports this crate defines. When a fire is submitted, the crate seals a trusted-submission binding proving it came from its own poller — evidence no other code can forge.", "sig": "// record grammar: cron + timezone validation, deterministic fire identity\ntrait TriggerRepository { ... } // plus materializer, submitter, state-lookup ports\nstruct TriggerPollerWorker;\nimpl TriggerPollerWorker {\n fn tick_once(&self) -> ...; // evaluate due fires against the ports\n}\nstruct TriggerTrustedInboundBinding; // sealed — mintable only by this crate's poller path", "security": "One of only two trust-minting domain authorities: its sealed trusted-submission binding is the host-trusted evidence that a fire came from the crate's own poller.", "why": "A distinct scheduling domain that also carries a trusted-mint authority, consumed by conversations, product, and composition."}, "memory": {"about": "Defines the provider-neutral contract for the assistant's persistent memory. It owns the MemoryService trait every memory provider implements and every memory-reading caller depends on, plus the scope and path types memory documents live under, the write-safety vocabulary a provider must enforce before persisting model-authored content, and the audit vocabulary for significant memory events. It deliberately contains no backend: concrete providers ship as extension packages, and the shared conformance suite published here is what proves any two of them interchangeable. Model-facing memory tools all follow the ironclaw.memory.* naming convention built on this contract.", "sig": "trait MemoryService {\n fn write(doc, scope) -> ...; // providers enforce prompt-write safety first\n fn search(query) -> ...;\n fn read(path) -> ...;\n}\n// scope + path value types; significant-event & audit vocabulary\n// shared conformance suite: every provider package must pass it\n// tool naming convention: ironclaw.memory.*", "security": "", "why": "One neutral contract implemented by provider extension packages above it, proven real by a conformance suite rather than by convention alone."}, "skills": {"about": "Handles skills — instruction files that extend the agent's behavior at the prompt level. It parses and validates skill definitions, deterministically scores and selects which skills apply to a given context, manages filesystem-backed installs including per-scope installs, and runs a pure learning path that improves skills over time. Anything needing inference is inverted out through SkillInferencePort, which the hosting tier implements, and callers observe skill activations through the SkillActivationObserver contract instead of reaching into the hosting tier directly.", "sig": "// parse -> validate -> score -> select (deterministic)\ntrait SkillInferencePort { ... } // inversion port — implemented by the hosting tier\ntrait SkillActivationObserver {\n fn on_event(event: ...); // observed-event vocabulary lives here\n}\n// filesystem-backed skill management, incl. scoped installs\n// pure-learning module: improves skills over time", "security": "", "why": "A self-contained contract with heavy parsing and selection logic, consumed independently by the hosting tier and by product's activation projection."}, "auth": {"about": "Runs product-facing authentication — the flows that connect a user's credentials to integrations. One generic engine performs token exchange, keepalive and refresh, and dynamic client registration, with every vendor's differences expressed as recipe data the engine consumes rather than as code branches. Around the engine sit durable flow, credential-account, interaction, and cleanup records, credential runtime selection with refresh locking, and the manual-token and gated-OAuth flows product surfaces need. Everything it exposes is a redacted data-transfer object: raw tokens, OAuth codes, and PKCE verifiers never appear in any serializable shape.", "sig": "struct AuthEngine; // token exchange, keepalive/refresh, dynamic client registration\n// vendor differences are recipe data — never a code branch\ntrait AuthRecipeResolver { ... } // implemented by the extension host\n// contract set: flow · interaction · credential-account · recovery ·\n// exchange · continuation · cleanup\n// exposes redacted DTOs only — no raw tokens, codes, or PKCE verifiers", "security": "The credential-custody domain: it holds durable token-lifecycle state but never raw secret bytes — those stay behind secret-store handles — and it never makes an authorization decision itself.", "why": "The recipe-driven engine is the crate's whole reason to exist — one of the family's two vendor-scoped charters, kept deliberately separate from model-provider sessions (llm) and host login (webui)."}, "attachments": {"about": "The one place inbound file attachments land. A channel or protocol adapter decodes its own payload into a normalized attachment, then calls this crate's single landing routine, which writes the bytes into agent-accessible, project-scoped storage under a scoped path. The ports every caller uses — InboundAttachmentLander and InboundAttachmentReader — live here beside their filesystem-backed default implementation, together with the size-ceiling constants all callers share.", "sig": "trait InboundAttachmentLander {\n fn land(attachment /* normalized by the adapter */) -> ...; // -> agent-accessible scoped path\n}\ntrait InboundAttachmentReader { fn read(...) -> ...; }\n// default impl over ScopedFilesystem lives beside the ports\n// shared size-ceiling constants for every caller", "security": "Writes only through the project-scoped filesystem authority — the same one the agent's file tools resolve through — so landing still requires an explicit mount grant even though the crate makes no authorization decision.", "why": "The single authority for a landing path several independent callers share, with port, default implementation, and shared constants in one place."}, "extractors": {"about": "Turns file bytes into text, and nothing else. A typed entry point inspects a normalized MIME type and dispatches to the right parser — PDF, Office Open XML (documents, slides, spreadsheets), legacy Office, RTF, or UTF-8 text and code — returning structured errors on failure rather than strings. It is a pure leaf: no async, no I/O, no knowledge of where the bytes came from, with decompression-bomb caps bounding per-entry and cumulative size on every ZIP-based format.", "sig": "// pure: no async, no I/O, no knowledge of the bytes' origin\nfn extract(mime /* normalized */, bytes: &[u8])\n -> Result;\n// dispatch: pdf | ooxml (word / slide / sheet) | legacy office | rtf | utf-8 text & code\n// zip-based formats bounded by decompression-bomb caps (per-entry + cumulative)", "security": "Holds no authority — its decompression-bomb caps are input hardening, not an authorization decision.", "why": "A pure leaf that keeps heavy document-parsing dependencies out of every consumer's build; attachments is its sole consumer."}, "identity": {"about": "The canonical identity layer: it maps every external identity — a browser OAuth login or a channel actor on a messaging platform — to one stable internal user identifier before any other state, such as conversation bindings or thread ownership, is touched. Its resolver mints, links, or looks up users keyed by tenant, surface, provider, provider instance, and external subject, and channel actors are explicitly barred from minting, so an unrecognized actor fails closed instead of auto-provisioning a user. It is also the durable home of the minimal user profile — email, display name, verified-email linkage, gated to browser-OAuth surfaces — plus a separate administrative user directory kept apart from the resolver so admin mutation can never perturb minting invariants. Its projects module carries the Project entity with membership and access-control records — access is resolved live on every request, never cached, so revoking a grant takes effect immediately.", "sig": "// external identity -> stable UserId, before any runtime state is touched\nfn resolve(tenant, surface, provider, provider_instance, external_subject)\n -> UserId; // mint | link | lookup — channel actors can never mint\n// minimal profile: email, display name, verified email (browser-OAuth only)\n// user directory: administrative enumeration, separate from the resolver\n// identity-binding store ports: provider identity -> UserId", "security": "The sole authority for minting new user identifiers; verified-email linking is restricted to browser-OAuth surfaces so a channel actor asserting an email can never collide with an OAuth-linked user.", "why": "A bottom-of-stack identity authority with a strictly enforced never-reach-upstream dependency rule — nothing above composition may bypass it to touch identity state."}, "llm": {"about": "The contract for talking to language-model vendors and everything needed to do it reliably. It defines LlmProvider, ships a concrete adapter for each supported provider along with each vendor's authentication and session handling, and wraps a registry-and-selection layer in reliability decorators — retry, circuit breaking, failover — that compose around any provider. It also owns recording (response caching and trace binding), the cost, transcript, and model-selection vocabulary callers use for model-adjacent bookkeeping, and a versioned model catalog shipped as a crate asset.", "sig": "trait LlmProvider { ... } // one concrete adapter per supported vendor\n// per-vendor authentication + session handling\n// registry + selection, wrapped in reliability decorators:\n// retry · circuit-breaker · failover — composable around any provider\n// recording: response caching, trace binding\n// vocabulary: cost, transcript, model selection\n// versioned model catalog shipped as a crate asset", "security": "Holds no authorization power — provider credentials, selection, and session refresh are its job, while whether a model call may happen at all is decided by the kernel before dispatch reaches it. Key custody and administration belong to operator; this crate consumes configured credentials at call time.", "why": "Isolates a heavy vendor cone — provider SDKs and their authentication flows — from every non-LLM consumer's build, as one of the family's two named vendor charters."}, "outbound": {"about": "Decides and records the authority side of outgoing deliveries — who may be notified, concrete targets, at-most-once state — while retry and reply semantics stay with the product's delivery coordinator and sending stays in transports. Its delivery-attempt store enforces at-most-once semantics through an atomic compare-and-swap reservation from prepared to sending that recovers cleanly after a crash; its resolution engine turns a delivery intent into concrete targets; and it keeps notification opt-in preferences and subscription cursors. Its policy service is the only code able to construct the sealed access-grant and delivery-binding types, and the crate has no HTTP client at all — transports live elsewhere and consume its state.", "sig": "trait OutboundStateStore { ... } // delivery attempts, preferences, subscription cursors\n// at-most-once: CAS reservation Prepared -> Sending, crash-recoverable\nstruct OutboundPolicyService; // sole constructor of the sealed types below\nstruct AccessGrant; // sealed\nstruct DeliveryBinding; // sealed\nfn resolve(intent) -> targets; // resolution engine — never a transport", "security": "An authority crate: the sole writer of delivery-attempt state and the sealed-grant minting point, with watch-authorization and push-authorization kept as deliberately separate decisions.", "why": "A distinct durable authority consumed independently by product, the extension host, and the streaming layer — the sealed-type pattern is what lets it stay a domain crate instead of moving into the kernel."}, "trust": {"about": "The first stage of the kernel's effect pipeline: it resolves the trust a package's manifest requests into the host-validated effective ceiling — the maximum authority that package may ever exercise — which every later authorization decision consumes. Its policy engine evaluates package identity, source, and requested authority under layered host policy, and invalidation is synchronous: a trust downgrade revokes affected grants before any subsequent side effect can run under the superseded ceiling. A ceiling by itself grants nothing; a caller running at an elevated ceiling still needs every later stage's explicit authorization, exactly like any other caller.", "sig": "// requested trust -> host-validated effective ceiling\nfn evaluate(package_identity, source, requested: TrustClass) -> EffectiveTrust;\nstruct EffectiveTrust {\n // sealed: privileged variants mintable only by this crate's own evaluation —\n // never deserialized from a wire type, never constructed by a caller\n}\n// layered host policy (HostTrustPolicy)\n// synchronous invalidation: a downgrade revokes affected grants before any next effect", "security": "The authority-ceiling gate: no user-installed package can fabricate a privileged ceiling by any means available to it.", "why": "The seal is a property of crate-scoped visibility — a dedicated crate is what makes 'only this code may change trust state' actually true rather than a convention."}, "authorization": {"about": "The default-deny decision stage: given a caller's effective trust ceiling and its grants and active leases, it answers allow, deny, or require-approval for the requested effect — only ever 'does a grant cover this,' never 'should one be created.' It also owns the capability-lease lifecycle: a lease is a one-shot permission sealed to a fingerprint of the exact invocation it was approved for, so approval of one input can never authorize a different one, and a single-winner claim lets a resumed, previously-approved call re-enter safely without granting a second parallel dispatch. The lease's status transitions are part of the public surface, since callers coordinate resume attempts through them.", "sig": "trait GrantAuthorizer {\n // default-deny: ceiling + grants + active leases -> verdict\n fn authorize(ceiling: EffectiveTrust, effect) -> Allow | Deny | RequireApproval;\n}\nstruct CapabilityLease { fingerprint, status } // sealed to one exact invocation\ntrait CapabilityLeaseStore { ... } // single-winner claim on resume", "security": "The default-deny gate, and the sole owner of the lease state every fingerprinted approval rides on.", "why": "Matching a static grant and resolving a one-off human decision are different questions with different failure modes — and only one of them should be able to mint a lease. Approvals' resolver is the sanctioned lease minter — a charter held by the stage's forbidden-edge rules, since the issuing port itself is public; this crate stores, matches, and expires leases."}, "approvals": {"about": "Where a require-approval verdict becomes something actionable: either a scoped lease bound to the fingerprint of the exact invocation a human or policy approved, or a denial. Approval requests and gate records are durable, and resolution order is fail-closed — the decision is recorded before the lease it authorizes is ever issued; a denial is durable and final for that request, so a caller must raise a new one rather than retry. Persistent 'always allow' policy exists, but only scope-bounded and only for capabilities whose manifest explicitly permits reuse; choosing who to notify and how is a product concern that calls into this crate, never the reverse.", "sig": "trait ApprovalResolver {\n // fail-closed order: record the decision durably, then issue the lease\n fn resolve(request) -> Approved(CapabilityLease) | Denied; // denial is durable & final\n}\n// durable approval-request + gate records\n// reusable approvals: scope-bounded 'always allow',\n// only where the capability's manifest permits reuse", "security": "The human-and-policy consent authority — the only place a pending decision becomes either a scoped, fingerprinted lease or a terminal denial.", "why": "Consent resolution has its own durability and ordering guarantees; folding it into authorization would blur 'does this grant apply' with 'did a human agree to this.'"}, "resources": {"about": "The accounting stage for everything scarce: cost, tokens, wall-clock time, bytes, egress, process count, and concurrency. Work follows a reserve, execute, reconcile-or-release protocol — estimated capacity is reserved before anything runs, and a receipt closes the loop between the estimate and what a completed invocation actually spent. The reservation governor runs the identical protocol over in-memory, on-disk, or durable backing stores without callers knowing which, and a reservation crossing an operator-configured budget ceiling pauses for approval through a gate kept deliberately distinct from capability approval.", "sig": "trait ResourceGovernor {\n fn reserve(estimate) -> Reservation; // fail-closed: no reservation, no work\n fn reconcile(reservation, actual) -> Receipt;\n fn release(reservation);\n}\n// dimensions: cost · tokens · wall-clock · bytes · egress · processes · concurrency\n// one protocol over in-memory, on-disk, and durable backing stores\n// budget gate: reservations crossing an operator ceiling pause for approval", "security": "No costed or quota-limited work executes without an active reservation, and a storage failure denies exactly like a quota denial.", "why": "The only kernel stage with multiple production backing implementations behind one protocol, whose platform-specific concerns must never leak into any other kernel crate's build."}, "runtime_policy": {"about": "Pure policy math, with no I/O and no side effects: it folds deployment mode, runtime profile, and organization policy into the effective runtime policy every dispatch enforces, and plans which execution lane — which isolated runtime — a given capability invocation is allowed to use. Resolution is monotonic, meaning policy can only reduce requested authority and never increase it, and any profile relaxing the default safety posture requires an explicit, recorded opt-in. The resulting policy type has exactly one sanctioned producer, so downstream stages never re-derive or second-guess a policy they receive.", "sig": "// pure computation — no I/O, no side effects\nfn resolve(mode: DeploymentMode, profile: RuntimeProfile, org: OrgPolicy)\n -> EffectiveRuntimePolicy; // exactly one sanctioned producer\nfn plan_capability(policy, capability) -> ExecutionPlan; // which execution lane may run this\n// monotone: policy may only reduce requested authority, never raise it\n// relaxed safety posture requires an explicit, recorded opt-in", "security": "The deterministic policy-math gate feeding the membrane — reproducible, so an audit record can name the exact policy that gated an invocation.", "why": "Pure, dependency-free logic consumed identically by the membrane and by mediated execution; a separate crate lets both depend on the function without depending on each other."}, "capabilities": {"about": "The membrane: the single caller-facing invocation service every privileged effect in the system must cross — no loop, extension, or product surface has any other path to one. Each of its six workflows (invoke, resume, resume-after-auth, decline-auth, resume-spawn, spawn) runs the same fold — trust ceiling, then grant matching, then consent, then reservation and policy — before any side effect, and seals the outcome into an authorization witness: a proof value only this crate can mint, consumed exactly once by dispatch. Its obligation seam hands mediated execution a restricted mount view and a prepared reservation, and its dispatcher routes the witness to exactly the lane sealed inside it, rejecting any mismatch.", "sig": "struct CapabilityHost; // the membrane — every privileged effect crosses here\nimpl CapabilityHost {\n // the fold: trust -> grants -> consent -> reservation -> policy, sealed once\n fn invoke(...) -> Authorized;\n // + resume · resume_after_auth · decline_auth · resume_spawn · spawn — same fold\n}\nstruct Authorized; // sealed witness: mintable only here, consumed exactly once\ntrait CapabilityObligationHandler { ... } // restricted mounts + prepared reservation\nstruct RuntimeDispatcher; // routes a witness to its sealed lane; mismatch -> reject", "security": "The membrane itself: only its fold can mint the sealed authorization witness, and no privileged effect is reachable any other way. The witness is minted through host_api's sealed constructor.", "why": "The sealing invariant — only this crate may produce a witness — is enforced by a dedicated boundary test, and one crate gives that test exactly one thing to check while keeping the six-workflow fold reviewable as a unit."}, "processes": {"about": "The durable lifecycle authority for every piece of host-tracked work, whether a foreground conversational turn or a background capability invocation. A row-native journal records each process's identity, lineage, and status — with checkpoints stored as rows, child relationships as edges in the same journal, and process input immutable once accepted — and ProcessSupervisor claims, leases, heartbeats, and recovers registered work, containing panics and driving orderly shutdown. Kinds of work are registered by the crates that own them through a process-executor port; this crate holds no opinion on whether a caller may spawn, only on what happens once it has.", "sig": "struct ProcessSupervisor; // claims, leases, heartbeats, recovers registered work\ntrait ProcessExecutor { fn run(claimed) -> ...; } // registered per process kind\n// kinds registered by their owners: agent-turn (runner), capability-invocation (host_runtime)\n// row-native journal: identity, lineage, status, checkpoints, child edges\ntrait ProcessDependencyPort { ... } // record & query child relationships\n// process input is immutable once accepted", "security": "The claimed-execution authority: a terminal status is written once and never overwritten by a late completion.", "why": "One journal answering 'what is this work doing right now' for every kind of host-tracked work is the entire point of a single lifecycle authority."}, "turns": {"about": "The admission gate for conversational work. TurnCoordinator is the durable entry point where a request becomes admitted work, enforcing one active run per thread and request idempotency, and offering accept, resume, and cancel. At the exit boundary it validates outcomes: a loop's reported completion, failure, or block is treated as a claim — never as truth — until checked against host-minted evidence through the exit-evidence port. Turn and run state are a typed projection over the process journal rather than a second durable store, so a turn's lifecycle and its underlying process can never give two different answers to 'is this still running.' Its request-idempotency check is the durable, kernel-side guarantee beneath the product surface's fast-path ledger.", "sig": "struct TurnCoordinator;\nimpl TurnCoordinator {\n fn accept(request) -> ...; // durable admission: one active run per thread, idempotent\n fn resume(...);\n fn cancel(...);\n}\ntrait LoopExitEvidencePort { ... } // host-minted evidence for checking an exit claim\nstruct LoopExitApplier; // a loop's exit is a claim — validated here before anything durable commits\n// turn & run state: a typed projection over the process journal, never a second store", "security": "Guarantees one active run per thread, and structurally that a loop cannot talk itself into a durable state transition it was not granted.", "why": "'May this turn keep running' and 'may this one capability call happen' are different fail-closed questions with different callers and blast radii — conflating them would let either concern block the other."}, "host_runtime": {"about": "The kernel's mediated-execution service — where a sealed authorization witness becomes one real lane call. It completes the obligations the membrane prepared: audit before and after, network-policy staging, one-shot secret staging and consumption, mount restriction, resource-ceiling enforcement, and output redaction and limits; its closed lane executor then invokes only the lane the witness names, and nothing else. All lane network access and credential material flow through its mediated egress and secret staging — always scoped, always consumed exactly once — and it composes the membrane from the kernel's other services for a given deployment. Memory is consumed here through its provider-neutral contract; the concrete provider arrives from assembly and is never named in this crate.", "sig": "trait HostRuntime { ... } // the port upper tiers call; DefaultHostRuntime implements it\nstruct RuntimeLaneExecutor; // closed: invokes only the lane the sealed witness names\nimpl CapabilityObligationHandler for BuiltinObligationHandler {\n // audit before/after · network-policy staging · one-shot secrets\n // mount restriction · resource ceilings · output redaction & limits\n}\n// mediated egress port: the only path a lane's network access flows through\n// memory resolved through MemoryService — the concrete provider arrives from assembly", "security": "Turns a sealed witness into exactly one lane call under restricted mounts, staged one-shot credentials, and scoped egress, then turns the lane's raw output into redacted evidence in the durable audit log.", "why": "The obligation-completion, lane-execution, and evidence-sanitization sequence is one atomic fold every runtime lane depends on identically — more crates would scatter it without adding isolation."}, "agent_loop": {"about": "The decision-making core of a single agent turn (one unit of agent work). Its planner composes a sealed set of strategies to decide what happens next — call the model, run a tool, finish — and its canonical executor walks the turn through fixed, ordered lifecycle stages. Everything privileged is reached through the port traits defined in loop_contracts; the crate depends on contract crates and nothing else, so it can never touch a model client, secret, or file handle directly, and its resumable state carries only refs, cursors, counters, and safe summaries — never raw prompts or model output. ironclaw_runner consumes its executor to drive claimed runs.", "sig": "pub struct CanonicalAgentLoopExecutor { /* ordered lifecycle stages */ }\nimpl CanonicalAgentLoopExecutor {\n pub async fn run(&self, ports: /* full loop_contracts port set */) -> LoopOutcome\n}\npub struct LoopFamilyRegistry { .. } // loop-family identity + sealed strategy registry\npub struct PlannerService { .. } // built-in strategy composition; strategy trait never exported\n// resumable state: refs, cursors, counters, versions, safe summaries — nothing raw", "security": "None of its own — its contracts-only dependency set means no privileged type is even importable, so the untrusted-loop rule is enforced by the compiler rather than by review.", "why": "Its entire dependency graph must stay swappable without touching authority, and isolating it to contracts-only dependencies is what makes that guarantee mechanical."}, "loop_host": {"about": "The concrete implementation of every loop_contracts port, built over the kernel's services — the plumbing that lets the sealed agent loop actually reach models, tools, memory, and durable state. It supplies the base capability-port adapter and its capability-surface-filtering decorators, the model-gateway adapter, the input queue and cancellation port, the checkpoint-state store, the budget accountant, the subagent-spawn port, and the identity, skill, and memory prompt-context builders that turn private state into safe summaries for prompt assembly. ironclaw_runner composes these adapters into the host handed to each claimed run. It is the one crate licensed to hold port types and kernel handles in the same module.", "sig": "// implements every loop_contracts port over kernel services\npub struct HostRuntimeLoopCapabilityPort { .. } // base capability adapter (+ surface-filter decorators)\nimpl LoopCapabilityPort for HostRuntimeLoopCapabilityPort { .. }\npub struct ModelGatewayPortAdapter { .. } // model port over the LLM gateway\npub struct CheckpointStateStore { .. } // + budget accountant, input queue, cancellation port\npub struct SubagentSpawnPort { .. }\nfn identity_context() / skill_context() / memory_context() -> SafeSummary // prompt-context builders", "security": "The concrete membrane implementation — every privileged effect a loop requests passes through an adapter here and is authorized, approved, and kernel-mediated before it executes; it must never bypass the capability host or the dispatcher.", "why": "It is the only place kernel handles and loop_contracts types may coexist — keeping it apart preserves agent_loop's contracts-only purity and keeps port adaptation separate from the runner's driver and claim concerns."}, "hooks": {"about": "The trust-tiered hook framework — middleware that wraps every loop_contracts port call so hooks can observe, gate, or adjust what a run does before the call reaches the real implementation. Every hook belongs to one of four trust classes (builtin, trusted, installed, self-authored) fixed by where its code came from, never self-declared, and each class is limited to sealed decision sinks. The crate ships a declarative predicate language with its evaluator and a sandboxed execution engine for portable hook code. Gate and mutator decisions fail closed; observers and effects fail isolated with redacted audit, and a hook that violates its protocol is barred for the rest of the run.", "sig": "pub struct HookDispatcher { .. } // trust-tier-specific installers\npub struct HookRegistry { .. }\nenum TrustClass { Builtin, Trusted, Installed, SelfAuthored } // fixed by source, never declarable\n// sealed decision sinks per class; gate/mutator fail closed, observer/effect fail isolated\npub struct HookedLoopCapabilityPort { inner: P } // outermost decorator\n// + Hooked* decorators for the full loop_contracts port set\n// declarative predicate language + evaluator; sandboxed engine for portable hook code", "security": "Hooks can restrict but never grant: no hook receives an ambient secret, filesystem handle, network client, or process handle, no hook can bypass a kernel-mediated policy stage, and protocol violators are barred for the remainder of the run.", "why": "An independent trust-tier contract with its own sandboxed execution engine, kept apart from loop_host's non-sandboxed adapters so neither crate carries a dependency the other has no need for."}, "extension_registry": {"about": "The system of record for installable extensions. It owns the manifest schema — a wire form, an internal normal form, and a resolved-and-digested form the rest of the system reads instead of re-parsing — plus the in-memory catalog and the durable records of what is installed, by whom, and with which credentials bound. It executes nothing, holds no secrets, and makes no trust decisions; the generic extension host and the product-side manager read it, and the host alone drives its lifecycle transitions.", "sig": "pub struct ExtensionRegistry { .. } // in-memory catalog of resolved manifests\n// manifest forms: wire schema → internal normal form → resolved + digest\npub struct InstallationRecordStore { .. } // durable installation / membership / credential-binding records\nimpl InstallationRecordStore {\n fn get(ExtensionId) -> InstallationRecord\n fn cas_update(..) -> .. // compare-and-swap record mutation; no execution\n}", "security": "The installation-lifecycle record authority — it records what is installed but never decides whether an effect is allowed, and it holds no secrets.", "why": "A record authority with a genuine persistence obligation and a manifest grammar many crates read; keeping the stateful, compare-and-swap-mutated store apart from the host keeps mutation out of the crate whose other job is verifying inbound trust."}, "extension_host": {"about": "The generic, vendor-blind host for installed extensions. It is the sole writer of installation-lifecycle state — install, bind, activate, remove — loading extension code through native, WASM, or MCP loaders that all produce one identical binding shape, and it runs the ingress router whose manifest-recipe verifier checks each inbound webhook's signature and mints the sealed verified-inbound evidence everything downstream trusts. Outbound, its egress transports carry a package's delivery calls with credentials staged and injected by the kernel’s mediated egress path — the transports ride that path, never a network client of their own —, so adapters never hold raw secrets. No vendor name or protocol branch appears here; all vendor-specific behavior lives in the packages it hosts.", "sig": "pub struct ExtensionHost { .. } // the sole lifecycle writer\nimpl ExtensionHost { async fn install(..); async fn activate(..); async fn bind(..); async fn remove(..); }\npub struct ActiveSnapshot { .. } // generation-stamped view of active installations\ntrait ExtensionLoader { .. } // native | wasm | mcp → one binding shape\npub struct ExtensionIngressRouter { .. } // manifest-recipe verifier mints sealed verified-inbound evidence\npub struct ChannelEgressTransport { .. } // host-mediated egress; credential injected at send time\n// + generic channel-identity, connection, configuration, and pairing mechanisms", "security": "The raw-request-to-verified-inbound trust membrane — its generic verifier is the only code permitted to mint sealed verified-inbound evidence, and it is the sole writer of installation-lifecycle state transitions.", "why": "The generic hosting machinery carries a real trust job — verification, binding, activation — and only a crate boundary makes the no-vendor-name, no-product-name rule a checkable fact rather than a convention."}, "extension_manager": {"about": "The product face of extensions — everything a user or operator does to discover, install, configure, and pair one. It owns the available-extension catalog and its import path, lifecycle commands exposed through its own product-surface implementation, the channel-configuration service, pairing-workflow orchestration, credential views, and the administrator, operator, and skill-activation capability handlers. It holds no lifecycle authority of its own: every install, activation, or removal is a call into the generic extension host, never a direct write to the registry's records. Composition mounts this surface beside the assistant's — extension-management traffic enters here directly, never routed through the conversation surface.", "sig": "pub struct SharedCommandSurface { .. } // the extension-management ProductSurface impl\nimpl ProductSurface for SharedCommandSurface { .. }\npub struct AvailableExtensionCatalog { .. } // discovery + import path\n// lifecycle commands: install / configure / remove — each a call into ExtensionHost authority\npub struct ChannelConfigService { .. } // channel-configuration product service\npub struct PairingWorkflow { .. } // pairing orchestration + credential views", "security": "None of its own — a product-UX layer that calls the generic host's authority-bearing operations rather than duplicating them.", "why": "A coherent product sub-owner with its own surface, distinct from the conversation-facing product crate and from the generic host whose authority it only ever calls."}, "pkg_slack": {"about": "The Slack channel adapter, protocol code only. It implements the shared ChannelAdapter contract — activation, cleanup, inbound parsing, and delivery — parsing Slack payloads into normalized messages, rendering outbound replies, and encoding preference targets. Signature verification, the OAuth flow, setup UX, and retry policy all live elsewhere: verification is performed by the generic host, driven by this package's manifest recipe. It depends on extension_contracts alone and is linked only by the binary.", "sig": "pub struct SlackChannelAdapter;\nimpl ChannelAdapter for SlackChannelAdapter {\n async fn normalize_inbound(payload) -> .. // parse only — never verify\n async fn deliver(message, target) -> .. // mrkdwn rendering; the host injects the bot token\n async fn activate(..) / cleanup(..)\n}\n// + Slack preference-target encoding; verification recipe + OAuth declared as manifest data", "security": "None — pure parsing and rendering; it can misrender a message but can never forge the fact that a request passed signature verification.", "why": "Channel-adapter packages are linked exclusively by the binary — a boundary only a crate, not a module, can enforce."}, "pkg_telegram": {"about": "The Telegram channel adapter — the same protocol-only shape as every channel package. It implements the ChannelAdapter contract for Telegram: payload parsing, message rendering, delivery, and preference-target encoding. It never verifies signatures, runs auth flows, or decides delivery semantics; those belong to the generic host and the product layer. It depends on extension_contracts alone and is linked only by the binary.", "sig": "pub struct TelegramChannelAdapter;\nimpl ChannelAdapter for TelegramChannelAdapter {\n async fn normalize_inbound(payload) -> .. // Telegram update parsing only\n async fn deliver(message, target) -> ..\n async fn activate(..) / cleanup(..)\n}\n// + PreferenceTargetCodec impl for Telegram reply targets", "security": "None — the same parse-only posture as every channel package; it cannot construct verified-inbound evidence.", "why": "It implements a channel adapter, subject to the same binary-only linkage boundary as every other channel package — enforceable only as a separate crate."}, "pkg_memory_native": {"about": "The bundled memory provider — the package that makes the agent's persistent memory work out of the box. Its manifest declares a [memory] provider surface, and its crate implements ironclaw_memory::MemoryService (the provider-neutral memory contract) over filesystem and in-memory repositories with full-text indexing and search; it also hosts the prompt-write-safety engine that enforces the write vocabulary the neutral contract defines. It ships installed by default so memory is always available, it is linked only by the binary, and exactly one memory provider is active per deployment.", "sig": "// manifest: declares the [memory] provider surface — installed by default\npub struct NativeMemoryService { backend: /* filesystem | in-memory repository */ }\nimpl ironclaw_memory::MemoryService for NativeMemoryService { .. }\n// full-text indexing + search over stored memories\npub struct PromptWriteSafetyEngine { .. } // enforces the contract's write-safety vocabulary\n// linked only by the binary; wired into the contract's shared conformance suite", "security": "None — a record-and-search backend; the prompt-write-safety engine it hosts enforces contract vocabulary the kernel consumes, but the enforcement call itself grants no authority.", "why": "A provider surface with real native backend weight — indexing and a filesystem cone that must not leak into shared-crate consumers — and provider packages, like channel packages, are linked only by the binary."}, "pkg_mem0": {"about": "The alternative memory provider, backed by an external mem0 service. It implements the same ironclaw_memory::MemoryService contract by mapping each memory operation onto the service's REST API, and its manifest declares the [memory] provider surface; a deployment installs it in place of the native provider. Its single HTTP egress path is hardened — bounded timeout, redirects disabled, target URL validated before any request leaves the process — and a mock-transport seam keeps the mapping testable without a live network. Only the binary links it. Like every provider it enforces the contract's prompt-write-safety vocabulary before persisting model-authored content — the shared conformance suite is what proves it.", "sig": "// manifest: declares the [memory] provider surface — installed per deployment\npub struct Mem0MemoryService { transport: /* hardened HTTP seam */ }\nimpl ironclaw_memory::MemoryService for Mem0MemoryService {\n // each memory operation mapped onto the external mem0 REST surface\n}\n// transport: bounded timeout, redirects disabled, target URL validated pre-flight\n// mock-transport seam keeps the mapping unit-testable without live network", "security": "None directly — it carries the target-validation obligation for its one HTTP egress path: bounded timeout, redirects disabled, and the URL validated before any request leaves the process.", "why": "It isolates an external HTTP dependency cone and provides the second independent implementation that keeps the memory contract's conformance suite honest."}, "assistant": {"about": "The personal assistant itself — the canonical implementation of the product surface that every front door (the browser app, OpenAI-compatible API clients, and channel adapters) ultimately calls. Inbound, it resolves which conversation and target a request binds to, checks the idempotency ledger for replays and in-flight duplicates, admits commands against a frozen inventory of command, capability, and view descriptors, and hands admitted work to the turn coordinator. Outbound, its delivery coordinator decides target, retry policy, and reply context before handing off to the at-most-once reservation owned one layer down. It also hosts the click-approval and click-auth interaction services that show a human a redacted view of a blocked run and forward the decision through resolution ports. Binding resolution and external-event dedup are the conversations domain's service — channel ingress reaches it without crossing this surface; the ledger here dedups product-surface requests only.", "sig": "pub struct AssistantServices { .. } // the canonical ProductSurface impl\nimpl ProductSurface for AssistantServices { .. } // frozen method set; trait defined in product_contracts\npub struct DeliveryCoordinator { .. } // target, retry policy, reply context → outbound reservation\npub struct IdempotencyLedger { .. } // replay / in-flight duplicate detection\n// click-approval + click-auth interaction services: redacted read models → resolution ports\n// frozen inventory of command / capability / view descriptor constants", "security": "Owns the admission boundary — binding, idempotency, and command-grammar decisions about whether a request is new, never whether it is allowed — and keeps approval and auth interactions strictly redacted, scoped, and routed through canonical resolution ports.", "why": "It is the product authority itself — bindings, admission, delivery semantics, and the surface's frozen contract — with a deliberately frozen public method set so its behavior can be reasoned about as one stable artifact."}, "operator": {"about": "The deployment-operator control plane — administering the running service rather than conversing with it. It implements the operator-service ports defined in product_contracts: LLM provider administration (registry, keys, active-model selection), the operator log ring, platform service lifecycle, and status. Boot-time values arrive as construction input from the assembly layer, secret storage is reached only through an assembly-supplied port, and its routes are mounted through host_ingress carriers rather than a router of its own.", "sig": "// implements the operator-service ports defined in ironclaw_product_contracts:\nimpl LlmConfigService for .. { .. } // provider registry, keys, active-model selection\nimpl ActiveModelReader for .. { .. }\nimpl OperatorLogService for .. { .. } // the operator log ring\nimpl ServiceLifecycleService for .. { .. } // platform service start/stop\nimpl StatusService for .. { .. }\n// routes ride ironclaw_host_ingress carriers; secrets via an assembly-supplied port", "security": "A control-plane implementer, not a decision-maker — it reaches secret storage only through an assembly-supplied port, and LLM-vendor administration is its one sanctioned vendor scope.", "why": "A distinct operator authority with its own vendor-integration surface, consumed only by the assembly layer and never by conversational code."}, "openai_compat": {"about": "The OpenAI-shaped API skin over the product surface. It owns the wire contract for chat and response-style completions — route descriptors, request and response DTOs, and a sanitized error envelope — plus an idempotency reference store scoped to this surface. Its workflows run over a bound product surface handed in by the assembly layer; it never resolves conversation bindings itself and compiles against product_contracts, not the assistant. It exists so OpenAI-compatible clients get a stable external wire contract versioned independently of the product's internal shape.", "sig": "pub fn openai_compat_routes() -> /* OpenAI-shaped chat + responses route table */\npub struct ChatCompletionRequest { .. } // wire DTOs with an external stability promise\npub struct ChatCompletionResponse { .. }\npub struct SanitizedErrorEnvelope { .. } // sealed error taxonomy\npub trait OpenAiCompatRefStorePort { .. } // the opaque-ref / idempotency store, scoped to this surface\n// workflows run over a BoundProductSurface supplied by assembly — no binding logic here", "security": "None beyond input sanitization and its sealed error envelope — authority is entirely delegated to the bound product surface it is handed.", "why": "A protocol surface with its own external wire-stability promise, versioned independently of the product surface's internal shape."}, "webui": {"about": "The web host — the only crate in the product family that binds a listener. It serves the embedded single-page application and the frozen WebChat route surface behind a fixed middleware order (origin check, body limit, bearer/session/OIDC authentication, rate limit, then the handler), and owns every host-authenticator implementation plus the OAuth login stack and the product-authentication HTTP routes. Every external request that is not a public webhook authenticates here before reaching product code; public webhooks skip this stage only because they carry their own verification recipe, checked one layer down in the extensions family.", "sig": "pub trait HostAuthenticator { .. } // bearer | session | OIDC | composite implementations\npub fn webchat_routes() -> /* frozen route descriptor table */\n// fixed middleware order: origin → body limit → authn → rate limit → handler\n// OAuth login stack (/auth/*) + product-authentication HTTP routes\n// embedded SPA + the serve loop — the family's only listener\n// re-exports ironclaw_host_ingress route-mount carriers", "security": "The sole listener in the family — every non-webhook request authenticates here before touching product, and this crate alone constructs authenticated-caller evidence, minted only through host_api's sealed constructor.", "why": "The transport-and-presentation artifact — a web-framework and single-page-application dependency cone that must never leak into crates that only need to call the surface it hosts."}, "host_ingress": {"about": "A tiny vocabulary crate for handing HTTP routes around. It defines carrier types that pair a prebuilt router with ingress policy descriptors — public, protected, and combined mounts — plus the drain-hook trait used to flush background work at shutdown. It binds no listener, enforces nothing, and persists nothing; it exists so one crate can build routes and another can mount them with authentication, rate limits, and body limits, without a web framework leaking into neutral contract crates. It depends on host_api and nothing else.", "sig": "pub struct PublicRouteMount { router, descriptor } // prebuilt router + ingress policy descriptor\npub struct ProtectedRouteMount { router, descriptor }\npub struct SplitRouteMount { .. }\npub trait DrainHook { async fn drain(&self) } // flush background work at shutdown\n// depends on ironclaw_host_api only — no listener, no middleware, no persistence", "security": "None directly — it exists precisely so neutral contracts never need a web-framework dependency, keeping that framework confined to the crates that actually bind a listener.", "why": "The smallest possible surface with a single external dependency, so a crate needing only the carrier shapes never pulls in a listener's full dependency cone."}, "composition": {"about": "The assembly root — the one crate allowed to see the whole workspace, because its job is to construct everyone else's owners. It turns deployment configuration, expressed as plain data (profile, mode, storage backend), into a running system: it invokes each family's own factory functions, computes fail-closed readiness over the constructed handles, manages background-task lifecycles, and exposes the result as a service-graph handle with product, auth, and readiness methods. Extension bindings arrive from the binary as opaque, already-constructed handles — composition can never name a concrete package. Its charter in one sentence: it wires owners, never becomes one.", "sig": "pub struct HostBindings { .. } // opaque adapter handles, supplied by the binary\npub struct RuntimeInput { .. } // deployment config as data: profile, mode, storage backend\npub async fn build_runtime(input: RuntimeInput) -> ServiceGraph\npub struct ServiceGraph { .. } // the service-graph handle\nimpl ServiceGraph { pub fn product(&self); pub fn auth(&self); pub fn readiness(&self); }\npub trait AdminApiTokenMinter { .. } // defined here; satisfied only by the binary", "security": "Fail-closed readiness gating and deployment-shape selection as data — it selects which policy applies but never authors policy content, and production blocks on any missing or under-specified handle rather than defaulting to a permissive shape.", "why": "The assembly root is by definition the one crate allowed to see everything — no lower crate could hold this role without breaking the layer model it depends on to exist."}, "cli": {"about": "The shipped binary, named ironclaw — the artifact an operator actually runs. It owns the command surface and the serve sequence (assemble a deployment through composition, obtain a product surface, start the web gateway), and it holds the two privileges no other crate has: the binding table that names and links each concrete extension package, and the sole implementation of the admin-token-minting port that composition defines. Every command is a thin caller into composition or a family crate, never a reimplementation of what it calls.", "sig": "$ ironclaw serve // assemble deployment → product surface → web gateway\n$ ironclaw // every command a thin caller into composition or a family crate\n// the binding table — the only place concrete extension packages are named:\nstatic BINDINGS: &[(ExtensionId, /* opaque ChannelAdapter handle */)]\nimpl AdminApiTokenMinter for /* the binary's minter */ { .. } // the sole implementation\n// + first-party registrars, credential-visibility policy", "security": "The two single-implementor privileges live here and nowhere else: it alone names and links concrete extension packages, and it alone implements the administrative token-minting authority.", "why": "It is the shipped artifact — a binary target, not a library — and the discipline that only the binary names a concrete extension depends on there being exactly one binary crate to hold that privilege."}, "config": {"about": "The boot contract: the config.toml schema and the home, profile, and boot resolution a deployment starts from, plus configuration seeding, budget environment defaults, and inline-secret rejection at parse time. It contains no vendor-specific sections — package-owned configuration flows through the generic administrative-configuration model instead — and no runtime wiring: it defines and validates the schema, never constructs the deployment the schema describes. It has zero workspace dependencies and is consumed only by the assembly crate and the binary; any other crate needing a boot value receives it as construction input.", "sig": "// config.toml — the boot contract (zero workspace dependencies)\n[storage] # storage-backend selection values, read by the assembly root\n# home / profile / boot resolution; seeding; budget env defaults\n# inline secret values -> rejected at parse time\npub struct ConfigFile { .. } // the typed, comment-preserving config schema\npub struct Home { .. } // resolved home / profile / boot paths", "security": "Inline-secret rejection at parse time — a raw secret typed directly into the configuration file is refused rather than silently accepted.", "why": "The zero-dependency guarantee is the crate's entire reason to be its own compilation unit — a guarantee only meaningful as a separately compiled, separately reviewed leaf."}, "trace_commons": {"about": "The client for Trace Commons, the external service agent traces can be contributed to. It defines the submission envelope schema, deterministically redacts every submission before it leaves the process, and runs the submission queue with its holds, the credit accounting, and device-key onboarding — key issuance, invitations, and the onboarding protocol. Each concern — schema, redaction, queue, credits, credentials — is a separately chartered module so it stays independently reviewable, and storage paths resolve through the scoped filesystem like every other domain. The model-callable trace-submission tool lives in the first-party extension package as a caller of this crate's client.", "sig": "// host-facing Trace Commons client\nfn submit(envelope) -> ...; // queued — deterministically redacted first, always\nmod schema; mod redaction; mod queue; // + submission holds\nmod credits; mod credentials; // device-key issuance & invitations\n// storage paths resolve through ScopedFilesystem", "security": "Carries the family's security-critical redaction obligation: every submission is deterministically redacted before anything leaves the process.", "why": "A distinct external-service domain whose redaction obligation deserves its own reviewable boundary, with its HTTP cone isolated from every other crate's build."}, "turn_runner": {"about": "The trusted bridge between kernel-claimed work and the agent loop. Registered with the process supervisor as the executor for turn-shaped work, it takes a claimed run under a lease, assembles that run's scoped port set through its loop-host factory, and drives execution through one of two registered drivers: a planned driver adapting ironclaw_agent_loop and a minimal text-only driver. It owns the driver registry with readiness validation and the failure-lane and retry disposition. When the loop claims an outcome, the runner submits that claim to the turn kernel's exit applier, which validates it against host-minted evidence before anything durable commits.", "sig": "pub struct DriverRegistry { .. } // readiness-validated driver registry\npub struct PlannedDriver; // adapts ironclaw_agent_loop's executor to the driver contract\npub struct TextDriver; // the smallest supported behavior\npub struct AgentTurnExecutor { .. } // the ProcessKind::AgentTurn executor\nimpl AgentTurnExecutor {\n async fn execute(&self, claimed: /* leased run */) -> /* claimed outcome → exit applier validates */\n}\nfn build_loop_host(/* claimed run */) -> /* run-scoped Loop*Port composition */", "security": "The trusted control-plane adapter — handed a claimed run under a lease, it hands that run only its scoped ports and never decides durability itself; the turn kernel's exit validation does.", "why": "Exactly one crate is trusted to bridge a kernel-issued claim into loop userland, so the kernel's only reach into this family stays a single registered executor rather than a same-tier dependency."}, "architecture_tests": {"about": "The workspace's enforcement suite — a test-only crate that fails the build whenever any crate's dependency graph or public surface drifts from the declared layer and family model. It owns the layer ladder, contract-purity allowlists, the rule pinning where trusted-evidence constructors may be called, the persistence rule bounding which crates may speak a database driver directly, the ban on reaching into another crate's assets by relative path, and the conformance suites for every domain with multiple backend or provider implementations. Nothing else imports it, and it inspects declared structure and source text rather than linking the crates it polices; a boundary rule that is not a test here is not a rule.", "sig": "#[test] fn layer_ladder_holds() // dep graph matches the declared model\n#[test] fn host_product_surface_method_set_is_frozen()\n#[test] fn composition_public_api_is_service_shaped()\n#[test] fn trusted_evidence_minted_only_at_sealed_sites()\n#[test] fn persistence_idiom_bounds_db_drivers()\n#[test] fn no_cross_crate_relative_asset_reach_ins()\n// + contract-purity allowlists + per-domain backend conformance suites", "security": "The enforcement mechanism for every security-relevant boundary claim the architecture makes — no other crate in the workspace may define an architecture-contract test.", "why": "Test-only isolation by definition — folding these tests into any crate they police would let that crate pass or fail its own boundary checks, defeating the independent check."}, "extension_support": {"about": "The shared support crate behind the bundled packages — not itself an installable package. It holds the package inventory (which package directories ship, with their trust-effect declarations) and the native tool executors that serve many packages at once: general file, text, and search tooling, groupware integrations, web access, and the generic builtin tools — file, shell, http, time, memory, trigger management, skill management and installation, and telemetry submission. Any loop reaches these tools by capability grant through the same dispatch path as every other tool, never by extension identity.", "sig": "pub static PACKAGES: &[PackageEntry] // which package directories ship, with trust-effect declarations\npub struct FirstPartyHandlerRegistrar { .. } // the binary registers native executors through this\n// ToolAdapter impls served through the generic capability-dispatch path:\n// file, shell, http, time, memory tools, trigger management,\n// skill management/installation, telemetry, gsuite, web-access, search\nimpl ToolAdapter for /* each bundled tool */ { .. }", "security": "None — first-party status raises a policy ceiling but never grants permission; every tool call still crosses the same authorization and approval stages as any other capability invocation.", "why": "It is the one sanctioned home for vendor-named native code outside packages/ and a heavy, varied native-tool dependency surface, kept apart so neither ever leaks into vendor-blind host code."}, "pkg_github": {"about": "Ships as pure data: a manifest declaring GitHub's tool surface, JSON schemas and prompt docs per tool, and the committed WASM artifact the WASM lane executes. The host injects the GitHub token at call time from the manifest's credential declaration — the package never holds a secret.", "sig": "# manifest.toml — schema reborn.extension_manifest.v3\nid = \"github\" trust = \"first_party_requested\"\n[runtime]\nkind = \"wasm\" module = \"wasm/github_tool.wasm\"\n[[tools]]\nid = \"github.create_repo\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\ndefault_permission = \"ask\" # writes ask; reads allow\n[[tools.credentials]]\nhandle = \"github_runtime_token\" # injected by the host at call time", "why": "A directory, not a crate: nothing here is a channel adapter, a provider surface, or a heavy native dependency."}, "pkg_gmail": {"about": "A data-only package declaring Gmail's tools and its OAuth recipe. gmail is the product identity; the google credential authority it names is shared with the other google-* extensions, so one Google login serves them all.", "sig": "# manifest.toml\nid = \"gmail\" # product identity: gmail\n[auth.google] # credential authority: google (shared)\nmethod = \"oauth2_code\" # PKCE flow run by the generic auth engine\n[runtime]\nkind = \"wasm\"\n[[tools]]\nid = \"gmail.send_message\"\neffects = [\"network\", \"use_secret\", \"external_write\"]\ndefault_permission = \"ask\"", "why": "A directory, not a crate — its OAuth flow is recipe data executed by the generic auth engine, never package code."}, "pkg_google": {"about": "The google-* extensions — drive, calendar, docs, sheets, slides — each a separate data-only package directory with its own manifest, sharing the google credential authority. Installing one never implies another.", "sig": "# one directory per extension: google-drive/, google-calendar/,\n# google-docs/, google-sheets/, google-slides/\n# each: manifest.toml + prompts/ + schemas/ + wasm/\nid = \"google_drive\" # separate product objects…\n[auth.google] # …sharing one credential authority\nmethod = \"oauth2_code\"\n[runtime]\nkind = \"wasm\"", "why": "Vendor identity is a credential namespace, never a product identity — one OAuth authority, many separate installable extensions."}, "pkg_web_access": {"about": "Declares the agent's web fetch and search toolset. The manifest is data; the native executors that serve it live as modules in extension_support, registered against this manifest identity.", "sig": "# manifest.toml\nid = \"web_access\"\n[runtime]\nkind = \"first_party\" # native executors are extension_support\n # modules, registered against this identity\n[[tools]]\nid = \"web_access.fetch\" # + search (sketch)\neffects = [\"network\"]\ndefault_permission = \"allow\"", "why": "A directory, not a crate — its native code lives as extension_support modules, per the package-to-crate rule."}, "pkg_notion": {"about": "Declares a Notion MCP server; the MCP lane connects to it through host-mediated egress and its discovered tools become ordinary capabilities. No code ships in the package at all.", "sig": "# manifest.toml\nid = \"notion\" trust = \"first_party_requested\"\n[mcp]\nserver = \"…\" # the MCP lane connects; egress host-mediated\nnamespace = \"notion\"\nmax_tools = 64\ndefault_permission = \"ask\"\neffects = [\"network\", \"use_secret\"]", "why": "Runtime kind is loading, never taxonomy: an MCP extension is a manifest pointing the lane at a server."}, "pkg_nearai": {"about": "Declares the NEAR AI MCP server for web search and hosted-agent tools. Same shape as every MCP extension: the manifest points the lane at a server; the generic host does the hosting.", "sig": "# manifest.toml\nid = \"nearai\"\n[mcp]\nserver = \"https://private.near.ai/mcp\"\nnamespace = \"nearai\"\nmax_tools = 64\ndefault_permission = \"ask\"\neffects = [\"network\", \"use_secret\"]\n# discovered tools become capabilities via the generic host", "why": "A directory, not a crate — the generic host and the MCP lane do all the work."}, "stress": {"about": "A standalone diagnostic binary that drives load against a running deployment — turn floods, delivery churn, subscription storms — to find contention before operators do. It lives under tools/, is excluded from default workspace work, and nothing in the product depends on it.", "sig": "// tools/ironclaw_stress — excluded from default-members\nfn main() // scenario runner against a served instance\n// turn floods · delivery churn · stream subscription storms\n// observes: latency, lease expiries, backpressure", "why": "A binary aimed at a running system, not a library anyone links — excluding it keeps default builds lean."}, "integration_tests": {"about": "The Reborn integration suite: in-process tests that assemble the real runtime through composition, drive it through product surfaces and channels, and assert at seams — transcripts, records, deliveries — rather than status alone. The workspace root package exists solely to host it.", "sig": "// workspace root — package ironclaw_integration_tests (tests only)\n// tests/integration/*.rs\n#[tokio::test]\nasync fn feature_lands_at_its_seam() {\n let rt = build_runtime(test_input()).await; // the real assembly\n // drive via ProductSurface / channel ingress, assert at the seam\n}", "why": "Integration-first coverage needs one home wired to the full composed runtime; the root package is that home."}, "libsql_runtime": {"about": "SQLite's WAL admits many readers and exactly one writer. This crate makes that constraint a type rather than a hope: every store that shares one physical database takes its connections from the same runtime, so writers queue on one lane instead of forming competing pools. It is the family's only crate with no workspace dependency in either direction — a leaf holding a driver cone, reachable from three different families precisely because it belongs to none of them.", "sig": "// one runtime per physical database\nlet rt = LibSqlRuntime::open(target).await?; // records provenance\nrt.target_matches(configured) // and can prove it\n\nrt.read().await? // bounded pool, PRAGMA query_only = ON\nrt.write().await? // one slot; reentrant acquisition is an error", "security": "An availability and correctness boundary, not an authorization one — and the distinction is the point. It decides that only one writer proceeds, never who is entitled to write; that grant came from the kernel long before a statement reached here. It fails closed three ways: it refuses a runtime that cannot prove the target it was opened for, refuses a reentrant writer, and fails a checkout at its deadline rather than queueing without bound.", "why": "The single-writer invariant only holds where the pool is singular, and the crates that must share it — the storage fabric, the trigger store, the assembly root — sit in three families. A module inside any one of them would either duplicate the lane or force the other two to depend on that crate wholesale to reach a pool."}}; function hl(src){ diff --git a/docs/reborn/target-architecture/families/domains.md b/docs/reborn/target-architecture/families/domains.md index 99249f3c120..0af53e3eb8e 100644 --- a/docs/reborn/target-architecture/families/domains.md +++ b/docs/reborn/target-architecture/families/domains.md @@ -46,7 +46,7 @@ The family favors narrow, single-purpose crates over shared infrastructure. Most - **Never belongs — authority decisions:** authorization, approval, and resource-reservation decisions stay in the kernel family. - **Never belongs — transport and framework code:** no domains crate touches Axum; HTTP appears only inside the narrow egress needs of the vendor-scoped and external-service charters. - **Never belongs — vendor names or vendor branches**, outside `ironclaw_llm` and `ironclaw_auth`. -- **Persistence idiom:** `ScopedFilesystem` is the floor. Every domains crate is backend-neutral by construction, depending only on the filesystem substrate's virtual-path, mount, and compare-and-swap authority — never a database driver directly. A crate that instead needs a hand-written SQL backend is a deliberate, narrow design choice that must be justified by an ADR; `ironclaw_triggers` is the one domain crate built this way, alongside `ironclaw_hooks` in the loop family. Such a crate still does not get its own connections: it owns its SQL and its transactions, and takes admission from the substrate runtime that owns the pool, so its writes queue on the same lane as every other writer to that database. +- **Persistence idiom:** `ScopedFilesystem` is the floor. Every domains crate is backend-neutral by construction, depending only on the filesystem substrate's virtual-path, mount, and compare-and-swap authority — never a database driver directly. A crate that instead needs a hand-written SQL backend is a deliberate, narrow design choice that must be justified by an ADR; `ironclaw_triggers` is the one domain crate built this way, alongside `ironclaw_hooks` in the loop family. **Both ADRs are written and both decided KEEP (2026-08-04): [`docs/adr/0003-triggers-keeps-hand-written-sql.md`](../../../adr/0003-triggers-keeps-hand-written-sql.md) and [`docs/adr/0004-hooks-keeps-its-predicate-state-backends.md`](../../../adr/0004-hooks-keeps-its-predicate-state-backends.md).** They are exceptions for different reasons and should not be cited as one precedent: triggers' claim/lease semantics are not expressible on the fabric *and* both its backends ship by profile, whereas hooks' backends are complete but **unwired** (composition hard-codes the in-memory one) and are kept as staged work against multi-host counters. A third crate wanting this exception needs its own ADR clearing the same bar, not a reference to these. Such a crate still does not get its own connections: it owns its SQL and its transactions, and takes admission from the substrate runtime that owns the pool, so its writes queue on the same lane as every other writer to that database. ## Dependency direction diff --git a/docs/reborn/target-architecture/families/extensions.md b/docs/reborn/target-architecture/families/extensions.md index 89c0f7d8e89..45e91c31ce7 100644 --- a/docs/reborn/target-architecture/families/extensions.md +++ b/docs/reborn/target-architecture/families/extensions.md @@ -1,6 +1,6 @@ # `crates/extensions/` — everything "installable package" -**Layer(s):** substrates (`ironclaw_extension_registry`), loops (`ironclaw_extension_host`, `ironclaw_extension_support`), products (`ironclaw_extension_manager`, every package crate) · **Crates:** 8 — `ironclaw_extension_registry`, `ironclaw_extension_host`, `ironclaw_extension_manager`, `ironclaw_extension_support`, `ironclaw_slack_extension`, `ironclaw_telegram_extension`, `ironclaw_memory_native`, `ironclaw_memory_mem0` · **Security posture:** the host-to-extension trust membrane — a single generic verifier is the only code permitted to mint sealed verified-inbound evidence; concrete packages parse, render, and serve their declared surfaces but can never construct trust, and only the binary may link a concrete package crate. +**Layer(s):** substrates (`ironclaw_extension_registry`), runtimes (`ironclaw_extension_support`), loops (`ironclaw_extension_host`), products (`ironclaw_extension_manager`, every package crate) · **Crates:** 8 — `ironclaw_extension_registry`, `ironclaw_extension_host`, `ironclaw_extension_manager`, `ironclaw_extension_support`, `ironclaw_slack_extension`, `ironclaw_telegram_extension`, `ironclaw_memory_native`, `ironclaw_memory_mem0` · **Security posture:** the host-to-extension trust membrane — a single generic verifier is the only code permitted to mint sealed verified-inbound evidence; concrete packages parse, render, and serve their declared surfaces but can never construct trust, and only the binary may link a concrete package crate. *This document specifies the target architecture as designed. Dispositions, migration constraints, evidence, and open decisions live in [PROPOSAL.md](../PROPOSAL.md), [CHECKLIST.md](../CHECKLIST.md), and [PLAN.md](../PLAN.md).* @@ -105,6 +105,7 @@ Every installable extension's manifest, prompts, schemas, code, and any built-ar - **Owns:** the package inventory (which package directories ship, with which trust-effect declarations); native tool executors — general-purpose file, text, and search tooling, groupware integrations, web access; the generic builtin tool implementations — file, shell, http, time, memory, trigger management, skill management and installation, and telemetry submission — available to any loop by capability grant, not by extension identity. - **Never contains:** a type only a loop-hosting crate should hold; the host's own dispatch types, capability-handler implementations, or capability-manifest declarations; this crate is invoked only through the same capability-dispatch path any tool uses. - **The executor/adapter seam (WS3, recorded 2026-08-03).** A builtin tool arrives here as an *executor*: a plain function or struct taking a narrow request the crate itself defines, reaching the outside world only through contracts-layer ports the host hands it per invocation (mediated HTTP egress, a scoped filesystem, the caller's scope, a capability id). Its capability-handler implementation, the manifest that declares it, and the code that inserts it into the handler registry stay on the host side of the seam — the crate may not name the kernel crate that owns those types, and a tool whose executor cannot be expressed without them has not finished moving. The groupware, web-access, coding, and skill-installation tools all ship in this shape. +- ✎ **Layer: `runtimes`, corrected 2026-08-04 (WS3 closeout) — the seam above is what forces it.** This entry previously placed the crate at `loops`. That cannot be true *and* the seam be true at the same time: a tool leaves its `FirstPartyCapabilityHandler` in `ironclaw_host_runtime`, so the seam makes the **kernel** a designed consumer of this crate, and a crate the kernel is designed to call cannot be declared two rungs above it. The declaration was the wrong half. `runtimes` is the least demotion that legalizes a kernel consumer; it is also the layer this crate's §8.2 posture already describes — mediated services arrive by injection, kernel ✗, invoked only through capability dispatch, which is the wasm / mcp / sandbox cell verbatim. Checked both ways before the flip: every normal dependency and every domain the *Depends on* line below reserves is `substrates` or `contracts`, and all five consumers are `kernel` or above. This is the move that deleted `LAYER_MATRIX_EXCEPTIONS`' last entry (`host_runtime → extension_support`); the consumer set it widens to is frozen by a `DowngradePin` in `reborn_same_layer_edge_inventory.rs`. **It does not change what belongs here** — the *Never contains* and *Never depends on* lines are unchanged and still binding, and the crate is still forbidden from naming `ironclaw_host_runtime` or `ironclaw_extensions`. - **Public surface:** tool-adapter implementations for its bundled tools, consumed through the generic dispatch path. - **Depends on:** `ironclaw_auth`, `ironclaw_extractors`, a storage substrate, `ironclaw_observability`, `ironclaw_safety`, `ironclaw_skills`, `extension_contracts`; the domains its bundled tools need — memory, traces, triggers — by declared charter. - **Never depends on:** `ironclaw_assistant`, `ironclaw_extension_host`, `ironclaw_extension_manager`, or `ironclaw_loop_host` — a package is content, not a host. diff --git a/scripts/ci/composition-budget.toml b/scripts/ci/composition-budget.toml index 406fbb9bf71..c8d76d4a242 100644 --- a/scripts/ci/composition-budget.toml +++ b/scripts/ci/composition-budget.toml @@ -89,12 +89,33 @@ observed_date = "2026-07-16" # dissolves the gates-before-movers ordering collision measured earlier # (the ceiling seeded pre-batch red the batch by 115 LOC). # +# Re-ratcheted 45127 -> 42938 on 2026-08-04 by the WS6 service-cluster eviction +# (admin-user directory + blocked-auth resume fan-out -> ironclaw_product; turn-end +# trace capture -> ironclaw_reborn_traces::capture + ironclaw_runner::trace_capture). +# -2189 LOC, measured on this branch with +# `bash scripts/ci/check-composition-budget.sh --print`. Set to current, not +# padded. Sibling WS6 slices lower it further; numeric conflicts belong to the +# coordinator, who takes the LOWEST measured value of the merged tree. +# # RE-RATCHET AT EVERY WAVE CLOSE. When a wave evicts behavior from composition, # lower loc_ceiling to the new observed count in the same PR; the gate prints a # NUDGE once observed sits more than loc_nudge_slack below the ceiling, so the # obligation is visible in CI output rather than remembered. Raising it is # allowed but must carry a one-line PR rationale, same rule as ceiling_bp. -loc_ceiling = 45127 +# +# Re-ratcheted 45127 -> 42688 on 2026-08-04 by the WS6 policy evictions +# (approval gate -> ironclaw_approvals, fire-time trigger access -> +# ironclaw_triggers): -2,439 production LOC. This is the "RE-RATCHET AT EVERY +# WAVE CLOSE" rule below being executed, not headroom being claimed — measured +# with `bash scripts/ci/check-composition-budget.sh --print` on the post- +# eviction tree. Sibling WS6 branches are shrinking composition too, so a +# numeric conflict here is expected and trivial: take the LOWER number, then +# re-measure on the merged tree rather than trusting either side's figure. +# +# Union re-seed on the waves-0-4 batch, 2026-08-04: the WS6 service-cluster +# eviction (-2,189) and the WS6 policy eviction (-2,439) are disjoint; on the +# merged tree the deltas add exactly: 45,127 - 2,189 - 2,439 = 40,499. +loc_ceiling = 40499 # Working slack for in-flight PRs. Deliberately small: the inflow this gate # exists to catch was +619 lines, and a tolerance that would have absorbed it # is a gate that constrains nothing. A change adding more than this to @@ -105,7 +126,7 @@ loc_tolerance = 150 loc_nudge_slack = 200 # Informational — observed when this file was last updated. Not consulted for # the pass/fail decision. -loc_observed = 45127 +loc_observed = 40499 loc_observed_date = "2026-08-04" # --- Dispatch (Arc) ratchet ------------------------------------------ diff --git a/tests/integration/auth/oauth_popup_journeys.rs b/tests/integration/auth/oauth_popup_journeys.rs index 5bd2ab34db8..5fd7c7d4663 100644 --- a/tests/integration/auth/oauth_popup_journeys.rs +++ b/tests/integration/auth/oauth_popup_journeys.rs @@ -67,15 +67,15 @@ async fn oauth_connect_binds_channel_identity_through_the_generic_hook() { mount::{MountGrant, MountPermissions, MountView}, path::{MountAlias, VirtualPath}, resource::ResourceScope, - }; - use ironclaw_reborn_composition::{ - RebornUserIdentityBinding, RebornUserIdentityBindingDeleteStore, - RebornUserIdentityBindingError, RebornUserIdentityBindingStore, - test_support::{ - build_oauth_product_auth_with_identity_for_test, - handle_oauth_callback_with_channel_identity_binding_for_test, + user_identity::{ + RebornUserIdentityBinding, RebornUserIdentityBindingDeleteStore, + RebornUserIdentityBindingError, RebornUserIdentityBindingStore, }, }; + use ironclaw_reborn_composition::test_support::{ + build_oauth_product_auth_with_identity_for_test, + handle_oauth_callback_with_channel_identity_binding_for_test, + }; use ironclaw_secrets::{SecretMaterial, SecretStore, SecretStorePort}; use secrecy::SecretString; diff --git a/tests/integration/hooks.rs b/tests/integration/hooks.rs index fb07f88b683..b844c6bbd98 100644 --- a/tests/integration/hooks.rs +++ b/tests/integration/hooks.rs @@ -1,6 +1,7 @@ //! C-HOOKS (+ E-HOOK-INFRA): a wired `hook_dispatcher_builder_factory` should //! fire hooks at the expected lifecycle points on a real coordinator-path turn, -//! and a hook deny should block the capability without wedging the run. +//! a hook deny should block the capability without wedging the run, and +//! dispatcher-owned state must not leak from one run into the next (#6945). //! //! These drive a full coordinator-path turn with an active hook dispatcher — //! the first tests to do so — so they also pin that `HookedLoopCheckpointPort` @@ -22,7 +23,8 @@ use ironclaw_hooks::dispatch::HOOK_DENY_PREDICATE_CODE; use reborn_support::assertions::ToolErrorClass; use reborn_support::builder::{RebornIntegrationHarness, StorageMode}; use reborn_support::hooks::{ - HOOK_TEST_DENY_REASON, RecordingHookLog, denying_hook_factory, recording_hook_factory, + HOOK_TEST_DENY_REASON, RecordingHookLog, denying_hook_factory, poisoning_hook_factory, + recording_hook_factory, }; use reborn_support::reply::RebornScriptedReply; use serde_json::json; @@ -196,3 +198,92 @@ async fn hook_deny_blocks_capability_without_wedging_run() { .await .expect("hook deny must record a security-audit event through the harness recorder"); } + +/// #6945: dispatcher-owned state must not survive from one run into the next. +/// +/// `RebornLoopDriverHostFactory` offers three hook seams with two deliberately +/// different lifetimes. Production wires the isolating one +/// (`with_hook_dispatcher_builder_factory`, minted by +/// `ironclaw_reborn_composition::hooks` and installed at +/// `ironclaw_runner::runtime`), so the closure runs once per +/// `build_text_only_host*` — i.e. once per run. The legacy +/// `with_hook_dispatcher(Arc)` adapter deliberately does the +/// opposite and clones one dispatcher into every build. Nothing failed if a +/// caller swapped one for the other, and `crates/ironclaw_hooks/CLAUDE.md` once +/// claimed a regression test — naming a file and two tests that never existed +/// — which is the gap #6945 tracks. +/// +/// The observable is slot poisoning. The installed hook commits a gate-sink +/// protocol violation, so run 1 fails closed (the capability is denied and +/// never reaches the wire) **and** the hook's slot is poisoned. A poisoned slot +/// is skipped for the rest of that dispatcher's life. So: +/// +/// - per-run dispatcher (production): run 2 gets a clean slot, the hook fires a +/// second time, and the fail-closed deny is re-applied — 2 fires, 0 egress. +/// - shared dispatcher (legacy adapter): run 2 skips the poisoned hook entirely, +/// so the gate goes quiet and the capability reaches the wire — 1 fire, 1 +/// egress. Both assertions below flip, which is what makes this red-able. +/// +/// Deliberately NOT asserted: predicate counter state. It is keyed by +/// `(hook_id, tenant_id, capability)` and shared across runs *by design* (the +/// evaluator is built once per tenant by composition, outside the per-run +/// closure), so asserting isolation for it would pin a rate-cap bypass. +#[tokio::test] +async fn poisoned_hook_slot_does_not_leak_into_the_next_run() { + let log = RecordingHookLog::new(); + let h = RebornIntegrationHarness::test_default() + .with_builtin_http_tools() + .with_hook_factory(poisoning_hook_factory(log.clone(), "builtin.http")) + // One entry per model call: each turn makes a tool call and then a + // terminal text reply, so two turns need four. + .script([ + RebornScriptedReply::tool_call("builtin.http", json!({"url": HTTP_TOOL_URL})), + RebornScriptedReply::text("first done"), + RebornScriptedReply::tool_call("builtin.http", json!({"url": HTTP_TOOL_URL})), + RebornScriptedReply::text("second done"), + ]) + .build() + .await + .expect("harness builds"); + + h.submit_turn("fetch items") + .await + .expect("turn 1 completes"); + assert_eq!( + log.fires(), + vec!["before_capability_poison:builtin.http"], + "run 1 must dispatch the hook exactly once before it poisons its slot" + ); + h.assert_egress_count(0) + .await + .expect("run 1's fail-closed deny must keep the capability off the wire"); + + h.submit_turn("fetch items again") + .await + .expect("turn 2 completes"); + + // The load-bearing assertion. Under the legacy shared-dispatcher adapter the + // run-1 poison survives into run 2, the hook is skipped, and this stays at + // one fire. + assert_eq!( + log.fires(), + vec![ + "before_capability_poison:builtin.http", + "before_capability_poison:builtin.http", + ], + "run 2 must get a fresh dispatcher with an un-poisoned slot, so the hook \ + fires again; a shared dispatcher would skip it and record only one fire" + ); + // …and the consequence that makes the leak a security problem rather than a + // telemetry one: a skipped gate hook is an un-applied deny, so the + // capability would reach real egress in run 2. + h.assert_egress_count(0) + .await + .expect("run 2 must re-apply the fail-closed deny from a clean slot"); + h.assert_tool_error( + ToolErrorClass::Denied, + "hook completed without minting a decision", + ) + .await + .expect("run 2's denial must carry the fail-closed reason, not a stale/blank one"); +} diff --git a/tests/integration/support/harness/profiles/extension.rs b/tests/integration/support/harness/profiles/extension.rs index 16f8f873cd2..4c7d2fffc92 100644 --- a/tests/integration/support/harness/profiles/extension.rs +++ b/tests/integration/support/harness/profiles/extension.rs @@ -1003,7 +1003,7 @@ pub(crate) fn extension_delivery_tools_profile() -> HarnessResult /// inbound request. fn slack_channel_extension_binding() -> ironclaw_reborn_composition::ChannelExtensionBinding { ironclaw_reborn_composition::ChannelExtensionBinding { - extension_id: "slack".to_string(), + extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, @@ -1013,7 +1013,7 @@ fn slack_channel_extension_binding() -> ironclaw_reborn_composition::ChannelExte fn telegram_channel_extension_binding() -> ironclaw_reborn_composition::ChannelExtensionBinding { ironclaw_reborn_composition::ChannelExtensionBinding { - extension_id: "telegram".to_string(), + extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted("telegram".to_string()), adapter: Arc::new(ironclaw_telegram_extension::TelegramChannelAdapter::default()), preference_target_codec: None, } diff --git a/tests/integration/support/hooks.rs b/tests/integration/support/hooks.rs index cf1b8ad13b9..89377b02fc7 100644 --- a/tests/integration/support/hooks.rs +++ b/tests/integration/support/hooks.rs @@ -108,6 +108,39 @@ impl PrivilegedBeforeCapabilityHook for DenyBeforeCapabilityHook { } } +/// Records its fire against `poison_target` and then returns **without minting +/// a decision** — the gate-sink protocol violation that +/// `HookDispatcher::run_before_capability_hook` classifies as +/// `FailureCategory::Malformed`, which (a) fails closed into a `Deny` for the +/// dispatched capability and (b) poisons the hook's registry slot for the +/// remaining life of that dispatcher. +/// +/// Protocol violation rather than `panic!` on purpose: it reaches the identical +/// `Err(failure)` arm (`classify_failure` poisons the slot either way) without +/// spraying an unwind backtrace across the test log. #6945 names both routes. +/// +/// Poisoning is what makes cross-run dispatcher isolation *observable*: a +/// dispatcher that survives into the next run has already skipped this slot, so +/// the hook goes quiet and its fail-closed deny silently stops being applied. +struct PoisoningBeforeCapabilityHook { + log: RecordingHookLog, + poison_target: String, +} + +#[async_trait] +impl PrivilegedBeforeCapabilityHook for PoisoningBeforeCapabilityHook { + async fn evaluate(&self, ctx: &BeforeCapabilityHookContext, sink: &mut dyn PrivilegedGateSink) { + if ctx.capability_name == self.poison_target { + self.log + .record(format!("before_capability_poison:{}", ctx.capability_name)); + // Deliberately mint nothing: `GateSinkState::Unset` is the + // malformed-hook path that fails closed and poisons the slot. + return; + } + sink.pass(); + } +} + fn hook_install_err(context: &str, error: impl std::fmt::Display) -> RebornLoopDriverHostError { RebornLoopDriverHostError::InvalidRequest { reason: format!("failed to install {context} recording hook: {error}"), @@ -140,6 +173,35 @@ pub fn recording_hook_factory(log: RecordingHookLog) -> HookDispatcherBuilderFac }) } +/// Installs a `BeforeCapability` hook that poisons its own slot on first fire +/// against `poison_target` (see [`PoisoningBeforeCapabilityHook`]). +/// +/// Like the other factories here it mints a **fresh** `HookRegistry` per call, +/// so the poison a run leaves behind can only reach the next run if the *host +/// factory* reuses one dispatcher across builds. That makes the returned +/// factory the probe for #6945's cross-run isolation semantic: the hook fires +/// once per run under the per-build seam, and exactly once ever under the +/// legacy shared-dispatcher adapter. +pub fn poisoning_hook_factory( + log: RecordingHookLog, + poison_target: impl Into, +) -> HookDispatcherBuilderFactory { + let poison_target = poison_target.into(); + Arc::new(move || { + let log = log.clone(); + let poison_target = poison_target.clone(); + let before_capability_id = + HookId::for_builtin(RECORDING_BEFORE_CAPABILITY_PATH, HookVersion::ONE); + HookDispatcherBuilder::new(HookRegistry::new()) + .install_builtin_before_capability( + before_capability_id, + HookPhase::Policy, + Box::new(PoisoningBeforeCapabilityHook { log, poison_target }), + ) + .map_err(|error| hook_install_err("poisoning before_capability", error)) + }) +} + /// Installs a recording `AfterModel` observer + a `BeforeCapability` hook that /// DENIES `deny_target`; proves a hook deny blocks the capability without wedging the run. pub fn denying_hook_factory(