From c276e9eff634ba90651e5410e34ec4a352b5ca3f Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 07:28:34 +0000 Subject: [PATCH 1/4] refactor(composition): enforcement axes become resolved policy values, not profile matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of #6274 — finishing `DeploymentConfig` as the main composition config (§4.4/§5.6 of the architecture-simplification note). Two composition sites derived enforcement authority by branching on a deployment mode rather than consuming resolved policy data: - `runtime.rs` skipped the model-budget accountant by matching `RebornCompositionProfile::LocalDevYolo`; - `RuntimeProfileApprovalGatePolicy` stored a `RuntimeProfile` and asked it `allows_minimal_approval_bypass()`. Both now read values classified once by the sanctioned resolver: `ironclaw_runtime_policy::{budget_enforcement, minimal_approval_bypass}` returning `BudgetEnforcement` / `MinimalApprovalBypass`. The classification lives in the one crate the guardrails already name as the only producer of `EffectiveRuntimePolicy`, so no consumer past the composition edge names a mode. The classifications key on `resolved_profile` (post-narrowing), which fixes a real gap: a tenant/org ceiling narrowing `LocalYolo` down to `LocalDev` previously left budgets unenforced and `Minimal` still bypassing gates, because the branch read the *requested* composition profile. Authority reductions now reach both axes. Also removes the `unwrap_or(RuntimeProfile::LocalDev)` fallback in `local_dev_approval_policy`, which invented a deployment profile to derive authority from when no policy was resolved, and defaulted the approval width to `AskDestructive`. The absent-policy path now fails closed to `AskAlways` with the bypass denied (`.claude/rules/error-handling.md`). Deliberately NOT stored as `EffectiveRuntimePolicy` fields: the resolver is the sole production constructor, and the other 99 construction sites are test fixtures, so stored fields would mean a 39-file mechanical diff per axis for a value that is a pure function of a field already serialized into the audit payload and the capability-surface digest. Regression tests (each fails before the change): - `org_ceiling_narrowing_restores_budget_enforcement` and `minimal_approval_bypass_tracks_the_resolved_profile` (resolver tier) - `org_ceiling_narrowing_yolo_away_restores_minimal_approval_gates` (gate-policy tier) - `absent_runtime_policy_fails_closed_to_ask_always_without_minimal_bypass` and `resolved_yolo_policy_allows_minimal_bypass_but_org_ceiling_removes_it` (driven through `local_dev_effects_require_approval`, the production caller) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/local_dev_authorization.rs | 42 ++-- .../src/local_dev_authorization/tests.rs | 62 ++++++ .../src/runtime.rs | 32 ++- .../src/runtime_profile_approval_policy.rs | 76 ++++++- crates/ironclaw_runtime_policy/src/lib.rs | 15 +- .../ironclaw_runtime_policy/src/resolver.rs | 196 ++++++++++++++++++ 6 files changed, 385 insertions(+), 38 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/local_dev_authorization.rs b/crates/ironclaw_reborn_composition/src/local_dev_authorization.rs index ea346cebaac..aba41e52346 100644 --- a/crates/ironclaw_reborn_composition/src/local_dev_authorization.rs +++ b/crates/ironclaw_reborn_composition/src/local_dev_authorization.rs @@ -16,8 +16,9 @@ use ironclaw_approvals::{ use ironclaw_authorization::TrustAwareCapabilityDispatchAuthorizer; use ironclaw_host_api::{ CapabilityId, EffectKind, InvocationId, Principal, ResourceScope, - runtime_policy::{ApprovalPolicy, EffectiveRuntimePolicy, RuntimeProfile}, + runtime_policy::{ApprovalPolicy, EffectiveRuntimePolicy}, }; +use ironclaw_runtime_policy::MinimalApprovalBypass; use tokio::sync::Notify; use crate::builtin_capability_policy::BuiltinCapabilityPolicy; @@ -33,11 +34,11 @@ pub(crate) fn local_dev_authorizer( capability_policy: Arc, settings: Arc, ) -> Arc { - let (approval_policy, resolved_profile) = local_dev_approval_policy(runtime_policy); + let (approval_policy, minimal_bypass) = local_dev_approval_policy(runtime_policy); let gate_effects = capability_policy.approval_gate_effects(); let exempt_capabilities = capability_policy.approval_gate_exempt_capabilities(); let gate_policy: Arc = Arc::new( - RuntimeProfileApprovalGatePolicy::new(resolved_profile, gate_effects) + RuntimeProfileApprovalGatePolicy::new(minimal_bypass, gate_effects) .with_exempt_capabilities(exempt_capabilities), ); profile_approval_authorizer(approval_policy, gate_policy, settings) @@ -571,24 +572,31 @@ pub(crate) fn local_dev_effects_require_approval( capability_policy: &BuiltinCapabilityPolicy, effects: &[EffectKind], ) -> bool { - let (approval_policy, resolved_profile) = local_dev_approval_policy(runtime_policy); - RuntimeProfileApprovalGatePolicy::new( - resolved_profile, - capability_policy.approval_gate_effects(), - ) - .effects_require_approval(approval_policy, effects) + let (approval_policy, minimal_bypass) = local_dev_approval_policy(runtime_policy); + RuntimeProfileApprovalGatePolicy::new(minimal_bypass, capability_policy.approval_gate_effects()) + .effects_require_approval(approval_policy, effects) } +/// Approval width plus the resolved `Minimal`-bypass value for a runtime +/// policy. +/// +/// The absent-policy case fails closed: `AskAlways` with the bypass denied. +/// This deliberately replaces the previous `unwrap_or(RuntimeProfile::LocalDev)` +/// fallback, which silently invented a *deployment profile* when none was +/// resolved — the mode-as-type leak §4.4 removes, and a silent default on an +/// authority decision that `.claude/rules/error-handling.md` forbids. Every +/// production path resolves a policy (`build_reborn_runtime` rejects the input +/// otherwise), so this arm is reachable only from callers that never had one. fn local_dev_approval_policy( runtime_policy: Option<&EffectiveRuntimePolicy>, -) -> (ApprovalPolicy, RuntimeProfile) { - let approval_policy = runtime_policy - .map(|policy| policy.approval_policy) - .unwrap_or(ApprovalPolicy::AskDestructive); - let resolved_profile = runtime_policy - .map(|policy| policy.resolved_profile) - .unwrap_or(RuntimeProfile::LocalDev); - (approval_policy, resolved_profile) +) -> (ApprovalPolicy, MinimalApprovalBypass) { + match runtime_policy { + Some(policy) => ( + policy.approval_policy, + ironclaw_runtime_policy::minimal_approval_bypass(policy), + ), + None => (ApprovalPolicy::AskAlways, MinimalApprovalBypass::Denied), + } } #[cfg(test)] diff --git a/crates/ironclaw_reborn_composition/src/local_dev_authorization/tests.rs b/crates/ironclaw_reborn_composition/src/local_dev_authorization/tests.rs index e6c3ec32f06..516d9a292fa 100644 --- a/crates/ironclaw_reborn_composition/src/local_dev_authorization/tests.rs +++ b/crates/ironclaw_reborn_composition/src/local_dev_authorization/tests.rs @@ -1010,3 +1010,65 @@ async fn global_auto_approve_does_not_bypass_manifest_ineligible_tool_through_st "global auto-approve must not bypass a manifest-ineligible tool, got {decision:?}" ); } + +#[test] +fn absent_runtime_policy_fails_closed_to_ask_always_without_minimal_bypass() { + // Regression for the §4.4 mode-as-type leak: `local_dev_approval_policy` + // used to answer an absent runtime policy with + // `unwrap_or(RuntimeProfile::LocalDev)` — inventing a *deployment profile* + // to derive authority from, and defaulting the approval width to the + // narrower-than-safest `AskDestructive`. It now fails closed on both axes. + let policy = builtin_capability_policy().expect("capability policy"); + + // `ReadFilesystem` is not in either gate-effect set, so under the old + // `AskDestructive` default it did not require approval. Under the + // fail-closed `AskAlways` default any non-empty effect set does. + assert!( + local_dev_effects_require_approval(None, &policy, &[EffectKind::ReadFilesystem]), + "an absent runtime policy must fail closed to AskAlways" + ); + // The empty effect set still needs no approval under AskAlways — the + // fallback tightens the width, it does not gate effect-free capabilities. + assert!(!local_dev_effects_require_approval(None, &policy, &[])); +} + +#[test] +fn resolved_yolo_policy_allows_minimal_bypass_but_org_ceiling_removes_it() { + // The paired positive case, driven through the production caller: a + // resolved trusted-laptop policy bypasses effect gates under `Minimal`, + // and the same request under an org ceiling does not. + let policy = builtin_capability_policy().expect("capability policy"); + let effects = [EffectKind::SpawnProcess]; + + let yolo = ironclaw_runtime_policy::resolve(ironclaw_runtime_policy::ResolveRequest { + yolo_disclosure_acknowledged: true, + ..ironclaw_runtime_policy::ResolveRequest::new( + ironclaw_host_api::runtime_policy::DeploymentMode::LocalSingleUser, + ironclaw_host_api::runtime_policy::RuntimeProfile::LocalYolo, + ) + }) + .expect("local yolo resolves"); + assert_eq!( + yolo.approval_policy, + ironclaw_host_api::runtime_policy::ApprovalPolicy::Minimal + ); + assert!( + !local_dev_effects_require_approval(Some(&yolo), &policy, &effects), + "resolved local-yolo must bypass effect gates under Minimal" + ); + + let narrowed = ironclaw_runtime_policy::resolve(ironclaw_runtime_policy::ResolveRequest { + yolo_disclosure_acknowledged: true, + org_policy: ironclaw_runtime_policy::OrgPolicyConstraints::default() + .set_max_profile(ironclaw_host_api::runtime_policy::RuntimeProfile::LocalDev), + ..ironclaw_runtime_policy::ResolveRequest::new( + ironclaw_host_api::runtime_policy::DeploymentMode::LocalSingleUser, + ironclaw_host_api::runtime_policy::RuntimeProfile::LocalYolo, + ) + }) + .expect("narrowed local yolo resolves"); + assert!( + local_dev_effects_require_approval(Some(&narrowed), &policy, &effects), + "an org ceiling that removes yolo must restore effect gates" + ); +} diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 11e0cd23916..e533ed5b9ef 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -3159,12 +3159,18 @@ pub async fn build_reborn_runtime( reason: format!("profile={profile} must not start live Reborn runtime traffic"), }); } - if services_input.runtime_policy().is_none() { - return Err(RebornRuntimeError::InvalidArgument { - reason: "RebornRuntimeInput.services must include a resolved runtime policy" - .to_string(), - }); - } + // Capture the resolved policy before `build_reborn_services` consumes the + // input. Downstream wiring selects enforcement behaviour from resolved + // policy *values* (§4.4) rather than re-branching on the deployment + // profile, so the policy has to outlive the services input. + let runtime_policy = + services_input + .runtime_policy() + .cloned() + .ok_or(RebornRuntimeError::InvalidArgument { + reason: "RebornRuntimeInput.services must include a resolved runtime policy" + .to_string(), + })?; let validated_identity = validate_runtime_identity(identity)?; services_input = services_input.with_local_runtime_identity( @@ -3418,8 +3424,11 @@ pub async fn build_reborn_runtime( let resolved_cost_table = llm_cost_table_arc; // Build the model budget accountant from the resolved cost table plus - // the local-dev governor. `local-dev-yolo` is the explicit local - // exception: it inherits host trust and must not pause on budget gates. + // the local-dev governor. `BudgetEnforcement::Unenforced` — the resolved + // trusted-laptop boundary — is the explicit exception: it inherits host + // trust and must not pause on budget gates. Reading the resolved value + // rather than the deployment profile means a tenant/org ceiling that + // narrows yolo away also restores enforcement (§4.4). // When neither an LLM policy nor a test override supplies a cost table // we deliberately skip the accountant — there's no spend to track and // the cascade would never fire. @@ -3437,8 +3446,11 @@ pub async fn build_reborn_runtime( // re-read by the wiring helper). let model_budget_accountant: Option< Arc, - > = match (profile, resolved_cost_table) { - (RebornCompositionProfile::LocalDevYolo, _) => None, + > = match ( + ironclaw_runtime_policy::budget_enforcement(&runtime_policy), + resolved_cost_table, + ) { + (ironclaw_runtime_policy::BudgetEnforcement::Unenforced, _) => None, (_, Some(cost_table)) => { let resolved_budget_defaults = match budget_defaults { Some(defaults) => { diff --git a/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs b/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs index 1426f38a54f..1ae693815a8 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_profile_approval_policy.rs @@ -1,7 +1,5 @@ -use ironclaw_host_api::{ - CapabilityId, EffectKind, - runtime_policy::{ApprovalPolicy, RuntimeProfile}, -}; +use ironclaw_host_api::{CapabilityId, EffectKind, runtime_policy::ApprovalPolicy}; +use ironclaw_runtime_policy::MinimalApprovalBypass; use crate::profile_approval_authorization::ProfileApprovalGatePolicy; @@ -22,18 +20,22 @@ impl RuntimeProfileApprovalGateEffectSets { #[derive(Debug, Clone)] pub(crate) struct RuntimeProfileApprovalGatePolicy { - resolved_profile: RuntimeProfile, + /// 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` + /// is the one place that classification lives. + minimal_bypass: MinimalApprovalBypass, effects: RuntimeProfileApprovalGateEffectSets, exempt_capabilities: Vec, } impl RuntimeProfileApprovalGatePolicy { pub(crate) fn new( - resolved_profile: RuntimeProfile, + minimal_bypass: MinimalApprovalBypass, effects: RuntimeProfileApprovalGateEffectSets, ) -> Self { Self { - resolved_profile, + minimal_bypass, effects, exempt_capabilities: Vec::new(), } @@ -48,7 +50,7 @@ impl RuntimeProfileApprovalGatePolicy { } fn profile_allows_minimal_bypass(&self) -> bool { - self.resolved_profile.allows_minimal_approval_bypass() + self.minimal_bypass == MinimalApprovalBypass::Allowed } } @@ -97,14 +99,20 @@ impl ProfileApprovalGatePolicy for RuntimeProfileApprovalGatePolicy { mod tests { use ironclaw_host_api::{ EffectKind, - runtime_policy::{ApprovalPolicy, RuntimeProfile}, + runtime_policy::{ApprovalPolicy, DeploymentMode, RuntimeProfile}, }; + use ironclaw_runtime_policy::{OrgPolicyConstraints, ResolveRequest}; use super::*; + /// Build the gate policy the way production does: resolve the profile + /// through the sanctioned resolver, then classify the resolved policy. + /// Driving the real resolver here (rather than hand-picking a bypass + /// value) keeps this suite honest about which profiles actually reach + /// `MinimalApprovalBypass::Allowed`. fn policy(profile: RuntimeProfile) -> RuntimeProfileApprovalGatePolicy { RuntimeProfileApprovalGatePolicy::new( - profile, + bypass_for(profile), RuntimeProfileApprovalGateEffectSets::new( vec![EffectKind::WriteFilesystem, EffectKind::SpawnProcess], vec![EffectKind::SpawnProcess], @@ -112,6 +120,25 @@ mod tests { ) } + fn bypass_for(profile: RuntimeProfile) -> MinimalApprovalBypass { + let deployment = match profile { + RuntimeProfile::HostedSafe + | RuntimeProfile::HostedDev + | RuntimeProfile::HostedYoloTenantScoped => DeploymentMode::HostedMultiTenant, + RuntimeProfile::EnterpriseSafe + | RuntimeProfile::EnterpriseDev + | RuntimeProfile::EnterpriseYoloDedicated => DeploymentMode::EnterpriseDedicated, + _ => DeploymentMode::LocalSingleUser, + }; + let resolved = ironclaw_runtime_policy::resolve(ResolveRequest { + yolo_disclosure_acknowledged: true, + org_policy: OrgPolicyConstraints::default().set_admin_approves_dedicated_yolo(true), + ..ResolveRequest::new(deployment, profile) + }) + .expect("test profile resolves"); + ironclaw_runtime_policy::minimal_approval_bypass(&resolved) + } + #[test] fn hard_floor_forces_approval_for_high_risk_effects_on_real_policy() { // The production gate policy must hard-floor these even under the most @@ -169,6 +196,35 @@ mod tests { } } + #[test] + fn org_ceiling_narrowing_yolo_away_restores_minimal_approval_gates() { + // Regression for the mode-as-type leak (§4.4): the gate policy used to + // hold a `RuntimeProfile` and ask it about itself. It now consumes a + // resolved value, so a tenant/org ceiling that narrows `LocalYolo` + // down to `LocalDev` also re-gates `Minimal` — authority reductions + // reach this axis instead of stopping at the requested profile. + let narrowed = ironclaw_runtime_policy::resolve(ResolveRequest { + yolo_disclosure_acknowledged: true, + org_policy: OrgPolicyConstraints::default().set_max_profile(RuntimeProfile::LocalDev), + ..ResolveRequest::new(DeploymentMode::LocalSingleUser, RuntimeProfile::LocalYolo) + }) + .expect("narrowed local yolo resolves"); + assert!(narrowed.was_reduced()); + + let gate_policy = RuntimeProfileApprovalGatePolicy::new( + ironclaw_runtime_policy::minimal_approval_bypass(&narrowed), + RuntimeProfileApprovalGateEffectSets::new( + vec![EffectKind::WriteFilesystem, EffectKind::SpawnProcess], + vec![EffectKind::SpawnProcess], + ), + ); + assert!( + gate_policy + .effects_require_approval(ApprovalPolicy::Minimal, &[EffectKind::SpawnProcess]), + "an org ceiling that removes yolo must restore Minimal approval gates" + ); + } + #[test] fn hosted_dev_ask_destructive_gates_process_but_not_read_only_effects() { let policy = policy(RuntimeProfile::HostedDev); diff --git a/crates/ironclaw_runtime_policy/src/lib.rs b/crates/ironclaw_runtime_policy/src/lib.rs index 6d0958c147d..0aa9bd3bdb6 100644 --- a/crates/ironclaw_runtime_policy/src/lib.rs +++ b/crates/ironclaw_runtime_policy/src/lib.rs @@ -30,6 +30,16 @@ //! compatibility matrix prevents this at the `(deployment, profile)` step //! before the backend mapping runs. //! +//! ## Resolved policy values +//! +//! Beyond the backend/mode fields on [`EffectiveRuntimePolicy`], this crate +//! classifies the resolved policy into the enforcement axes composition needs: +//! [`budget_enforcement`] and [`minimal_approval_bypass`]. Both are keyed on +//! the *resolved* profile, so a tenant/org ceiling narrowing authority reaches +//! them for free. They exist so no consumer past the composition edge branches +//! on a deployment mode — §4.4 of +//! `docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md`. +//! //! ## Determinism and audit //! //! [`resolve`] is deterministic — equal inputs always produce equal outputs, @@ -41,7 +51,10 @@ mod resolver; -pub use resolver::{OrgPolicyConstraints, ResolveError, ResolveRequest, resolve}; +pub use resolver::{ + BudgetEnforcement, MinimalApprovalBypass, OrgPolicyConstraints, ResolveError, ResolveRequest, + budget_enforcement, minimal_approval_bypass, resolve, +}; // `EffectiveRuntimePolicy` appears in `resolve`'s return type, so it must be // reachable from the crate root. Other host-api runtime_policy types are not diff --git a/crates/ironclaw_runtime_policy/src/resolver.rs b/crates/ironclaw_runtime_policy/src/resolver.rs index d93f28923a7..828f3c55408 100644 --- a/crates/ironclaw_runtime_policy/src/resolver.rs +++ b/crates/ironclaw_runtime_policy/src/resolver.rs @@ -447,6 +447,88 @@ fn audit_for(deployment: DeploymentMode) -> AuditMode { } } +/// Whether the resolved runtime boundary enforces model-spend budgets. +/// +/// A resolved policy *value*, not a deployment mode. Composition selects the +/// budget accountant from this instead of branching on a profile name — §4.4 +/// of `docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md` +/// ("resolve mode to policy data at the composition edge; the kernel never +/// names a mode"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BudgetEnforcement { + /// Reserve model spend against budget accounts and pause the run on a + /// budget gate. The default for every deployment. + Enforced, + /// Skip the budget accountant entirely: no reservation, no gate, no + /// cascade. Reserved for the single-user trusted-laptop boundary, which + /// already inherits full host trust and must not pause on spend. + Unenforced, +} + +/// Whether [`ApprovalPolicy::Minimal`] may bypass approval gates under the +/// resolved runtime boundary. +/// +/// Same shape and rationale as [`BudgetEnforcement`]: the approval gate policy +/// consumes this value rather than storing a [`RuntimeProfile`] and asking it +/// about itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MinimalApprovalBypass { + /// `Minimal` suppresses effect-driven approval gates. + Allowed, + /// `Minimal` still gates effects — the fail-closed default. + Denied, +} + +/// Classify budget enforcement for a resolved policy. +/// +/// Keyed on `resolved_profile`, so a tenant/org ceiling that narrows +/// `LocalYolo` down to `LocalDev` restores budget enforcement: authority +/// reductions reach this axis for free, which a caller branching on its own +/// requested deployment mode does not get. +/// +/// [`RuntimeProfile`] is `#[non_exhaustive]`, so every variant is listed +/// explicitly and the required wildcard fails closed to +/// [`BudgetEnforcement::Enforced`]: an unclassified future profile keeps +/// budgets on rather than silently inheriting the laptop exception. +pub fn budget_enforcement(policy: &EffectiveRuntimePolicy) -> BudgetEnforcement { + match policy.resolved_profile { + // The trusted-laptop boundary: single-user, host-trust-inheriting, and + // explicitly disclosed. Every other profile — including the hosted and + // enterprise yolo tiers, which are multi-tenant or org-owned — keeps + // budgets enforced. + RuntimeProfile::LocalYolo => BudgetEnforcement::Unenforced, + RuntimeProfile::SecureDefault + | RuntimeProfile::LocalSafe + | RuntimeProfile::LocalDev + | RuntimeProfile::HostedSafe + | RuntimeProfile::HostedDev + | RuntimeProfile::HostedYoloTenantScoped + | RuntimeProfile::EnterpriseSafe + | RuntimeProfile::EnterpriseDev + | RuntimeProfile::EnterpriseYoloDedicated + | RuntimeProfile::Sandboxed + | RuntimeProfile::Experiment => BudgetEnforcement::Enforced, + // Fail closed: a profile variant added upstream and not yet classified + // here keeps budgets enforced. + _ => BudgetEnforcement::Enforced, + } +} + +/// Classify `Minimal`-approval bypass for a resolved policy. +/// +/// Mirrors [`RuntimeProfile::allows_minimal_approval_bypass`], which this +/// function is the sanctioned entry point for: consumers read the resolved +/// value instead of holding a profile. +pub fn minimal_approval_bypass(policy: &EffectiveRuntimePolicy) -> MinimalApprovalBypass { + if policy.resolved_profile.allows_minimal_approval_bypass() { + MinimalApprovalBypass::Allowed + } else { + MinimalApprovalBypass::Denied + } +} + #[cfg(test)] mod tests { use super::*; @@ -934,4 +1016,118 @@ mod tests { assert_eq!(policy.resolved_profile, profile); } } + + // --- resolved policy values (§4.4: mode becomes data) ------------------ + + #[test] + fn budget_enforcement_is_unenforced_only_for_the_trusted_laptop_boundary() { + let laptop = resolve(req_yolo( + DeploymentMode::LocalSingleUser, + RuntimeProfile::LocalYolo, + )) + .expect("local yolo resolves"); + assert_eq!( + budget_enforcement(&laptop), + BudgetEnforcement::Unenforced, + "local-yolo is the single trusted-laptop exception" + ); + + // Every other resolvable (deployment, profile) pair keeps budgets + // enforced — including the hosted and enterprise yolo tiers, which + // are multi-tenant or org-owned and must never inherit the laptop + // exception. + let enforced_pairs = [ + ( + DeploymentMode::LocalSingleUser, + RuntimeProfile::SecureDefault, + ), + (DeploymentMode::LocalSingleUser, RuntimeProfile::LocalSafe), + (DeploymentMode::LocalSingleUser, RuntimeProfile::LocalDev), + (DeploymentMode::LocalSingleUser, RuntimeProfile::Sandboxed), + (DeploymentMode::LocalSingleUser, RuntimeProfile::Experiment), + ( + DeploymentMode::HostedMultiTenant, + RuntimeProfile::HostedSafe, + ), + (DeploymentMode::HostedMultiTenant, RuntimeProfile::HostedDev), + ( + DeploymentMode::HostedMultiTenant, + RuntimeProfile::HostedYoloTenantScoped, + ), + ( + DeploymentMode::EnterpriseDedicated, + RuntimeProfile::EnterpriseSafe, + ), + ( + DeploymentMode::EnterpriseDedicated, + RuntimeProfile::EnterpriseDev, + ), + ]; + for (deployment, profile) in enforced_pairs { + let policy = resolve(req_yolo(deployment, profile)) + .unwrap_or_else(|e| panic!("({deployment:?}, {profile:?}) failed: {e}")); + assert_eq!( + budget_enforcement(&policy), + BudgetEnforcement::Enforced, + "{profile:?} under {deployment:?} must keep budgets enforced" + ); + } + } + + #[test] + fn org_ceiling_narrowing_restores_budget_enforcement() { + // The regression this axis exists to prevent: today's composition + // branches on the *requested* deployment profile, so an org ceiling + // that narrows local-yolo down to local-dev would still skip the + // budget accountant. Keying on `resolved_profile` fixes that. + let narrowed = resolve(ResolveRequest { + org_policy: OrgPolicyConstraints::default().set_max_profile(RuntimeProfile::LocalDev), + ..req_yolo(DeploymentMode::LocalSingleUser, RuntimeProfile::LocalYolo) + }) + .expect("narrowed local yolo resolves"); + + assert!(narrowed.was_reduced(), "ceiling must narrow the profile"); + assert_eq!(narrowed.requested_profile, RuntimeProfile::LocalYolo); + assert_eq!(narrowed.resolved_profile, RuntimeProfile::LocalDev); + assert_eq!( + budget_enforcement(&narrowed), + BudgetEnforcement::Enforced, + "an org ceiling that removes yolo must also restore budget enforcement" + ); + } + + #[test] + fn minimal_approval_bypass_tracks_the_resolved_profile() { + let bypass = resolve(req_yolo( + DeploymentMode::LocalSingleUser, + RuntimeProfile::LocalYolo, + )) + .expect("local yolo resolves"); + assert_eq!( + minimal_approval_bypass(&bypass), + MinimalApprovalBypass::Allowed + ); + + let gated = resolve(req( + DeploymentMode::LocalSingleUser, + RuntimeProfile::LocalDev, + )) + .expect("local dev resolves"); + assert_eq!( + minimal_approval_bypass(&gated), + MinimalApprovalBypass::Denied + ); + + // Same ceiling property as budgets: narrowing away yolo removes the + // bypass. + let narrowed = resolve(ResolveRequest { + org_policy: OrgPolicyConstraints::default().set_max_profile(RuntimeProfile::LocalDev), + ..req_yolo(DeploymentMode::LocalSingleUser, RuntimeProfile::LocalYolo) + }) + .expect("narrowed local yolo resolves"); + assert_eq!( + minimal_approval_bypass(&narrowed), + MinimalApprovalBypass::Denied + ); + } } From 3a66d974aa05867150d7ed312ee18429ca72d36b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 07:55:33 +0000 Subject: [PATCH 2/4] refactor(composition): DeploymentConfig owns every deployment axis; no branching on mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #6274 — the pivot (§4.4/§5.6/§5.11 of the architecture- simplification note). `DeploymentConfig` covered three of seven composition profiles and held only the runtime-policy request. It now covers all seven and carries every axis that code used to obtain by matching a `RebornCompositionProfile`: - `RuntimeSubstrate` — None | Local | ProductionShaped - `TrafficPolicy` — Disabled | ValidateOnly | Serve { required_readiness, veto_on_production_blocking_diagnostic } - `ReadinessContract` — the (state, diagnostics) pair - `StorageShape` — None | LocalDevRoot | HostedSingleTenantPool | OperatorSupplied - event-store profile, hosted-extension-installation-state - the runtime-policy request, now `Option` (disabled and the production-shaped profiles carry an operator-supplied policy on `RebornBuildInput` instead), so `resolve()` returns `Result>` — "no request" stays distinguishable from "a request that failed". `DeploymentConfig::for_profile` is the one place a profile name becomes deployment data. Converted to read it: - `enforce_runtime_cutover_gate` — a seven-arm match, each arm spelling out its own readiness precondition, becomes one `TrafficPolicy` read. The pre-build live-traffic check and the gate now share `TrafficPolicy::live_traffic_refusal` so they cannot drift on wording or on which deployments may start. - `build_reborn_services` and `build_reborn_runtime`'s runtime-parts selection — dispatch on `RuntimeSubstrate`. - `check_production_scheduler_wake_wiring` — reads the substrate. - `readiness_contract_for_profile` — a field read. - The four `profile == HostedSingleTenant` storage-pairing guards in `factory.rs`/`input.rs` — read `StorageShape`. Modeling the pairing as a storage axis is what let these go; they were never really about the mode. - The six `RebornCompositionProfile` predicates delegate to the config, so there is one source of truth rather than seven parallel matches. Result: `runtime.rs` no longer names a profile variant at all outside comments — the new ratchet's shrink check caught that and the allowlist entry is already gone. New `reborn_deployment_mode_branching_ratchet` (§10) freezes the SET of production composition files naming a profile variant; a file entering fails, and a file leaving must be removed from the allowlist in the same PR. Definition of done is `{deployment.rs}`; each remaining entry documents why it is still there and what retires it. Named owner and a scanner self-test per §10. Tests: `every_composition_profile_maps_to_a_deployment_config`, `substrate_and_traffic_axes_replace_the_profile_predicates` (pins the predicates as delegations), `a_serving_deployment_requires_its_own_ readiness_state` (a constructor setting the two independently would make the deployment unstartable — pinned here rather than found at boot), `only_production_vetoes_on_a_production_blocking_diagnostic`, `deployments_without_a_policy_request_resolve_to_none`, `readiness_contract_travels_on_the_config`. The existing cutover-gate cases now drive the gate through a `DeploymentConfig`. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eborn_deployment_mode_branching_ratchet.rs | 281 +++++++++ .../src/deployment.rs | 581 +++++++++++++++++- .../src/factory.rs | 25 +- .../ironclaw_reborn_composition/src/input.rs | 16 +- crates/ironclaw_reborn_composition/src/lib.rs | 2 +- .../src/local_runtime_profile.rs | 46 +- .../src/readiness.rs | 36 +- .../src/root/profile.rs | 53 +- .../src/runtime.rs | 143 ++--- .../src/runtime/tests/core.rs | 39 +- 10 files changed, 1002 insertions(+), 220 deletions(-) create mode 100644 crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs diff --git a/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs new file mode 100644 index 00000000000..f7f497c6218 --- /dev/null +++ b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs @@ -0,0 +1,281 @@ +//! Anti-slippage ratchet for the deployment-mode *branching* axis (§4.4 / §10 +//! of `docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md`). +//! +//! Its two siblings own deployment mode as a **type name** +//! (`reborn_localdev_typename_ratchet`, `reborn_deployment_mode_typename_ratchet`). +//! This one owns the behaviour those names were a symptom of: **code that +//! reads a deployment mode to decide what to do.** +//! +//! §4.4 is explicit that mode and lane get opposite treatment, and why: +//! +//! > A deployment mode must be branched on in exactly **zero** places past the +//! > composition edge — that is the whole §2.1 thesis — so giving it an enum +//! > would hand every crate an invitation to `match` on it (which is precisely +//! > how the 66-identifier `LocalDev*` family grew). +//! +//! `RebornCompositionProfile` *is* such an enum. It survives as the CLI/env +//! parse artifact and as a display label; what must not survive is consumers +//! reading its variants to select behaviour. `DeploymentConfig` (§5.6) is where +//! a profile becomes data — substrate, traffic policy, readiness contract, +//! storage shape — and everything downstream reads those fields. +//! +//! ## What this freezes +//! +//! The **set** of production files under `crates/ironclaw_reborn_composition/src` +//! that name a `RebornCompositionProfile` variant. Set membership, not a count, +//! per §10: a count lets a new violation silently replace a retired one; only +//! set membership catches a *swap*. A file entering the set fails; a file +//! leaving it must be removed from the allowlist in the same PR, so the debt +//! can only shrink. +//! +//! This is deliberately coarser than "detect a `match`": variant paths are what +//! branching needs, and a line-based scan cannot reliably tell +//! `match p { Profile::X => .. }` from `if p == Profile::X` from +//! `matches!(p, Profile::X | ..)` — all three are the same debt. +//! +//! ## Definition of done +//! +//! The allowlist reaches `{deployment.rs}` — `DeploymentConfig::for_profile`, +//! the one place a profile name becomes deployment data. The remaining entries +//! and why each is still here are documented on the allowlist itself. +//! +//! **Owner:** the #6274 driver (Illia Polosukhin) — the person driving this +//! allowlist to `{deployment.rs}`. §10 requires every ratchet to name one; an +//! unowned ratchet is telemetry, not a gate. +//! +//! Scanner semantics: comments and string literals are stripped before +//! matching, so this file's own doc comment and fixtures do not self-trip. +//! Skips `tests/`, `examples/`, `benches/` trees and `*tests.rs` files — +//! test fixtures naming a profile are not production branching. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Production files under composition `src/` allowed to name a +/// `RebornCompositionProfile` variant, each with the reason it is still here. +/// +/// Sorted; entries are `src/`-relative with `/` separators. +const ALLOWLIST: &[(&str, &str)] = &[ + ( + "deployment.rs", + "TARGET STATE — `DeploymentConfig::for_profile` is the one place a \ + profile name becomes deployment data (§4.4). This entry stays.", + ), + ( + "factory.rs", + "Storage-input/deployment pairing guards. Now read \ + `DeploymentConfig::storage_shape()`; the variant paths that remain are \ + test fixtures and the `local_dev_extension_installation_state_path` \ + label argument.", + ), + ( + "input.rs", + "`RebornBuildInput` constructors take a profile and pass it through to \ + `RebornServices::profile()`. Retires when the build input carries a \ + `DeploymentConfig` instead of a profile.", + ), + ( + "local_runtime_profile.rs", + "The composition edge itself: maps a profile to its config and rejects \ + deployments this local-runtime helper does not assemble. Retires into \ + `deployment.rs` when the build input carries a config.", + ), + ( + "readiness.rs", + "Readiness diagnostics carry a profile as an operator-facing **label** \ + on the wire (`RebornReadinessDiagnostic::profile`), not a branch. \ + Retires only if that wire field is reshaped.", + ), + ( + "webui/facade.rs", + "Inline `#[cfg(test)]` fixtures constructing a production-profile \ + readiness snapshot. Retires when those fixtures move under `tests/`.", + ), +]; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("architecture crate must live under crates/ironclaw_architecture") + .to_path_buf() +} + +/// Remove line comments, block comments, and string literals so that prose and +/// fixtures inside them cannot trip the scan. +fn strip_comments_and_strings(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut chars = source.chars().peekable(); + let mut in_line_comment = false; + let mut in_block_comment = false; + let mut in_string = false; + let mut escaped = false; + while let Some(ch) = chars.next() { + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + out.push('\n'); + } + continue; + } + if in_block_comment { + if ch == '*' && chars.peek() == Some(&'/') { + chars.next(); + in_block_comment = false; + } + continue; + } + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + match ch { + '/' if chars.peek() == Some(&'/') => { + chars.next(); + in_line_comment = true; + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + in_block_comment = true; + } + '"' => in_string = true, + _ => out.push(ch), + } + } + out +} + +fn is_scanned_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + name.ends_with(".rs") && !name.ends_with("tests.rs") +} + +fn collect(dir: &Path, root: &Path, found: &mut BTreeSet) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|err| panic!("read {}: {err}", dir.display())); + for entry in entries { + let entry = entry.unwrap_or_else(|err| panic!("read dir entry: {err}")); + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + // `*_tests` covers the inline test trees composition keeps beside + // production modules (e.g. `factory/local_dev_host_tests/`). + if matches!(name, "tests" | "examples" | "benches" | "target") + || name.ends_with("_tests") + { + continue; + } + collect(&path, root, found); + continue; + } + if !is_scanned_file(&path) { + continue; + } + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + if !strip_comments_and_strings(&source).contains("RebornCompositionProfile::") { + continue; + } + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + found.insert(relative); + } +} + +#[test] +fn deployment_mode_branching_allowlist_is_frozen_and_only_shrinks() { + let root = workspace_root().join("crates/ironclaw_reborn_composition/src"); + let mut found = BTreeSet::new(); + collect(&root, &root, &mut found); + + let allowed: BTreeSet = ALLOWLIST + .iter() + .map(|(path, _)| (*path).to_string()) + .collect(); + + let new_debt: Vec<&String> = found.difference(&allowed).collect(); + assert!( + new_debt.is_empty(), + "new deployment-mode branching in composition: {new_debt:?}\n\ + A `RebornCompositionProfile` variant in a production file means code is \ + reading a deployment mode to decide what to do (§4.4). Add the axis to \ + `DeploymentConfig` and read that field instead. If the reference is a \ + display label with no behaviour attached, add the file to ALLOWLIST \ + with that justification." + ); + + let retired: Vec<&String> = allowed.difference(&found).collect(); + assert!( + retired.is_empty(), + "ALLOWLIST names files that no longer reference a composition profile: \ + {retired:?}\n\ + The ratchet may only shrink: delete these entries in the same PR that \ + retired them, so the allowlist keeps meaning what it says." + ); +} + +#[test] +fn deployment_rs_is_the_target_state_entry() { + // The definition of done is `{deployment.rs}`. Pin that the target entry is + // present and documented as terminal, so a future cleanup does not + // accidentally drive the allowlist to empty and delete the one place a + // profile is *supposed* to become data. + let target = ALLOWLIST + .iter() + .find(|(path, _)| *path == "deployment.rs") + .expect("deployment.rs must stay on the allowlist as the target state"); + assert!( + target.1.contains("TARGET STATE"), + "deployment.rs's allowlist reason must mark it terminal, got: {}", + target.1 + ); +} + +#[test] +fn allowlist_is_sorted_and_unique() { + let paths: Vec<&str> = ALLOWLIST.iter().map(|(path, _)| *path).collect(); + let mut sorted = paths.clone(); + sorted.sort_unstable(); + assert_eq!( + paths, sorted, + "ALLOWLIST must stay sorted for reviewability" + ); + let unique: BTreeSet<&str> = paths.iter().copied().collect(); + assert_eq!(unique.len(), paths.len(), "ALLOWLIST has duplicate entries"); +} + +#[test] +fn scanner_strips_comments_and_strings() { + // Self-test (§10: every check ships with its own self-test). Without + // stripping, this ratchet's own doc comment would put it on the list. + let source = r#" + // RebornCompositionProfile::LocalDev in a line comment + /* RebornCompositionProfile::Production in a block comment */ + let label = "RebornCompositionProfile::Disabled"; + "#; + let stripped = strip_comments_and_strings(source); + assert!( + !stripped.contains("RebornCompositionProfile::"), + "stripped source still contains a variant path: {stripped}" + ); + + let real = "match profile { RebornCompositionProfile::LocalDev => 1, _ => 0 }"; + assert!( + strip_comments_and_strings(real).contains("RebornCompositionProfile::"), + "real branching must survive stripping" + ); +} diff --git a/crates/ironclaw_reborn_composition/src/deployment.rs b/crates/ironclaw_reborn_composition/src/deployment.rs index c11fc388a84..1c4c3a69a78 100644 --- a/crates/ironclaw_reborn_composition/src/deployment.rs +++ b/crates/ironclaw_reborn_composition/src/deployment.rs @@ -18,18 +18,112 @@ //! `RebornStorageInput`. This value carries only the policy request. use ironclaw_host_api::runtime_policy::{DeploymentMode, RuntimeProfile}; +use ironclaw_reborn_event_store::RebornProfile; use ironclaw_runtime_policy::{ EffectiveRuntimePolicy, OrgPolicyConstraints, ResolveError, ResolveRequest, }; +use crate::RebornCompositionProfile; +use crate::readiness::{RebornReadinessDiagnostic, RebornReadinessState}; + +/// Which runtime substrate a deployment assembles. +/// +/// Replaces the `requires_production_shape` / `uses_local_runtime_substrate` +/// profile predicates as the value `build_reborn_services` and +/// `build_reborn_runtime` dispatch on: a deployment selects a substrate, it +/// does not *have a mode that implies one*. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeSubstrate { + /// No runtime is assembled — the facades report disabled. + None, + /// The local runtime substrate (in-memory / on-disk / libSQL volume). + Local, + /// The production-shaped substrate (libSQL or PostgreSQL store graph). + ProductionShaped, +} + +/// Which storage handle shape a deployment is assembled from. +/// +/// Replaces the `uses_local_dev_storage_input` predicate *and* the +/// `profile == HostedSingleTenant` pairing checks that guarded +/// `RebornStorageInput` variants: the question "does this deployment take a +/// filesystem root, a hosted single-tenant pool, or an operator-supplied +/// durable store" is an axis, not a mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageShape { + /// No storage is assembled. + None, + /// A local filesystem root (`RebornStorageInput::LocalDev`). + LocalDevRoot, + /// A hosted single-tenant PostgreSQL pool plus a workspace root. + HostedSingleTenantPool, + /// An operator-supplied durable store (libSQL or PostgreSQL). + OperatorSupplied, +} + +/// Whether, and under what precondition, a deployment may carry live traffic. +/// +/// Replaces the `starts_live_runtime` predicate plus every per-profile arm of +/// `enforce_runtime_cutover_gate`. The two gate conditions that used to be +/// spelled out per profile — which readiness state is required, and whether a +/// production-blocking diagnostic vetoes the start — are parameters here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrafficPolicy { + /// Reborn is switched off; starting a runtime is an error. + Disabled, + /// Validates the assembled wiring but must never start live traffic. + ValidateOnly, + /// Serves live traffic once readiness reaches `required_readiness`. + Serve { + required_readiness: RebornReadinessState, + /// When set, a readiness diagnostic with `blocks_production` also + /// vetoes the start. Production-only today. + veto_on_production_blocking_diagnostic: bool, + }, +} + +impl TrafficPolicy { + pub(crate) fn starts_live_runtime(self) -> bool { + matches!(self, Self::Serve { .. }) + } + + /// The operator-facing reason this deployment refuses live traffic, or + /// `None` when it serves. + /// + /// Shared by the pre-build check in `build_reborn_runtime` and the + /// post-build cutover gate so the two cannot drift on wording or on which + /// deployments are allowed to start. + pub(crate) fn live_traffic_refusal(self, profile: RebornCompositionProfile) -> Option { + match self { + Self::Disabled => Some(format!( + "profile={profile} must not start live Reborn runtime traffic" + )), + Self::ValidateOnly => Some(format!( + "profile={profile} validates production-shaped wiring but must not start live Reborn runtime traffic" + )), + Self::Serve { .. } => None, + } + } +} + +/// The readiness contract a deployment reports, as data. +/// +/// §4.4 Bucket 1: `readiness_contract_for_profile` used to `match` a +/// composition profile to build this pair. Each deployment constructor now +/// carries its own contract and the match is gone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadinessContract { + pub state: RebornReadinessState, + pub diagnostics: Vec, +} + /// The runtime-policy request one deployment target makes, expressed as data. /// -/// Consumed at the composition edge (`local_runtime_profile`): a -/// `RebornCompositionProfile` maps to one of the named constructors below and -/// everything downstream consumes the resolved policy values — no code past -/// this edge branches on a deployment mode. +/// Absent for deployments that assemble no local runtime policy: the disabled +/// profile and the production-shaped profiles, which carry an operator-supplied +/// policy on `RebornBuildInput` instead. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct DeploymentConfig { +pub(crate) struct RuntimePolicyRequest { /// Where IronClaw is running and who owns the machine boundary. pub(crate) deployment: DeploymentMode, /// The operator-requested runtime preset for this deployment. @@ -40,26 +134,128 @@ pub(crate) struct DeploymentConfig { pub(crate) org_policy: OrgPolicyConstraints, } +/// One deployment target, expressed entirely as data. +/// +/// This is the §5.6 "modes are data" value: every axis that used to be read by +/// `match`ing a [`RebornCompositionProfile`] — which substrate to assemble, +/// whether live traffic is allowed, what readiness reports, which event-store +/// profile and storage shape to use, and the runtime-policy request — is a +/// field here, set by one of the named constructors below. The whole +/// local/hosted/production diff is readable on this page. +/// +/// Two deliberate boundaries are preserved: +/// +/// - The sanctioned resolver in `ironclaw_runtime_policy` stays the **only** +/// producer of [`EffectiveRuntimePolicy`]; [`DeploymentConfig::resolve`] is +/// a thin adapter over [`ResolveRequest`], not a second policy engine. +/// - Storage roots, workspace paths, and connection pools are runtime +/// *handles*, not deployment policy — they continue to ride +/// `RebornStorageInput`. This value carries only the policy request and the +/// shape selections. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeploymentConfig { + /// The profile name this config was built from. A **label** — carried for + /// logging, telemetry, and the readiness diagnostics the operator reads. + /// Nothing branches on it; that is what the other fields are for, and the + /// `reborn_deployment_mode_branching_ratchet` architecture test holds the + /// line. + profile: RebornCompositionProfile, + policy_request: Option, + substrate: RuntimeSubstrate, + traffic: TrafficPolicy, + readiness: ReadinessContract, + event_store_profile: RebornProfile, + /// Whether this deployment reads hosted extension installation state. + hosted_extension_installation_state: bool, + storage_shape: StorageShape, +} + impl DeploymentConfig { + /// Reborn switched off: no substrate, no traffic, disabled readiness. + pub fn disabled() -> Self { + Self { + profile: RebornCompositionProfile::Disabled, + policy_request: None, + substrate: RuntimeSubstrate::None, + traffic: TrafficPolicy::Disabled, + readiness: ReadinessContract { + state: RebornReadinessState::Disabled, + diagnostics: vec![RebornReadinessDiagnostic::disabled()], + }, + event_store_profile: RebornProfile::LocalDev, + hosted_extension_installation_state: false, + storage_shape: StorageShape::None, + } + } + /// Standalone local development on a single-user machine. - pub(crate) fn local_dev() -> Self { + pub fn local_dev() -> Self { Self { - deployment: DeploymentMode::LocalSingleUser, - requested_profile: RuntimeProfile::LocalDev, - yolo_disclosure_acknowledged: false, - org_policy: OrgPolicyConstraints::default(), + profile: RebornCompositionProfile::LocalDev, + policy_request: Some(RuntimePolicyRequest { + deployment: DeploymentMode::LocalSingleUser, + requested_profile: RuntimeProfile::LocalDev, + yolo_disclosure_acknowledged: false, + org_policy: OrgPolicyConstraints::default(), + }), + substrate: RuntimeSubstrate::Local, + traffic: TrafficPolicy::Serve { + required_readiness: RebornReadinessState::DevOnly, + veto_on_production_blocking_diagnostic: false, + }, + readiness: ReadinessContract { + state: RebornReadinessState::DevOnly, + diagnostics: vec![RebornReadinessDiagnostic::local_dev()], + }, + event_store_profile: RebornProfile::LocalDev, + hosted_extension_installation_state: false, + storage_shape: StorageShape::LocalDevRoot, } } /// Trusted-laptop local development with minimal approvals. Requires the /// operator's explicit host-access confirmation; without it the resolver /// fails closed with [`ResolveError::YoloRequiresDisclosure`]. - pub(crate) fn local_dev_yolo(confirm_host_access: bool) -> Self { + pub fn local_dev_yolo(confirm_host_access: bool) -> Self { Self { - deployment: DeploymentMode::LocalSingleUser, - requested_profile: RuntimeProfile::LocalYolo, - yolo_disclosure_acknowledged: confirm_host_access, - org_policy: OrgPolicyConstraints::default(), + profile: RebornCompositionProfile::LocalDevYolo, + policy_request: Some(RuntimePolicyRequest { + deployment: DeploymentMode::LocalSingleUser, + requested_profile: RuntimeProfile::LocalYolo, + yolo_disclosure_acknowledged: confirm_host_access, + org_policy: OrgPolicyConstraints::default(), + }), + readiness: ReadinessContract { + state: RebornReadinessState::DevOnly, + diagnostics: vec![RebornReadinessDiagnostic::local_dev_yolo()], + }, + ..Self::local_dev() + } + } + + /// Hosted single-tenant product surface backed by the local runtime + /// substrate and an operator-supplied store. + pub fn hosted_single_tenant() -> Self { + Self { + profile: RebornCompositionProfile::HostedSingleTenant, + policy_request: Some(RuntimePolicyRequest { + deployment: DeploymentMode::LocalSingleUser, + requested_profile: RuntimeProfile::LocalDev, + yolo_disclosure_acknowledged: false, + org_policy: OrgPolicyConstraints::default(), + }), + substrate: RuntimeSubstrate::Local, + traffic: TrafficPolicy::Serve { + required_readiness: RebornReadinessState::HostedSingleTenantValidated, + veto_on_production_blocking_diagnostic: false, + }, + readiness: ReadinessContract { + state: RebornReadinessState::HostedSingleTenantValidated, + diagnostics: vec![RebornReadinessDiagnostic::hosted_single_tenant()], + }, + event_store_profile: RebornProfile::LocalDev, + hosted_extension_installation_state: true, + storage_shape: StorageShape::HostedSingleTenantPool, } } @@ -67,23 +263,137 @@ impl DeploymentConfig { /// process execution disabled, scoped virtual filesystem, brokered /// network/secrets, ask-always approvals (the resolver-owned secure /// default under a hosted deployment boundary). - pub(crate) fn hosted_single_tenant_volume() -> Self { + pub fn hosted_single_tenant_volume() -> Self { + Self { + profile: RebornCompositionProfile::HostedSingleTenantVolume, + policy_request: Some(RuntimePolicyRequest { + deployment: DeploymentMode::HostedMultiTenant, + requested_profile: RuntimeProfile::SecureDefault, + yolo_disclosure_acknowledged: false, + org_policy: OrgPolicyConstraints::default(), + }), + traffic: TrafficPolicy::Serve { + required_readiness: RebornReadinessState::HostedSingleTenantVolumePreviewValidated, + veto_on_production_blocking_diagnostic: false, + }, + readiness: ReadinessContract { + state: RebornReadinessState::HostedSingleTenantVolumePreviewValidated, + diagnostics: vec![RebornReadinessDiagnostic::hosted_single_tenant_volume()], + }, + hosted_extension_installation_state: true, + storage_shape: StorageShape::LocalDevRoot, + ..Self::hosted_single_tenant() + } + } + + /// Production: the production-shaped substrate, serving live traffic only + /// once readiness validates. + pub fn production() -> Self { + Self { + profile: RebornCompositionProfile::Production, + policy_request: None, + substrate: RuntimeSubstrate::ProductionShaped, + traffic: TrafficPolicy::Serve { + required_readiness: RebornReadinessState::ProductionValidated, + veto_on_production_blocking_diagnostic: true, + }, + readiness: ReadinessContract { + state: RebornReadinessState::ProductionValidated, + diagnostics: Vec::new(), + }, + event_store_profile: RebornProfile::Production, + hosted_extension_installation_state: false, + storage_shape: StorageShape::OperatorSupplied, + } + } + + /// Migration dry run: assembles production-shaped wiring to validate it, + /// and must never start live traffic. + pub fn migration_dry_run() -> Self { Self { - deployment: DeploymentMode::HostedMultiTenant, - requested_profile: RuntimeProfile::SecureDefault, - yolo_disclosure_acknowledged: false, - org_policy: OrgPolicyConstraints::default(), + profile: RebornCompositionProfile::MigrationDryRun, + traffic: TrafficPolicy::ValidateOnly, + readiness: ReadinessContract { + state: RebornReadinessState::MigrationDryRunValidated, + diagnostics: Vec::new(), + }, + ..Self::production() } } - /// Resolve this deployment request through the sanctioned resolver. - pub(crate) fn resolve(&self) -> Result { + /// Map a composition profile to its deployment config. + /// + /// This is the **one** place a profile name becomes deployment data + /// (§4.4). `confirm_host_access` only affects the yolo policy request; + /// every other axis is profile-determined, so this mapping is infallible + /// and the profile predicates can delegate to it. + pub fn for_profile(profile: RebornCompositionProfile, confirm_host_access: bool) -> Self { + match profile { + RebornCompositionProfile::Disabled => Self::disabled(), + RebornCompositionProfile::LocalDev => Self::local_dev(), + RebornCompositionProfile::LocalDevYolo => Self::local_dev_yolo(confirm_host_access), + RebornCompositionProfile::HostedSingleTenant => Self::hosted_single_tenant(), + RebornCompositionProfile::HostedSingleTenantVolume => { + Self::hosted_single_tenant_volume() + } + RebornCompositionProfile::Production => Self::production(), + RebornCompositionProfile::MigrationDryRun => Self::migration_dry_run(), + } + } + + /// The profile label this config was built from. Logging and telemetry + /// only — never a branch (see the field doc). + pub fn profile(&self) -> RebornCompositionProfile { + self.profile + } + + pub fn substrate(&self) -> RuntimeSubstrate { + self.substrate + } + + pub fn traffic(&self) -> TrafficPolicy { + self.traffic + } + + pub fn readiness(&self) -> &ReadinessContract { + &self.readiness + } + + pub(crate) fn event_store_profile(&self) -> RebornProfile { + self.event_store_profile + } + + pub(crate) fn uses_hosted_extension_installation_state(&self) -> bool { + self.hosted_extension_installation_state + } + + pub fn storage_shape(&self) -> StorageShape { + self.storage_shape + } + + pub(crate) fn uses_local_dev_storage_input(&self) -> bool { + self.storage_shape == StorageShape::LocalDevRoot + } + + /// Resolve this deployment's runtime-policy request through the sanctioned + /// resolver. + /// + /// `Ok(None)` for deployments that make no policy request — disabled and + /// the production-shaped profiles, which carry an operator-supplied policy + /// on `RebornBuildInput` instead. Distinguishing "no request" from "a + /// request that failed" keeps the fail-closed resolver error visible + /// rather than collapsing both into an absent policy. + pub(crate) fn resolve(&self) -> Result, ResolveError> { + let Some(request) = self.policy_request.as_ref() else { + return Ok(None); + }; ironclaw_runtime_policy::resolve(ResolveRequest { - deployment: self.deployment, - requested_profile: self.requested_profile, - org_policy: self.org_policy.clone(), - yolo_disclosure_acknowledged: self.yolo_disclosure_acknowledged, + deployment: request.deployment, + requested_profile: request.requested_profile, + org_policy: request.org_policy.clone(), + yolo_disclosure_acknowledged: request.yolo_disclosure_acknowledged, }) + .map(Some) } /// The deployment's capability-policy *data* (§4.4.1 category 1): the @@ -112,9 +422,218 @@ mod tests { use super::*; + /// Resolve a config that is known to make a policy request. + fn resolved(config: DeploymentConfig) -> EffectiveRuntimePolicy { + config + .resolve() + .expect("resolves") + .expect("config makes a policy request") + } + + #[test] + fn every_composition_profile_maps_to_a_deployment_config() { + // The §4.4 pivot: `for_profile` is the one profile match, and it must + // cover every variant so nothing downstream needs its own. + for profile in [ + RebornCompositionProfile::Disabled, + RebornCompositionProfile::LocalDev, + RebornCompositionProfile::LocalDevYolo, + RebornCompositionProfile::HostedSingleTenant, + RebornCompositionProfile::HostedSingleTenantVolume, + RebornCompositionProfile::Production, + RebornCompositionProfile::MigrationDryRun, + ] { + let config = DeploymentConfig::for_profile(profile, true); + assert_eq!( + config.profile(), + profile, + "for_profile must round-trip the label it was built from" + ); + } + } + + #[test] + fn substrate_and_traffic_axes_replace_the_profile_predicates() { + // Locks the axis values the five former `match profile` sites read, + // and pins the predicates on the profile enum as thin delegations — + // they must agree with the config by construction. + let cases = [ + ( + RebornCompositionProfile::Disabled, + RuntimeSubstrate::None, + false, + ), + ( + RebornCompositionProfile::LocalDev, + RuntimeSubstrate::Local, + true, + ), + ( + RebornCompositionProfile::LocalDevYolo, + RuntimeSubstrate::Local, + true, + ), + ( + RebornCompositionProfile::HostedSingleTenant, + RuntimeSubstrate::Local, + true, + ), + ( + RebornCompositionProfile::HostedSingleTenantVolume, + RuntimeSubstrate::Local, + true, + ), + ( + RebornCompositionProfile::Production, + RuntimeSubstrate::ProductionShaped, + true, + ), + ( + RebornCompositionProfile::MigrationDryRun, + RuntimeSubstrate::ProductionShaped, + false, + ), + ]; + for (profile, substrate, starts_live) in cases { + let config = DeploymentConfig::for_profile(profile, true); + assert_eq!(config.substrate(), substrate, "substrate for {profile}"); + assert_eq!( + config.traffic().starts_live_runtime(), + starts_live, + "starts_live_runtime for {profile}" + ); + assert_eq!(profile.starts_live_runtime(), starts_live); + assert_eq!( + profile.uses_local_dev_storage_input(), + config.uses_local_dev_storage_input() + ); + assert_eq!( + profile.uses_hosted_extension_installation_state(), + config.uses_hosted_extension_installation_state() + ); + assert_eq!( + profile.to_event_store_profile(), + config.event_store_profile() + ); + assert_eq!( + profile.requires_production_shape(), + substrate == RuntimeSubstrate::ProductionShaped + ); + assert_eq!( + profile.uses_local_runtime_substrate(), + substrate == RuntimeSubstrate::Local + ); + } + } + + #[test] + fn a_serving_deployment_requires_its_own_readiness_state() { + // The cutover gate compares reported readiness against + // `TrafficPolicy::Serve::required_readiness`. If a constructor ever set + // the two independently, the deployment could never start — so the + // invariant is pinned here rather than discovered at boot. + for profile in [ + RebornCompositionProfile::LocalDev, + RebornCompositionProfile::LocalDevYolo, + RebornCompositionProfile::HostedSingleTenant, + RebornCompositionProfile::HostedSingleTenantVolume, + RebornCompositionProfile::Production, + ] { + let config = DeploymentConfig::for_profile(profile, true); + let TrafficPolicy::Serve { + required_readiness, .. + } = config.traffic() + else { + panic!("{profile} must serve live traffic"); + }; + assert_eq!( + required_readiness, + config.readiness().state, + "{profile} must require the readiness state it reports" + ); + } + } + + #[test] + fn only_production_vetoes_on_a_production_blocking_diagnostic() { + let production = DeploymentConfig::production(); + assert_eq!( + production.traffic(), + TrafficPolicy::Serve { + required_readiness: RebornReadinessState::ProductionValidated, + veto_on_production_blocking_diagnostic: true, + } + ); + for profile in [ + RebornCompositionProfile::LocalDev, + RebornCompositionProfile::LocalDevYolo, + RebornCompositionProfile::HostedSingleTenant, + RebornCompositionProfile::HostedSingleTenantVolume, + ] { + let config = DeploymentConfig::for_profile(profile, true); + assert!( + matches!( + config.traffic(), + TrafficPolicy::Serve { + veto_on_production_blocking_diagnostic: false, + .. + } + ), + "{profile} must not inherit the production diagnostic veto" + ); + } + assert_eq!( + DeploymentConfig::migration_dry_run().traffic(), + TrafficPolicy::ValidateOnly + ); + assert_eq!( + DeploymentConfig::disabled().traffic(), + TrafficPolicy::Disabled + ); + } + + #[test] + fn deployments_without_a_policy_request_resolve_to_none() { + // Disabled and the production-shaped profiles carry an + // operator-supplied policy on the build input instead. `Ok(None)` must + // stay distinguishable from a resolver failure. + for profile in [ + RebornCompositionProfile::Disabled, + RebornCompositionProfile::Production, + RebornCompositionProfile::MigrationDryRun, + ] { + let resolved = DeploymentConfig::for_profile(profile, false) + .resolve() + .expect("no request cannot fail resolution"); + assert!(resolved.is_none(), "{profile} makes no policy request"); + } + } + + #[test] + fn readiness_contract_travels_on_the_config() { + let disabled = DeploymentConfig::disabled(); + assert_eq!(disabled.readiness().state, RebornReadinessState::Disabled); + assert_eq!(disabled.readiness().diagnostics.len(), 1); + + assert_eq!( + DeploymentConfig::production().readiness().state, + RebornReadinessState::ProductionValidated + ); + assert!( + DeploymentConfig::production() + .readiness() + .diagnostics + .is_empty() + ); + assert_eq!( + DeploymentConfig::migration_dry_run().readiness().state, + RebornReadinessState::MigrationDryRunValidated + ); + } + #[test] fn local_dev_resolves_to_local_host_policy() { - let policy = DeploymentConfig::local_dev().resolve().expect("resolves"); + let policy = resolved(DeploymentConfig::local_dev()); assert_eq!(policy.deployment, DeploymentMode::LocalSingleUser); assert_eq!(policy.resolved_profile, RuntimeProfile::LocalDev); assert_eq!(policy.process_backend, ProcessBackendKind::LocalHost); @@ -131,18 +650,14 @@ mod tests { #[test] fn local_dev_yolo_with_disclosure_resolves_minimal_approvals() { - let policy = DeploymentConfig::local_dev_yolo(true) - .resolve() - .expect("resolves"); + let policy = resolved(DeploymentConfig::local_dev_yolo(true)); assert_eq!(policy.resolved_profile, RuntimeProfile::LocalYolo); assert_eq!(policy.approval_policy, ApprovalPolicy::Minimal); } #[test] fn hosted_single_tenant_volume_resolves_secure_default_without_processes() { - let policy = DeploymentConfig::hosted_single_tenant_volume() - .resolve() - .expect("resolves"); + let policy = resolved(DeploymentConfig::hosted_single_tenant_volume()); assert_eq!(policy.deployment, DeploymentMode::HostedMultiTenant); assert_eq!(policy.resolved_profile, RuntimeProfile::SecureDefault); assert_eq!(policy.process_backend, ProcessBackendKind::None); diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 3661d874543..13f9258f9cc 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -1319,13 +1319,18 @@ pub async fn build_reborn_services( owner_id = %input.owner_id, "building Reborn composition facades" ); - match input.profile { - RebornCompositionProfile::Disabled => Ok(RebornServices::disabled()), - RebornCompositionProfile::LocalDev - | RebornCompositionProfile::LocalDevYolo - | RebornCompositionProfile::HostedSingleTenant - | RebornCompositionProfile::HostedSingleTenantVolume => build_local_runtime(input).await, - RebornCompositionProfile::Production | RebornCompositionProfile::MigrationDryRun => { + // Substrate selection is deployment *data* (§4.4/§5.6), not a profile + // match: the config says which substrate to assemble and this dispatches + // on that value. + let substrate = crate::deployment::DeploymentConfig::for_profile( + input.profile, + input.grants_trusted_laptop_access(), + ) + .substrate(); + match substrate { + crate::deployment::RuntimeSubstrate::None => Ok(RebornServices::disabled()), + crate::deployment::RuntimeSubstrate::Local => build_local_runtime(input).await, + crate::deployment::RuntimeSubstrate::ProductionShaped => { build_production_shaped(input).await } } @@ -1476,7 +1481,8 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result + if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + == crate::deployment::StorageShape::HostedSingleTenantPool => { return Err(RebornBuildError::InvalidConfig { reason: "profile=hosted-single-tenant requires hosted single-tenant Postgres storage input" @@ -1497,7 +1503,8 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result + if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + != crate::deployment::StorageShape::HostedSingleTenantPool => { return Err(RebornBuildError::InvalidConfig { reason: format!("{profile} profile requires local-runtime storage input"), diff --git a/crates/ironclaw_reborn_composition/src/input.rs b/crates/ironclaw_reborn_composition/src/input.rs index b5127f7495f..7dafd8e3bd4 100644 --- a/crates/ironclaw_reborn_composition/src/input.rs +++ b/crates/ironclaw_reborn_composition/src/input.rs @@ -319,7 +319,13 @@ impl RebornBuildInput { pool: deadpool_postgres::Pool, secret_master_key: ironclaw_secrets::SecretMaterial, ) -> Result { - if profile != RebornCompositionProfile::HostedSingleTenant { + // The storage handle and the deployment must agree. Expressed as the + // config's storage-shape axis rather than a profile-name comparison + // (§4.4): a deployment that takes a hosted single-tenant pool is a + // property of the deployment, not of its name. + if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + != crate::deployment::StorageShape::HostedSingleTenantPool + { return Err(RebornBuildError::InvalidConfig { reason: format!( "hosted single-tenant Postgres storage requires profile=hosted-single-tenant; got profile={profile}" @@ -347,7 +353,13 @@ impl RebornBuildInput { root: PathBuf, config_file: Option<&ironclaw_reborn_config::RebornConfigFile>, ) -> Result { - if profile != RebornCompositionProfile::HostedSingleTenant { + // The storage handle and the deployment must agree. Expressed as the + // config's storage-shape axis rather than a profile-name comparison + // (§4.4): a deployment that takes a hosted single-tenant pool is a + // property of the deployment, not of its name. + if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + != crate::deployment::StorageShape::HostedSingleTenantPool + { return Err(RebornBuildError::InvalidConfig { reason: format!( "hosted single-tenant Postgres storage requires profile=hosted-single-tenant; got profile={profile}" diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 0fe0ed7388e..e0569a50195 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -30,7 +30,7 @@ mod approval_test_support; mod automation; mod blocked_auth_resume; mod builtin_capability_policy; -mod deployment; +pub mod deployment; mod error; mod extension_host; mod factory; diff --git a/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs b/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs index 9ef518d1ed3..b5ee5fc3037 100644 --- a/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs +++ b/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs @@ -16,6 +16,8 @@ pub enum RebornRuntimeProfileError { MissingLibsqlFeature, #[error("failed to resolve local runtime policy: {0}")] Policy(#[from] ResolveError), + #[error("profile={profile} carries no runtime-policy request to resolve")] + MissingPolicyRequest { profile: RebornCompositionProfile }, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -30,21 +32,15 @@ pub(crate) fn deployment_config_for_profile( profile: RebornCompositionProfile, options: RebornRuntimeProfileOptions, ) -> Result { - match profile { - RebornCompositionProfile::LocalDev => Ok(DeploymentConfig::local_dev()), - RebornCompositionProfile::LocalDevYolo => Ok(DeploymentConfig::local_dev_yolo( - options.confirm_host_access, - )), - RebornCompositionProfile::HostedSingleTenantVolume => { - Ok(DeploymentConfig::hosted_single_tenant_volume()) - } - RebornCompositionProfile::Disabled - | RebornCompositionProfile::HostedSingleTenant - | RebornCompositionProfile::Production - | RebornCompositionProfile::MigrationDryRun => { - Err(RebornRuntimeProfileError::UnsupportedProfile { profile }) - } + let config = DeploymentConfig::for_profile(profile, options.confirm_host_access); + // This module builds the *local-dev storage input* shape (a filesystem + // root). Deployments that take an operator-supplied pool or assemble no + // runtime are not its business — expressed as the config axis rather than + // a second list of profile names. + if !config.uses_local_dev_storage_input() { + return Err(RebornRuntimeProfileError::UnsupportedProfile { profile }); } + Ok(config) } /// Build the local runtime substrate input and its matching runtime policy from @@ -121,7 +117,17 @@ pub fn hosted_single_tenant_runtime_policy() -> Result Result { - DeploymentConfig::hosted_single_tenant_volume().resolve() + // The hosted volume preview always carries a policy request, so the + // `None` arm is unreachable in practice; it maps to the resolver's own + // fail-closed shape rather than being unwrapped. + DeploymentConfig::hosted_single_tenant_volume() + .resolve() + .and_then(|policy| { + policy.ok_or(ResolveError::IncompatibleDeployment { + deployment: ironclaw_host_api::runtime_policy::DeploymentMode::HostedMultiTenant, + profile: ironclaw_host_api::runtime_policy::RuntimeProfile::SecureDefault, + }) + }) } /// Resolved policy for trusted single-user local development with inherited @@ -143,6 +149,9 @@ pub fn local_dev_yolo_runtime_policy( RebornRuntimeProfileError::UnsupportedProfile { .. } => { unreachable!("local-dev-yolo is a local runtime profile") } + RebornRuntimeProfileError::MissingPolicyRequest { .. } => { + unreachable!("local-dev-yolo carries a runtime-policy request") + } }) } @@ -150,7 +159,9 @@ fn local_runtime_policy( profile: RebornCompositionProfile, options: RebornRuntimeProfileOptions, ) -> Result { - Ok(deployment_config_for_profile(profile, options)?.resolve()?) + deployment_config_for_profile(profile, options)? + .resolve()? + .ok_or(RebornRuntimeProfileError::MissingPolicyRequest { profile }) } fn local_runtime_policy_for_local_dev_shape( @@ -168,5 +179,8 @@ fn local_runtime_policy_for_local_dev_shape( RebornRuntimeProfileError::UnsupportedProfile { .. } => { unreachable!("{profile_name} uses the local-dev runtime policy shape") } + RebornRuntimeProfileError::MissingPolicyRequest { .. } => { + unreachable!("{profile_name} carries a runtime-policy request") + } }) } diff --git a/crates/ironclaw_reborn_composition/src/readiness.rs b/crates/ironclaw_reborn_composition/src/readiness.rs index 10eb6fd88ff..818e7af467b 100644 --- a/crates/ironclaw_reborn_composition/src/readiness.rs +++ b/crates/ironclaw_reborn_composition/src/readiness.rs @@ -263,37 +263,17 @@ pub struct RebornReadinessDiagnostic { pub blocks_production: bool, } +/// The readiness contract a profile reports. +/// +/// §4.4 Bucket 1: this used to `match` the composition profile to build the +/// pair. The contract is now data each `DeploymentConfig` constructor carries, +/// so this is a field read. pub(crate) fn readiness_contract_for_profile( profile: RebornCompositionProfile, ) -> (RebornReadinessState, Vec) { - match profile { - RebornCompositionProfile::Disabled => ( - RebornReadinessState::Disabled, - vec![RebornReadinessDiagnostic::disabled()], - ), - RebornCompositionProfile::LocalDev => ( - RebornReadinessState::DevOnly, - vec![RebornReadinessDiagnostic::local_dev()], - ), - RebornCompositionProfile::LocalDevYolo => ( - RebornReadinessState::DevOnly, - vec![RebornReadinessDiagnostic::local_dev_yolo()], - ), - RebornCompositionProfile::HostedSingleTenant => ( - RebornReadinessState::HostedSingleTenantValidated, - vec![RebornReadinessDiagnostic::hosted_single_tenant()], - ), - RebornCompositionProfile::HostedSingleTenantVolume => ( - RebornReadinessState::HostedSingleTenantVolumePreviewValidated, - vec![RebornReadinessDiagnostic::hosted_single_tenant_volume()], - ), - RebornCompositionProfile::Production => { - (RebornReadinessState::ProductionValidated, Vec::new()) - } - RebornCompositionProfile::MigrationDryRun => { - (RebornReadinessState::MigrationDryRunValidated, Vec::new()) - } - } + let config = crate::deployment::DeploymentConfig::for_profile(profile, false); + let contract = config.readiness(); + (contract.state, contract.diagnostics.clone()) } impl RebornReadinessDiagnostic { diff --git a/crates/ironclaw_reborn_composition/src/root/profile.rs b/crates/ironclaw_reborn_composition/src/root/profile.rs index e966ab19025..6f805c0dda1 100644 --- a/crates/ironclaw_reborn_composition/src/root/profile.rs +++ b/crates/ironclaw_reborn_composition/src/root/profile.rs @@ -33,58 +33,39 @@ impl RebornCompositionProfile { self != Self::Disabled } + /// The deployment data this profile name selects. + /// + /// Every predicate below reads this rather than `match`ing on `self`: + /// `DeploymentConfig::for_profile` is the one profile match in the crate + /// (§4.4). The `confirm_host_access` argument only affects the yolo + /// *policy request*, which none of these predicates read, so passing + /// `false` here cannot change any answer. + fn deployment(self) -> crate::deployment::DeploymentConfig { + crate::deployment::DeploymentConfig::for_profile(self, false) + } + pub fn requires_production_shape(self) -> bool { - matches!(self, Self::Production | Self::MigrationDryRun) + self.deployment().substrate() == crate::deployment::RuntimeSubstrate::ProductionShaped } pub fn uses_local_runtime_substrate(self) -> bool { - matches!( - self, - Self::LocalDev - | Self::LocalDevYolo - | Self::HostedSingleTenant - | Self::HostedSingleTenantVolume - ) + self.deployment().substrate() == crate::deployment::RuntimeSubstrate::Local } pub fn uses_local_dev_storage_input(self) -> bool { - matches!( - self, - Self::LocalDev | Self::LocalDevYolo | Self::HostedSingleTenantVolume - ) + self.deployment().uses_local_dev_storage_input() } pub fn starts_live_runtime(self) -> bool { - matches!( - self, - Self::LocalDev - | Self::LocalDevYolo - | Self::HostedSingleTenant - | Self::HostedSingleTenantVolume - | Self::Production - ) + self.deployment().traffic().starts_live_runtime() } pub fn uses_hosted_extension_installation_state(self) -> bool { - matches!( - self, - Self::HostedSingleTenant | Self::HostedSingleTenantVolume - ) + self.deployment().uses_hosted_extension_installation_state() } pub fn to_event_store_profile(self) -> ironclaw_reborn_event_store::RebornProfile { - match self { - Self::Disabled - | Self::LocalDev - | Self::LocalDevYolo - | Self::HostedSingleTenant - | Self::HostedSingleTenantVolume => { - ironclaw_reborn_event_store::RebornProfile::LocalDev - } - Self::Production | Self::MigrationDryRun => { - ironclaw_reborn_event_store::RebornProfile::Production - } - } + self.deployment().event_store_profile() } } diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index e533ed5b9ef..dd744c1a63d 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -107,6 +107,7 @@ use ironclaw_turns::run_profile::UserProfileContext; use self::latency::{trace_runtime_latency_error, trace_runtime_latency_ok}; use self::runtime_turn_scheduler::RuntimeTurnScheduler; use crate::builtin_capability_policy::{BuiltinCapabilityPolicy, builtin_capability_policy}; +use crate::deployment::{DeploymentConfig, RuntimeSubstrate, TrafficPolicy}; use crate::factory::{ComposedTurnStateStore, builtin_extension_registry}; #[cfg(any(test, feature = "test-support"))] use crate::outbound::outbound_preferences::OutboundDeliveryTargetEntry; @@ -149,7 +150,7 @@ use crate::runtime_input::{ }; use crate::{ RebornBuildError, RebornCompositionProfile, RebornProductAuthServices, RebornReadiness, - RebornReadinessState, RebornServices, build_reborn_services, + RebornServices, build_reborn_services, }; use production::{ EmptyCapabilitySurfaceResolver, EmptyIdentityContextSource, @@ -404,66 +405,50 @@ where } } +/// Gate live-traffic startup on the deployment's [`TrafficPolicy`]. +/// +/// §4.4: this used to be a seven-arm `match` on the composition profile, with +/// each arm spelling out its own readiness precondition. The precondition is +/// now data on the config — a required readiness state plus an optional +/// production-blocking-diagnostic veto — so this reads one value. The profile +/// still appears in the error text, as a label for the operator. fn enforce_runtime_cutover_gate( - profile: RebornCompositionProfile, + deployment: &DeploymentConfig, readiness: &RebornReadiness, ) -> Result<(), RebornRuntimeError> { - match profile { - RebornCompositionProfile::Production => { - if readiness.state != RebornReadinessState::ProductionValidated { - return Err(RebornRuntimeError::InvalidArgument { - reason: format!( - "profile=production cannot start Reborn runtime before production readiness is validated; state={:?}", - readiness.state - ), - }); - } - if let Some(diagnostic) = readiness + let profile = deployment.profile(); + let traffic = deployment.traffic(); + if let Some(reason) = traffic.live_traffic_refusal(profile) { + return Err(RebornRuntimeError::InvalidArgument { reason }); + } + if let TrafficPolicy::Serve { + required_readiness, + veto_on_production_blocking_diagnostic, + } = traffic + { + if readiness.state != required_readiness { + return Err(RebornRuntimeError::InvalidArgument { + reason: format!( + "profile={profile} cannot start Reborn runtime before readiness is validated; required_state={required_readiness:?}, state={:?}", + readiness.state + ), + }); + } + if veto_on_production_blocking_diagnostic + && let Some(diagnostic) = readiness .diagnostics .iter() .find(|diagnostic| diagnostic.blocks_production) - { - return Err(RebornRuntimeError::InvalidArgument { - reason: format!( - "profile=production cannot start Reborn runtime while readiness diagnostic blocks production: component={:?}, reason={:?}", - diagnostic.component, diagnostic.reason - ), - }); - } - Ok(()) - } - RebornCompositionProfile::MigrationDryRun => Err(RebornRuntimeError::InvalidArgument { - reason: - "profile=migration-dry-run validates production-shaped wiring but must not start live Reborn runtime traffic" - .to_string(), - }), - RebornCompositionProfile::Disabled => Err(RebornRuntimeError::InvalidArgument { - reason: "profile=disabled must not start live Reborn runtime traffic".to_string(), - }), - RebornCompositionProfile::HostedSingleTenant => { - if readiness.state != RebornReadinessState::HostedSingleTenantValidated { - return Err(RebornRuntimeError::InvalidArgument { - reason: format!( - "profile=hosted-single-tenant cannot start Reborn runtime before hosted single-tenant readiness is validated; required_state=HostedSingleTenantValidated, state={:?}", - readiness.state - ), - }); - } - Ok(()) - } - RebornCompositionProfile::HostedSingleTenantVolume => { - if readiness.state != RebornReadinessState::HostedSingleTenantVolumePreviewValidated { - return Err(RebornRuntimeError::InvalidArgument { - reason: format!( - "profile=hosted-single-tenant-volume cannot start Reborn runtime before hosted volume preview readiness is validated; required_state=HostedSingleTenantVolumePreviewValidated, state={:?}", - readiness.state - ), - }); - } - Ok(()) + { + return Err(RebornRuntimeError::InvalidArgument { + reason: format!( + "profile={profile} cannot start Reborn runtime while readiness diagnostic blocks production: component={:?}, reason={:?}", + diagnostic.component, diagnostic.reason + ), + }); } - RebornCompositionProfile::LocalDev | RebornCompositionProfile::LocalDevYolo => Ok(()), } + Ok(()) } /// Guard: production and migration-dry-run compositions always pre-mint @@ -480,10 +465,8 @@ fn check_production_scheduler_wake_wiring( wiring: &Option, ) -> Result<(), RebornRuntimeError> { if wiring.is_none() - && matches!( - profile, - RebornCompositionProfile::Production | RebornCompositionProfile::MigrationDryRun - ) + && DeploymentConfig::for_profile(profile, false).substrate() + == RuntimeSubstrate::ProductionShaped { return Err(RebornRuntimeError::InvalidArgument { reason: "production runtime missing scheduler wake wiring".to_string(), @@ -3142,22 +3125,13 @@ pub async fn build_reborn_runtime( })?; let profile = services_input.profile(); - if !profile.starts_live_runtime() { - if profile == RebornCompositionProfile::MigrationDryRun { - return Err(RebornRuntimeError::InvalidArgument { - reason: - "profile=migration-dry-run validates production-shaped wiring but must not start live Reborn runtime traffic" - .to_string(), - }); - } - if profile == RebornCompositionProfile::Disabled { - return Err(RebornRuntimeError::InvalidArgument { - reason: "profile=disabled must not start live Reborn runtime traffic".to_string(), - }); - } - return Err(RebornRuntimeError::InvalidArgument { - reason: format!("profile={profile} must not start live Reborn runtime traffic"), - }); + // The deployment this build assembles, as data (§4.4/§5.6). Every axis + // below — live-traffic admission, the cutover gate, substrate selection — + // reads a field on this value instead of re-matching the profile. + let deployment = + DeploymentConfig::for_profile(profile, services_input.grants_trusted_laptop_access()); + if let Some(reason) = deployment.traffic().live_traffic_refusal(profile) { + return Err(RebornRuntimeError::InvalidArgument { reason }); } // Capture the resolved policy before `build_reborn_services` consumes the // input. Downstream wiring selects enforcement behaviour from resolved @@ -3234,7 +3208,7 @@ pub async fn build_reborn_runtime( ) .await?; } - enforce_runtime_cutover_gate(profile, &services.readiness)?; + enforce_runtime_cutover_gate(&deployment, &services.readiness)?; // Extract the pre-minted scheduler wake wiring from the production composition path // (minted in `build_production_shaped`) so it can be handed to @@ -3253,11 +3227,8 @@ pub async fn build_reborn_runtime( #[cfg(not(any(feature = "libsql", feature = "postgres")))] let production_scheduler_wake: Option = None; - let runtime_parts = match profile { - RebornCompositionProfile::LocalDev - | RebornCompositionProfile::LocalDevYolo - | RebornCompositionProfile::HostedSingleTenant - | RebornCompositionProfile::HostedSingleTenantVolume => { + let runtime_parts = match deployment.substrate() { + RuntimeSubstrate::Local => { let local_runtime = services .local_runtime @@ -3268,7 +3239,7 @@ pub async fn build_reborn_runtime( })?; local_runtime_parts(local_runtime) } - RebornCompositionProfile::Production => { + RuntimeSubstrate::ProductionShaped => { #[cfg(any(feature = "libsql", feature = "postgres"))] { let production_runtime = services.production_runtime.as_ref().ok_or( @@ -3295,7 +3266,17 @@ pub async fn build_reborn_runtime( }); } } - _ => unreachable!("unsupported runtime profile checked above"), + // `RuntimeSubstrate::None` never reaches here: the disabled + // deployment's traffic policy refuses live traffic before the services + // are built, and again at the cutover gate above. + RuntimeSubstrate::None => { + return Err(RebornRuntimeError::InvalidArgument { + reason: format!( + "profile={} assembles no runtime substrate", + deployment.profile() + ), + }); + } }; let RuntimeStoreParts { local_runtime, diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs index 444ce11c297..6926b637f2d 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs @@ -1,3 +1,7 @@ +// arch-exempt: large_file, pre-existing ~6.9K-line composition runtime test suite; this change only repoints existing cutover-gate cases at DeploymentConfig plus one shared helper, plan #6168 +// +// Decomposition into per-concern test modules is tracked with the composition +// god-crate shrink (#6168); this file is not the place to add unrelated cases. use std::sync::{ Arc, Mutex as StdMutex, atomic::{AtomicUsize, Ordering}, @@ -263,6 +267,18 @@ fn readiness_for_runtime_gate( } } +/// Drive the cutover gate the way production does: build the deployment from +/// the profile, then gate on its `TrafficPolicy`. +fn cutover_gate( + profile: RebornCompositionProfile, + readiness: &crate::RebornReadiness, +) -> Result<(), RebornRuntimeError> { + super::enforce_runtime_cutover_gate( + &crate::deployment::DeploymentConfig::for_profile(profile, false), + readiness, + ) +} + #[test] fn runtime_cutover_gate_allows_validated_production_readiness() { let readiness = readiness_for_runtime_gate( @@ -271,7 +287,7 @@ fn runtime_cutover_gate_allows_validated_production_readiness() { Vec::new(), ); - super::enforce_runtime_cutover_gate(RebornCompositionProfile::Production, &readiness) + cutover_gate(RebornCompositionProfile::Production, &readiness) .expect("validated production runtime can start"); } @@ -290,9 +306,8 @@ fn runtime_cutover_gate_rejects_blocking_production_diagnostic() { ], ); - let error = - super::enforce_runtime_cutover_gate(RebornCompositionProfile::Production, &readiness) - .expect_err("blocking production diagnostic prevents runtime start"); + let error = cutover_gate(RebornCompositionProfile::Production, &readiness) + .expect_err("blocking production diagnostic prevents runtime start"); let RebornRuntimeError::InvalidArgument { reason } = error else { panic!("expected invalid argument, got {error:?}"); }; @@ -308,9 +323,8 @@ fn runtime_cutover_gate_rejects_migration_dry_run_runtime_start() { Vec::new(), ); - let error = - super::enforce_runtime_cutover_gate(RebornCompositionProfile::MigrationDryRun, &readiness) - .expect_err("migration-dry-run cannot start live runtime"); + let error = cutover_gate(RebornCompositionProfile::MigrationDryRun, &readiness) + .expect_err("migration-dry-run cannot start live runtime"); let RebornRuntimeError::InvalidArgument { reason } = error else { panic!("expected invalid argument, got {error:?}"); }; @@ -325,7 +339,7 @@ fn runtime_cutover_gate_allows_local_dev_readiness() { vec![crate::RebornReadinessDiagnostic::local_dev()], ); - super::enforce_runtime_cutover_gate(RebornCompositionProfile::LocalDev, &readiness) + cutover_gate(RebornCompositionProfile::LocalDev, &readiness) .expect("local-dev runtime is not production traffic"); } @@ -337,7 +351,7 @@ fn runtime_cutover_gate_allows_hosted_single_tenant_readiness() { Vec::new(), ); - super::enforce_runtime_cutover_gate(RebornCompositionProfile::HostedSingleTenant, &readiness) + cutover_gate(RebornCompositionProfile::HostedSingleTenant, &readiness) .expect("validated hosted single-tenant runtime can start"); } @@ -349,11 +363,8 @@ fn runtime_cutover_gate_rejects_local_dev_readiness_for_hosted_single_tenant() { vec![crate::RebornReadinessDiagnostic::local_dev()], ); - let error = super::enforce_runtime_cutover_gate( - RebornCompositionProfile::HostedSingleTenant, - &readiness, - ) - .expect_err("hosted single-tenant runtime requires hosted readiness"); + let error = cutover_gate(RebornCompositionProfile::HostedSingleTenant, &readiness) + .expect_err("hosted single-tenant runtime requires hosted readiness"); let RebornRuntimeError::InvalidArgument { reason } = error else { panic!("expected invalid argument, got {error:?}"); }; From c3554d15b47daa1d34ca0e57e0104379d90358e2 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 08:12:31 +0000 Subject: [PATCH 3/4] test(architecture): deployment-mode branching ratchet lexer handles char literals The inline stripper rolled its own Peekable lexer that ignored char literals: a flipped in_string and swallowed the rest of the file, silently hiding any DeploymentMode branch after it (a ratchet false negative). Rewrote it char-indexed (a char literal needs 2-char lookahead the Peekable form can't do), mirroring the shared ratchet_support stripper's char-vs-lifetime handling. Self-test extended with the -hides-a-branch regression plus lifetime preservation. Reported-by: gemini-code-assist (PR #6279 review) Co-Authored-By: Claude Fable 5 --- ...eborn_deployment_mode_branching_ratchet.rs | 104 +++++++++++++----- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs index f7f497c6218..432a2f3aea9 100644 --- a/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs @@ -103,50 +103,77 @@ fn workspace_root() -> PathBuf { /// Remove line comments, block comments, and string literals so that prose and /// fixtures inside them cannot trip the scan. +/// +/// Char literals are consumed too: a `"` (or `/`) inside a char literal such as +/// `'"'` must not open a string/comment and swallow the rest of the file, which +/// would silently hide a `DeploymentMode` branch from the scan. A char literal +/// (`'x'` / `'\n'` / `'"'`) is dropped; a lifetime (`'a`) is emitted as-is. +/// Uses char-indexed lookahead (a char literal needs to see two chars ahead), +/// mirroring the shared `ratchet_support` stripper the other §10 ratchets use. fn strip_comments_and_strings(source: &str) -> String { + let chars: Vec = source.chars().collect(); let mut out = String::with_capacity(source.len()); - let mut chars = source.chars().peekable(); - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut in_string = false; - let mut escaped = false; - while let Some(ch) = chars.next() { - if in_line_comment { - if ch == '\n' { - in_line_comment = false; - out.push('\n'); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + // Line comment — drop to (not including) the newline, which the next + // iteration preserves. + if c == '/' && chars.get(i + 1) == Some(&'/') { + i += 2; + while i < chars.len() && chars[i] != '\n' { + i += 1; } continue; } - if in_block_comment { - if ch == '*' && chars.peek() == Some(&'/') { - chars.next(); - in_block_comment = false; + // Block comment — drop through the closing `*/`. + if c == '/' && chars.get(i + 1) == Some(&'*') { + i += 2; + while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) { + i += 1; } + i += 2; continue; } - if in_string { - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - in_string = false; + // String literal — drop through the closing `"`, honoring escapes. + if c == '"' { + i += 1; + while i < chars.len() { + if chars[i] == '\\' { + i += 2; + continue; + } + if chars[i] == '"' { + i += 1; + break; + } + i += 1; } continue; } - match ch { - '/' if chars.peek() == Some(&'/') => { - chars.next(); - in_line_comment = true; + // Char literal vs lifetime — only consume when it closes as a literal. + if c == '\'' { + // Escaped char literal `'\...'`: drop through the closing quote. + if chars.get(i + 1) == Some(&'\\') { + let mut k = i + 2; + while k < chars.len() && chars[k] != '\'' { + k += 1; + } + i = k + 1; + continue; } - '/' if chars.peek() == Some(&'*') => { - chars.next(); - in_block_comment = true; + // Single-char literal `'x'` (incl. `'"'`): the quote two chars ahead + // proves it is a literal, not a lifetime — drop all three. + if chars.get(i + 2) == Some(&'\'') { + i += 3; + continue; } - '"' => in_string = true, - _ => out.push(ch), + // A lifetime (`'a`) — emit and move on. + out.push(c); + i += 1; + continue; } + out.push(c); + i += 1; } out } @@ -278,4 +305,21 @@ fn scanner_strips_comments_and_strings() { strip_comments_and_strings(real).contains("RebornCompositionProfile::"), "real branching must survive stripping" ); + + // Regression (2026-07-19 gemini review): a char literal containing `"` must + // not open a string and swallow a following branch. Before the char-literal + // handling, `'"'` flipped in_string and the DeploymentMode match after it + // was hidden from the scan — a silent ratchet false negative. + let with_char_literal = r#" + let quote = '"'; + match profile { RebornCompositionProfile::LocalDev => 1, _ => 0 } + "#; + assert!( + strip_comments_and_strings(with_char_literal).contains("RebornCompositionProfile::"), + "a `'\"'` char literal must not hide the branch after it" + ); + // The char literal itself is dropped (it is not a lifetime), and a real + // lifetime is preserved. + assert!(!strip_comments_and_strings("let c = '\"';").contains('"')); + assert!(strip_comments_and_strings("fn f<'a>(x: &'a str) {}").contains("'a")); } From 571087078c66a815c7c8b1a9783c680dc07ec560 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 15:29:10 -0700 Subject: [PATCH 4/4] refactor(composition): de-prefix local_dev builder names to the shared mechanism they are (#6280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(composition): de-prefix local_dev builder names to the shared mechanism they are Phase 3 of #6274 (§4.4.1 category 2: "mis-prefixed shared substrate — not local at all; de-prefix, don't configify"). These builders are not local-dev-specific. Both `build_local_dev_store_graph` variants are already called by the hosted single-tenant volume deployment as well as local-dev, and the rest are the ordinary shared substrate every deployment assembles. The `local_dev` prefix claimed a deployment mode that the code does not have. Renames (mechanical; no logic change): build_local_dev_store_graph -> build_local_runtime_store_graph build_local_dev_root_filesystem -> build_local_runtime_root_filesystem build_local_dev_secret_store -> build_secret_store build_local_dev_secret_store_for_test -> build_secret_store_for_test build_local_dev_approval_interaction_service* -> build_approval_interaction_service* build_local_dev_extension_management_for_test -> build_extension_management_for_test build_local_dev_skill_context_source_for_test -> build_skill_context_source_for_test type LocalDevWorkspaceFilesystems -> WorkspaceFilesystems The two `build_local_runtime_*` names keep a qualifier because they genuinely select the local runtime substrate (`RuntimeSubstrate::Local`) rather than the production-shaped one — that is a substrate distinction, not a deployment mode. `reborn_localdev_typename_ratchet`'s allowlist is already empty, so no ratchet entry retires here; the private `LocalDevWorkspaceFilesystems` alias was below its pub-visibility scan and is cleaned up for consistency. Call sites updated across the composition crate, its tests, the integration harness, `tests/integration/secrets.rs`, and `tests/integration/CLAUDE.md`. No new names collide with existing symbols (verified against HEAD). Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(composition): RebornBuildInput carries the DeploymentConfig (#6282) Phase 4 of #6274. Completes the pivot: the build input carries the deployment as data instead of a profile name that consumers re-derive one from. `RebornBuildInput.profile: RebornCompositionProfile` becomes `deployment: DeploymentConfig`. `profile()` stays as a delegating accessor so no external caller changes; `deployment()` is the new read path. This removes the re-derivation Phase 2 had to sprinkle at each consumer: `build_reborn_services`, `build_reborn_runtime`, and the storage-shape guards called `DeploymentConfig::for_profile(profile, ..)` at the point of use. The config is now built once and carried. **The hazard that shaped the design.** A config built inside `RebornBuildInput::new` cannot know the operator's yolo host-access disclosure, so its policy request would carry `yolo_disclosure_acknowledged: false` and resolve fail-closed. Rather than paper over that, `new` takes an already-built `DeploymentConfig`, and `local_runtime_build_input_with_options` — the one place holding the operator's confirmation — builds it and hands it in. Pinned by `yolo_disclosure_reaches_both_the_carried_deployment_and_the_resolved_policy`, which resolves the *carried* config and asserts it reaches `RuntimeProfile::LocalYolo` / `ApprovalPolicy::Minimal`. `input.rs` now names zero profile variants, so its `reborn_deployment_mode_branching_ratchet` entry retires — the allowlist is down to four: deployment.rs (the terminal target state), factory.rs, local_runtime_profile.rs, readiness.rs. Also: - `local_dev_from_deployment` replaces the profile-name path, and its `debug_assert` is on the storage-shape axis rather than a list of profile names. - `with_deployment` is `#[cfg(test)]`: production builds the deployment at construction, and this only exists so a test can construct the deliberately mismatched deployment/storage pairing that drives the fail-closed guard in `build_reborn_services`. Tests: the two above plus `deployments_without_the_local_dev_storage_shape_are_rejected` (the helper's rejection is the storage-shape axis, not a profile list). NOT included, and not blocked on this: the single `build_runtime(cfg)` of §5.11, which merges the local and production store graphs into one backend-parameterized graph. That depends on Slice A store consolidation, which §9 gates behind Slice 0's reference-model property suite — infrastructure this repo does not have yet. Landing it before that oracle exists is exactly what §9 forbids. Co-authored-by: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...eborn_deployment_mode_branching_ratchet.rs | 6 - .../src/factory.rs | 75 ++++---- .../ironclaw_reborn_composition/src/input.rs | 83 +++++++-- .../src/local_runtime_profile.rs | 98 +++++++++- .../src/runtime.rs | 21 ++- .../src/runtime/test_support.rs | 6 +- .../src/test_support/local_dev_boot.rs | 16 +- .../src/test_support/mod.rs | 9 +- .../refreshing_capability_port.rs | 6 +- .../src/test_support/skill_activation.rs | 2 +- .../tests/facade_factory.rs | 8 +- tests/integration/CLAUDE.md | 2 +- tests/integration/secrets.rs | 10 +- tests/integration/support/harness/mod.rs | 169 +++++++++--------- 14 files changed, 326 insertions(+), 185 deletions(-) diff --git a/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs index 432a2f3aea9..56714c39462 100644 --- a/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_deployment_mode_branching_ratchet.rs @@ -68,12 +68,6 @@ const ALLOWLIST: &[(&str, &str)] = &[ test fixtures and the `local_dev_extension_installation_state_path` \ label argument.", ), - ( - "input.rs", - "`RebornBuildInput` constructors take a profile and pass it through to \ - `RebornServices::profile()`. Retires when the build input carries a \ - `DeploymentConfig` instead of a profile.", - ), ( "local_runtime_profile.rs", "The composition edge itself: maps a profile to its config and rejects \ diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 41a19abb8d9..dac59a20867 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -206,7 +206,7 @@ use ironclaw_turns::{ LoopCheckpointStore, }; -/// Output of [`build_local_dev_root_filesystem`]: the composed local-dev +/// Output of [`build_local_runtime_root_filesystem`]: the composed local-dev /// root filesystem and, when libSQL is the substrate, a clone of the raw /// libSQL handle. The handle backs both the local-dev trigger repository /// and the canonical Reborn identity store, so each rides the same @@ -235,7 +235,7 @@ enum StorageBackendInput { Postgres(deadpool_postgres::Pool), } -type LocalDevWorkspaceFilesystems = ( +type WorkspaceFilesystems = ( Arc>, Arc>, MountView, @@ -695,7 +695,7 @@ impl RebornServices { } /// Test-support access to the local-dev communication-preference repository - /// (W6-COLD-SPOTS seam). This is the SAME `Arc` that `build_local_dev_store_graph` + /// (W6-COLD-SPOTS seam). This is the SAME `Arc` that `build_local_runtime_store_graph` /// wires into `RebornRuntimeSubstrate::outbound_preferences` via /// `local_dev_outbound_store`, for tests only. Returns `None` for /// production-profile compositions without a local-dev runtime. @@ -709,7 +709,7 @@ impl RebornServices { /// Test-support access to the on-disk local-dev storage root (W6-COLD-SPOTS /// seam), for tests only — mirrors the same `local_runtime.local_dev_storage_root` - /// that `build_local_dev_store_graph` establishes in production. Used to reopen + /// that `build_local_runtime_store_graph` establishes in production. Used to reopen /// a fresh outbound-preferences store at the same root (see /// `open_local_dev_outbound_preferences_store_for_test`). Returns `None` for /// production-profile compositions without a local-dev runtime. @@ -1318,18 +1318,14 @@ pub async fn build_reborn_services( input: RebornBuildInput, ) -> Result { tracing::debug!( - profile = %input.profile, + profile = %input.profile(), owner_id = %input.owner_id, "building Reborn composition facades" ); // Substrate selection is deployment *data* (§4.4/§5.6), not a profile // match: the config says which substrate to assemble and this dispatches // on that value. - let substrate = crate::deployment::DeploymentConfig::for_profile( - input.profile, - input.grants_trusted_laptop_access(), - ) - .substrate(); + let substrate = input.deployment().substrate(); match substrate { crate::deployment::RuntimeSubstrate::None => Ok(RebornServices::disabled()), crate::deployment::RuntimeSubstrate::Local => build_local_runtime(input).await, @@ -1456,7 +1452,7 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result Result Result { return Err(RebornBuildError::InvalidConfig { @@ -1506,7 +1504,7 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result { return Err(RebornBuildError::InvalidConfig { @@ -1596,7 +1594,7 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result Result Result Result { let RebornStoreGraphInput { @@ -2686,7 +2684,7 @@ async fn build_local_dev_store_graph( } #[cfg(not(any(feature = "libsql", feature = "postgres")))] -async fn build_local_dev_store_graph( +async fn build_local_runtime_store_graph( input: RebornStoreGraphInput, ) -> Result { let RebornStoreGraphInput { @@ -3169,7 +3167,7 @@ fn build_budget_sinks() -> BudgetSinks { } } -async fn build_local_dev_root_filesystem( +async fn build_local_runtime_root_filesystem( root: &Path, workspace_root: &Path, host_home_root: Option<&HostHomeRoot>, @@ -3239,7 +3237,7 @@ async fn open_local_dev_libsql_database( // (`build_default_local_dev_database_roots_for_test`) can call this // without duplicating the 4-step libSQL setup sequence (Builder → // LibSqlRootFilesystem → run_migrations → mount). Production callers -// stay inside this module (`build_local_dev_root_filesystem`). +// stay inside this module (`build_local_runtime_root_filesystem`). pub(crate) async fn build_default_local_dev_database_roots( root: &Path, composite: &mut CompositeRootFilesystem, @@ -3331,7 +3329,7 @@ pub(crate) async fn open_local_dev_slack_host_state_filesystem_for_test( storage_root: &Path, ) -> Result>, RebornBuildError> { let workspace_root = storage_root.join("workspace"); - let bundle = build_local_dev_root_filesystem( + let bundle = build_local_runtime_root_filesystem( storage_root, &workspace_root, None, @@ -3438,7 +3436,7 @@ pub(crate) async fn open_local_dev_approval_request_store_for_test( /// W6-COLD-SPOTS: fresh `CommunicationPreferenceRepository` reopen, mirrors /// [`open_local_dev_approval_request_store_for_test`]. Reuses /// [`local_dev_outbound_store`] — the same composition-owned construction the -/// production `build_local_dev_store_graph` path uses — so the reopen path +/// production `build_local_runtime_store_graph` path uses — so the reopen path /// never drifts from production and needs no `disallowed_methods` exception. /// Tests only. #[cfg(all(feature = "test-support", feature = "libsql"))] @@ -3458,7 +3456,7 @@ pub(crate) async fn open_local_dev_outbound_preferences_store_for_test( /// [`open_local_dev_approval_request_store_for_test`] (same on-disk root; /// sibling capability stores). Reuses [`mount_default_local_dev_database_roots`] /// plus the production [`crate::wrap_scoped`] so the reopen mounts and scopes -/// the SAME way `build_local_dev_store_graph` does when it first builds +/// the SAME way `build_local_runtime_store_graph` does when it first builds /// `tool_permission_overrides` / `auto_approve_settings` / /// `persistent_approval_policies` (above) — the reopen path never drifts from /// production. Tests only; zero bytes in production builds. @@ -3534,7 +3532,7 @@ where // (`mount_local_dev_database_roots_for_test`) can forward to it across the // crate boundary for downstream integration tests without a second copy of the // mount truth. Production callers stay inside this module -// (`build_local_dev_root_filesystem` / `build_default_local_dev_database_roots`). +// (`build_local_runtime_root_filesystem` / `build_default_local_dev_database_roots`). pub(crate) fn mount_local_dev_database_roots( root: &mut CompositeRootFilesystem, database: Arc, @@ -3602,7 +3600,7 @@ fn mount_local_dev_project_roots( } #[cfg(any(feature = "libsql", feature = "postgres"))] -pub(crate) async fn build_local_dev_secret_store( +pub(crate) async fn build_secret_store( root: &Path, scoped_filesystem: Arc>, explicit_master_key: Option, @@ -3641,11 +3639,11 @@ where /// and reconstructing the whole composite just to reach one mount is /// heavy and risks silently diverging from `serve`'s copy. /// - `/secrets`'s physical backing is the same local-dev libSQL file -/// `build_local_dev_root_filesystem` opens for `/tenants` in production — +/// `build_local_runtime_root_filesystem` opens for `/tenants` in production — /// a key written here is immediately visible to `serve`, no extra /// coordination needed. /// - Uses the same resolver chain as production (env -> cached dotfile -> -/// OS keychain -> generate-and-cache, via [`build_local_dev_secret_store`]). +/// OS keychain -> generate-and-cache, via [`build_secret_store`]). /// - `run_migrations()` here and again on `serve`'s later open is safe — /// already relied on as idempotent elsewhere in this module's tests. #[cfg(feature = "libsql")] @@ -3656,7 +3654,7 @@ pub async fn open_local_dev_secret_store( let filesystem = Arc::new(LibSqlRootFilesystem::new(db)); filesystem.run_migrations().await?; let scoped = crate::wrap_scoped(filesystem); - let (store, _crypto) = build_local_dev_secret_store(root, scoped, None).await?; + let (store, _crypto) = build_secret_store(root, scoped, None).await?; Ok(store as Arc) } @@ -4151,7 +4149,7 @@ fn build_workspace_filesystems( filesystem: Arc, workspace_root: &Path, host_home_root: Option<&HostHomeRoot>, -) -> Result { +) -> Result { let read_only_workspace_mounts = workspace_mount_view(MountPermissions::read_only(), &[]) .map_err(|error| RebornBuildError::InvalidConfig { reason: error.to_string(), @@ -4527,7 +4525,7 @@ async fn build_production_shaped( input: RebornBuildInput, ) -> Result { let RebornBuildInput { - profile, + deployment, owner_id, local_runtime_identity, storage, @@ -4554,6 +4552,8 @@ async fn build_production_shaped( nearai_mcp_bootstrap_config: _, turn_state_store_limits, } = input; + // Label for logging/errors; behaviour reads `deployment`'s axes. + let profile = deployment.profile(); #[cfg(any(feature = "libsql", feature = "postgres"))] let wiring_config = production_config( required_runtime_backends, @@ -6225,11 +6225,16 @@ mod tests { #[tokio::test] async fn hosted_single_tenant_rejects_local_dev_storage_input() { let dir = tempfile::tempdir().expect("tempdir"); - let mut input = RebornBuildInput::local_dev( + let input = RebornBuildInput::local_dev( "hosted-single-tenant-local-storage-owner", dir.path().join("local-dev"), ); - input.profile = RebornCompositionProfile::HostedSingleTenant; + // Deliberate mismatch: a hosted single-tenant deployment paired with a + // local-dev storage input must be rejected by the storage-shape guard. + let input = input.with_deployment(crate::deployment::DeploymentConfig::for_profile( + RebornCompositionProfile::HostedSingleTenant, + false, + )); let error = match build_reborn_services(input).await { Ok(_) => { @@ -6422,7 +6427,7 @@ mod tests { .expect("manual-token account should survive local-dev rebuild"); assert_eq!(rebuilt_account.access_secret.as_ref(), Some(&access_secret)); - let rebuilt_filesystem = build_local_dev_root_filesystem( + let rebuilt_filesystem = build_local_runtime_root_filesystem( &local_dev_root, &local_dev_root.join("workspace"), None, @@ -6431,7 +6436,7 @@ mod tests { .await .expect("local-dev filesystem rebuild") .filesystem; - let (rebuilt_secret_store, _rebuilt_secret_crypto) = build_local_dev_secret_store( + let (rebuilt_secret_store, _rebuilt_secret_crypto) = build_secret_store( &local_dev_root, local_dev_scoped_filesystem(rebuilt_filesystem), None, @@ -6496,7 +6501,7 @@ mod tests { std::fs::create_dir_all(local_dev_root.join("system/extensions")) .expect("system extensions dir"); - let root_filesystem = build_local_dev_root_filesystem( + let root_filesystem = build_local_runtime_root_filesystem( &local_dev_root, &local_dev_root.join("workspace"), None, diff --git a/crates/ironclaw_reborn_composition/src/input.rs b/crates/ironclaw_reborn_composition/src/input.rs index 7dafd8e3bd4..77801abf2f5 100644 --- a/crates/ironclaw_reborn_composition/src/input.rs +++ b/crates/ironclaw_reborn_composition/src/input.rs @@ -28,6 +28,7 @@ use ironclaw_reborn_event_store::{PostgresPoolTlsOptions, RebornPostgresSslMode} #[cfg(feature = "postgres")] use crate::RebornBuildError; +use crate::deployment::DeploymentConfig; use crate::product_auth::oauth::google_oauth::google_provider_spec; use crate::product_auth::oauth::notion_oauth::notion_provider_spec; use crate::product_auth::oauth::oauth_dcr::OAuthDcrProviderConfig; @@ -176,7 +177,17 @@ impl RebornRuntimeProcessBinding { } pub struct RebornBuildInput { - pub(crate) profile: RebornCompositionProfile, + /// The deployment this build assembles, as data (§4.4/§5.6). Carries the + /// substrate, traffic, readiness, and storage-shape axes every consumer + /// reads instead of re-deriving them from a profile name. + /// + /// The **resolved** runtime policy rides `runtime_policy`, not this value: + /// `new` builds the config without a yolo host-access disclosure (it is not + /// known at construction), so callers that hold the operator's confirmation + /// install the accurate config through + /// [`RebornBuildInput::with_deployment`] — `local_runtime_build_input_with_options` + /// is the one that does. + pub(crate) deployment: DeploymentConfig, pub(crate) owner_id: String, pub(crate) local_runtime_identity: Option, pub(crate) storage: RebornStorageInput, @@ -244,9 +255,30 @@ pub(crate) enum RebornStorageInput { } impl RebornBuildInput { - /// Selected composition profile. + /// Selected composition profile — a display/telemetry label. Behaviour + /// comes from [`RebornBuildInput::deployment`]. pub fn profile(&self) -> RebornCompositionProfile { - self.profile + self.deployment.profile() + } + + /// The deployment axes this build assembles from. + pub fn deployment(&self) -> &DeploymentConfig { + &self.deployment + } + + /// Replace the deployment this input was constructed with. + /// + /// Test-only: production builds the deployment at construction + /// (`RebornBuildInput::new` takes it, and `local_runtime_build_input_with_options` + /// supplies one built where the operator's yolo disclosure is known). This + /// exists so tests can construct a deliberately mismatched + /// deployment/storage pairing and drive the fail-closed guard in + /// `build_reborn_services` — production behaviour, reached through a + /// pairing production rejects. + #[cfg(test)] + pub(crate) fn with_deployment(mut self, deployment: DeploymentConfig) -> Self { + self.deployment = deployment; + self } /// Owner id (string form). Used by the assembled runtime to mint the @@ -284,14 +316,14 @@ impl RebornBuildInput { pub fn disabled(owner_id: impl Into) -> Self { Self::new( - RebornCompositionProfile::Disabled, + DeploymentConfig::disabled(), owner_id, RebornStorageInput::Disabled, ) } pub fn local_dev(owner_id: impl Into, root: PathBuf) -> Self { - Self::local_dev_with_profile(RebornCompositionProfile::LocalDev, owner_id, root) + Self::local_dev_from_deployment(DeploymentConfig::local_dev(), owner_id, root) } pub(crate) fn local_dev_with_profile( @@ -299,9 +331,24 @@ impl RebornBuildInput { owner_id: impl Into, root: PathBuf, ) -> Self { - debug_assert!(profile.uses_local_dev_storage_input()); + Self::local_dev_from_deployment( + DeploymentConfig::for_profile(profile, false), + owner_id, + root, + ) + } + + /// Build a local-dev-storage-shaped input from an already-resolved + /// deployment. The `debug_assert` is on the storage-shape **axis**, not on + /// a list of profile names (§4.4). + pub(crate) fn local_dev_from_deployment( + deployment: DeploymentConfig, + owner_id: impl Into, + root: PathBuf, + ) -> Self { + debug_assert!(deployment.uses_local_dev_storage_input()); Self::new( - profile, + deployment, owner_id, RebornStorageInput::LocalDev { root, @@ -323,7 +370,7 @@ impl RebornBuildInput { // config's storage-shape axis rather than a profile-name comparison // (§4.4): a deployment that takes a hosted single-tenant pool is a // property of the deployment, not of its name. - if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + if DeploymentConfig::for_profile(profile, false).storage_shape() != crate::deployment::StorageShape::HostedSingleTenantPool { return Err(RebornBuildError::InvalidConfig { @@ -333,7 +380,7 @@ impl RebornBuildInput { }); } Ok(Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::HostedSingleTenantPostgres { root, @@ -357,7 +404,7 @@ impl RebornBuildInput { // config's storage-shape axis rather than a profile-name comparison // (§4.4): a deployment that takes a hosted single-tenant pool is a // property of the deployment, not of its name. - if crate::deployment::DeploymentConfig::for_profile(profile, false).storage_shape() + if DeploymentConfig::for_profile(profile, false).storage_shape() != crate::deployment::StorageShape::HostedSingleTenantPool { return Err(RebornBuildError::InvalidConfig { @@ -373,7 +420,7 @@ impl RebornBuildInput { .. } = resolve_postgres_storage_from_config_and_env(profile, config_file)?; Ok(Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::HostedSingleTenantPostgres { root, @@ -487,7 +534,7 @@ impl RebornBuildInput { secret_master_key: ironclaw_secrets::SecretMaterial, ) -> Self { Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::Libsql { db, @@ -508,7 +555,7 @@ impl RebornBuildInput { auth_token: Option, ) -> Self { Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::Libsql { db, @@ -529,7 +576,7 @@ impl RebornBuildInput { secret_master_key: ironclaw_secrets::SecretMaterial, ) -> Self { Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::Postgres { pool, @@ -549,7 +596,7 @@ impl RebornBuildInput { url: ironclaw_secrets::SecretMaterial, ) -> Self { Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::Postgres { pool, @@ -578,7 +625,7 @@ impl RebornBuildInput { let trust_policy = crate::builtin_first_party_trust_policy()?; Ok(Self::new( - profile, + DeploymentConfig::for_profile(profile, false), owner_id, RebornStorageInput::Postgres { pool, @@ -786,12 +833,12 @@ impl RebornBuildInput { } fn new( - profile: RebornCompositionProfile, + deployment: DeploymentConfig, owner_id: impl Into, storage: RebornStorageInput, ) -> Self { Self { - profile, + deployment, owner_id: owner_id.into(), local_runtime_identity: None, storage, diff --git a/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs b/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs index b5ee5fc3037..74b81989bc9 100644 --- a/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs +++ b/crates/ironclaw_reborn_composition/src/local_runtime_profile.rs @@ -70,9 +70,15 @@ pub fn local_runtime_build_input_with_options( return hosted_single_tenant_volume_build_input(owner_id, root); } - let policy = local_runtime_policy(profile, options)?; + // Build the deployment once, here, where the operator's host-access + // confirmation is known, and carry it on the input rather than letting + // downstream re-derive it from the profile name (§4.4). + let deployment = deployment_config_for_profile(profile, options)?; + let policy = deployment + .resolve()? + .ok_or(RebornRuntimeProfileError::MissingPolicyRequest { profile })?; Ok( - RebornBuildInput::local_dev_with_profile(profile, owner_id, root) + RebornBuildInput::local_dev_from_deployment(deployment, owner_id, root) .with_runtime_policy(policy), ) } @@ -184,3 +190,91 @@ fn local_runtime_policy_for_local_dev_shape( } }) } + +#[cfg(test)] +mod tests { + use ironclaw_host_api::runtime_policy::{ApprovalPolicy, RuntimeProfile}; + + use super::*; + + #[test] + fn yolo_disclosure_reaches_both_the_carried_deployment_and_the_resolved_policy() { + // This module is the one place that holds the operator's host-access + // confirmation, so it must be the place that builds the deployment. + // The hazard being pinned: `RebornBuildInput::new` cannot know the + // disclosure, so a config built there would carry + // `yolo_disclosure_acknowledged: false` and resolve fail-closed. The + // input must carry the config built *here* instead. + let dir = std::env::temp_dir().join("reborn-yolo-disclosure-test"); + let input = local_runtime_build_input_with_options( + RebornCompositionProfile::LocalDevYolo, + "yolo-owner", + dir, + RebornRuntimeProfileOptions { + confirm_host_access: true, + }, + ) + .expect("confirmed local-dev-yolo builds"); + + assert_eq!( + input.profile(), + RebornCompositionProfile::LocalDevYolo, + "the carried deployment must keep the requested profile label" + ); + let carried = input + .deployment() + .resolve() + .expect("carried deployment resolves") + .expect("local-dev-yolo makes a policy request"); + assert_eq!( + carried.resolved_profile, + RuntimeProfile::LocalYolo, + "the carried deployment must have the disclosure, or it would fail closed" + ); + assert_eq!(carried.approval_policy, ApprovalPolicy::Minimal); + } + + #[test] + fn unconfirmed_yolo_fails_closed_before_an_input_is_built() { + let dir = std::env::temp_dir().join("reborn-yolo-unconfirmed-test"); + let error = local_runtime_build_input_with_options( + RebornCompositionProfile::LocalDevYolo, + "yolo-owner", + dir, + RebornRuntimeProfileOptions { + confirm_host_access: false, + }, + ); + let Err(error) = error else { + panic!("unconfirmed yolo must not produce a build input"); + }; + assert!(matches!( + error, + RebornRuntimeProfileError::Policy(ResolveError::YoloRequiresDisclosure { .. }) + )); + } + + #[test] + fn deployments_without_the_local_dev_storage_shape_are_rejected() { + // The helper builds the local-dev storage input shape; the rejection is + // expressed as the storage-shape axis, not a list of profile names. + for profile in [ + RebornCompositionProfile::Disabled, + RebornCompositionProfile::HostedSingleTenant, + RebornCompositionProfile::Production, + RebornCompositionProfile::MigrationDryRun, + ] { + let error = deployment_config_for_profile( + profile, + RebornRuntimeProfileOptions { + confirm_host_access: true, + }, + ) + .expect_err("non-local-dev-storage deployments are not this helper's business"); + assert!(matches!( + error, + RebornRuntimeProfileError::UnsupportedProfile { .. } + )); + } + } +} diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index c53931017ae..6c502729dee 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -747,16 +747,16 @@ impl RegistryPersistentApprovalGranteeResolver { /// test accessor: production wires one for audit-log observability only, not /// correctness the test needs. Propagates policy/resolver construction failures /// instead of collapsing them to `None`. Thin wrapper over -/// `build_local_dev_approval_interaction_service_with_turn_run_source` using +/// `build_approval_interaction_service_with_turn_run_source` using /// `local_runtime.turn_state` as the turn-run snapshot source — production /// behavior is unchanged by the seam below. -pub(crate) fn build_local_dev_approval_interaction_service( +pub(crate) fn build_approval_interaction_service( local_runtime: &crate::factory::RebornRuntimeSubstrate, builtin_capability_policy: Arc, turn_coordinator: Arc, audit_sink: Option>, ) -> Result, RebornRuntimeError> { - build_local_dev_approval_interaction_service_with_turn_run_source( + build_approval_interaction_service_with_turn_run_source( local_runtime, builtin_capability_policy, turn_coordinator, @@ -765,17 +765,17 @@ pub(crate) fn build_local_dev_approval_interaction_service( ) } -/// Identical to [`build_local_dev_approval_interaction_service`] +/// Identical to [`build_approval_interaction_service`] /// except the approval turn-run locator reads `turn_run_source` instead of /// always deriving it from `local_runtime.turn_state`. Lets a caller whose /// real runs live in a DIFFERENT `TurnStateStore` composition (e.g. /// `RebornIntegrationGroup`'s own `build_default_planned_runtime`, whose runs /// are invisible to this crate's `local_runtime.turn_state`) substitute its -/// own store. `build_local_dev_approval_interaction_service` is the +/// own store. `build_approval_interaction_service` is the /// production entry point and is a thin wrapper over this function with /// `local_runtime.turn_state` as the source, so production behavior is /// unchanged. -pub(crate) fn build_local_dev_approval_interaction_service_with_turn_run_source( +pub(crate) fn build_approval_interaction_service_with_turn_run_source( local_runtime: &crate::factory::RebornRuntimeSubstrate, builtin_capability_policy: Arc, turn_coordinator: Arc, @@ -1023,7 +1023,7 @@ struct SnapshotApprovalTurnRunLocator { /// A trait object (not the concrete `ComposedTurnStateStore`) so a /// caller can substitute a different turn-state store's snapshot view — /// see `turn_run_snapshot::TurnRunSnapshotSource` and - /// `build_local_dev_approval_interaction_service_with_turn_run_source`. + /// `build_approval_interaction_service_with_turn_run_source`. turn_state: Arc, } @@ -3134,8 +3134,7 @@ pub async fn build_reborn_runtime( // The deployment this build assembles, as data (§4.4/§5.6). Every axis // below — live-traffic admission, the cutover gate, substrate selection — // reads a field on this value instead of re-matching the profile. - let deployment = - DeploymentConfig::for_profile(profile, services_input.grants_trusted_laptop_access()); + let deployment = services_input.deployment().clone(); if let Some(reason) = deployment.traffic().live_traffic_refusal(profile) { return Err(RebornRuntimeError::InvalidArgument { reason }); } @@ -3953,7 +3952,7 @@ pub async fn build_reborn_runtime( if let (Some(local_runtime), Some(builtin_capability_policy)) = (local_runtime, builtin_capability_policy) { - build_local_dev_approval_interaction_service( + build_approval_interaction_service( local_runtime, builtin_capability_policy, Arc::clone(&planned_turn_coordinator), @@ -4221,7 +4220,7 @@ fn build_webui_auth_interaction_service( /// Identical to [`build_webui_auth_interaction_service`] except /// the auth read model reads `turn_run_source` instead of a hardcoded /// `ComposedTurnStateStore`. See -/// `build_local_dev_approval_interaction_service_with_turn_run_source`'s doc +/// `build_approval_interaction_service_with_turn_run_source`'s doc /// for why this seam exists. fn build_webui_auth_interaction_service_with_turn_run_source( product_auth: Option<&RebornProductAuthServices>, diff --git a/crates/ironclaw_reborn_composition/src/runtime/test_support.rs b/crates/ironclaw_reborn_composition/src/runtime/test_support.rs index bf3f15c832f..b5156b8b5f5 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/test_support.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/test_support.rs @@ -13,7 +13,7 @@ use super::*; impl RebornServices { /// Real `DefaultApprovalInteractionService` wired like `build_reborn_runtime`, via the - /// shared `build_local_dev_approval_interaction_service` recipe so the two never drift. + /// shared `build_approval_interaction_service` recipe so the two never drift. /// `Ok(None)` without a local-dev runtime; `Err` surfaces a local-dev capability-policy /// or grantee-resolver construction failure instead of collapsing it into `None`. No /// audit sink threaded — production wires one for audit-log observability only, not @@ -33,7 +33,7 @@ impl RebornServices { reason: format!("local-dev capability policy is invalid: {error}"), } })?); - Ok(Some(build_local_dev_approval_interaction_service( + Ok(Some(build_approval_interaction_service( local_runtime, builtin_capability_policy, turn_coordinator, @@ -92,7 +92,7 @@ impl RebornServices { } })?); Ok(Some( - build_local_dev_approval_interaction_service_with_turn_run_source( + build_approval_interaction_service_with_turn_run_source( local_runtime, builtin_capability_policy, turn_coordinator, diff --git a/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs b/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs index 9315a5ad801..c9097f62b31 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs @@ -3,7 +3,7 @@ //! `build_approval_gate_evidence_for_test`, //! `build_default_local_dev_database_roots_for_test`, //! `mount_local_dev_database_roots_for_test`, -//! `build_local_dev_secret_store_for_test` — mirror the production local-dev +//! `build_secret_store_for_test` — mirror the production local-dev //! boot sequence so the integration-test harness (`tests/support/reborn/`) //! drives the real local-dev composition paths without duplicating the wiring //! logic. @@ -15,7 +15,7 @@ pub const LOCAL_DEV_DB_FILENAME: &str = crate::factory::LOCAL_DEV_DB_FILENAME; /// Test-only accessor mirroring the full local-dev database-roots boot path -/// (`build_local_dev_root_filesystem` → `build_default_local_dev_database_roots`). +/// (`build_local_runtime_root_filesystem` → `build_default_local_dev_database_roots`). /// /// Constructs the durable database backend and mounts it across the /// control-plane roots (`/tenants`, `/memory`, `/events`) of `composite`, @@ -26,7 +26,7 @@ pub const LOCAL_DEV_DB_FILENAME: &str = crate::factory::LOCAL_DEV_DB_FILENAME; /// Called by the Reborn integration-test framework's `StorageMode::LibSql` /// builder arm (`tests/support/reborn/builder.rs:build_storage_composite`) so /// the 4-step libSQL setup sequence lives once (production call site: -/// `build_local_dev_root_filesystem` → `build_default_local_dev_database_roots`). +/// `build_local_runtime_root_filesystem` → `build_default_local_dev_database_roots`). /// For tests only — gated behind `test-support`, ships zero bytes in production. #[cfg(feature = "test-support")] pub async fn build_default_local_dev_database_roots_for_test( @@ -37,7 +37,7 @@ pub async fn build_default_local_dev_database_roots_for_test( } /// Test-only accessor mirroring the production local-dev boot path -/// (`build_local_dev_root_filesystem` → `mount_local_dev_database_roots`). +/// (`build_local_runtime_root_filesystem` → `mount_local_dev_database_roots`). /// /// Mounts `database` across the control-plane roots (`/tenants`, `/memory`, /// `/events`) of `root` exactly as the libSQL local-dev boot path does, so @@ -62,7 +62,7 @@ where /// Reborn runtime assembly. /// /// Mirrors the production wiring in `build_local_runtime` where -/// `build_local_dev_secret_store` is called with the scoped filesystem and a +/// `build_secret_store` is called with the scoped filesystem and a /// master key resolved from the environment or the root directory's cached key /// file. Tests that need a real `FilesystemSecretStore` — for example, to /// verify `put` + `lease_once` + `consume` round-trips against an in-process @@ -76,16 +76,16 @@ where /// secret written by the first. For tests only — zero bytes shipped in /// production builds. #[cfg(any(feature = "libsql", feature = "postgres"))] -pub async fn build_local_dev_secret_store_for_test( +pub async fn build_secret_store_for_test( root: &std::path::Path, scoped: std::sync::Arc>, ) -> Result>, crate::RebornBuildError> where F: ironclaw_filesystem::RootFilesystem + 'static, { - // `build_local_dev_secret_store` also returns the crypto (for the admin + // `build_secret_store` also returns the crypto (for the admin // secret provisioner); this test helper only needs the store. - let (store, _crypto) = crate::factory::build_local_dev_secret_store(root, scoped, None).await?; + let (store, _crypto) = crate::factory::build_secret_store(root, scoped, None).await?; Ok(store) } diff --git a/crates/ironclaw_reborn_composition/src/test_support/mod.rs b/crates/ironclaw_reborn_composition/src/test_support/mod.rs index f3f749b334b..82229e97a1b 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/mod.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/mod.rs @@ -16,7 +16,7 @@ //! 3. [`local_dev_boot`] — `build_approval_gate_evidence_for_test`, //! `build_default_local_dev_database_roots_for_test`, //! `mount_local_dev_database_roots_for_test`, -//! `build_local_dev_secret_store_for_test` — mirror the production +//! `build_secret_store_for_test` — mirror the production //! local-dev boot sequence so the integration-test harness //! (`tests/support/reborn/`) drives the real local-dev composition paths //! without duplicating the wiring logic. @@ -96,7 +96,7 @@ pub use durable::{ }; pub use local_dev_boot::LOCAL_DEV_DB_FILENAME; #[cfg(any(feature = "libsql", feature = "postgres"))] -pub use local_dev_boot::build_local_dev_secret_store_for_test; +pub use local_dev_boot::build_secret_store_for_test; #[cfg(feature = "test-support")] pub use local_dev_boot::{ build_approval_gate_evidence_for_test, build_default_local_dev_database_roots_for_test, @@ -120,14 +120,13 @@ pub use projection::build_webui_event_stream_for_test; #[cfg(feature = "test-support")] pub use refreshing_capability_port::{ ExtensionManagementTestHandle, RefreshingCapabilityPortTestParts, - build_local_dev_extension_management_for_test, create_refreshing_capability_port_for_test, + build_extension_management_for_test, create_refreshing_capability_port_for_test, }; #[cfg(feature = "test-support")] pub use result_read::{RESULT_READ_CAPABILITY_ID, wrap_result_read_capability_for_test}; #[cfg(feature = "test-support")] pub use skill_activation::{ - SKILL_ACTIVATE_CAPABILITY_ID, SkillActivationTestSource, - build_local_dev_skill_context_source_for_test, + SKILL_ACTIVATE_CAPABILITY_ID, SkillActivationTestSource, build_skill_context_source_for_test, }; #[cfg(all(feature = "test-support", feature = "slack-v2-host-beta"))] pub use slack_channel_connection::{ diff --git a/crates/ironclaw_reborn_composition/src/test_support/refreshing_capability_port.rs b/crates/ironclaw_reborn_composition/src/test_support/refreshing_capability_port.rs index faf713bbdc2..0ad4458f966 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/refreshing_capability_port.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/refreshing_capability_port.rs @@ -39,7 +39,7 @@ pub struct RefreshingCapabilityPortTestParts { pub result_writer: std::sync::Arc, pub milestone_sink: std::sync::Arc, /// Opaque handle built by - /// `test_support::build_local_dev_skill_context_source_for_test`. Wraps + /// `test_support::build_skill_context_source_for_test`. Wraps /// the crate-private `ComposedSelectableSkillContextSource` so it never /// appears in this (public, `test-support`-gated) struct's field types; /// the private type is recovered internally via @@ -53,7 +53,7 @@ pub struct RefreshingCapabilityPortTestParts { /// (`local_dev.rs` `create_capability_port`). pub thread_service: std::sync::Arc, /// Opaque handle built by - /// [`build_local_dev_extension_management_for_test`]. Wraps the + /// [`build_extension_management_for_test`]. Wraps the /// crate-private (`pub(crate)`) `RebornLocalExtensionManagementPort` so it /// never appears in this (public, `test-support`-gated) struct's field /// types; mirrors `skill_activation_source` above. Active-extension @@ -142,7 +142,7 @@ impl ExtensionManagementTestHandle { /// install/activate an extension can also just omit this call and leave the /// field `None` for the same no-op surface. #[cfg(feature = "test-support")] -pub fn build_local_dev_extension_management_for_test( +pub fn build_extension_management_for_test( services: &crate::RebornServices, ) -> Option { let extension_management = services diff --git a/crates/ironclaw_reborn_composition/src/test_support/skill_activation.rs b/crates/ironclaw_reborn_composition/src/test_support/skill_activation.rs index 100d4627b02..4629471fb78 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/skill_activation.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/skill_activation.rs @@ -46,7 +46,7 @@ impl SkillActivationTestSource { /// composed. Mirrors `build_user_profile_source_for_test` (E-SKILL seam). /// Tests only. #[cfg(feature = "test-support")] -pub fn build_local_dev_skill_context_source_for_test( +pub fn build_skill_context_source_for_test( services: &crate::RebornServices, tenant_id: &ironclaw_host_api::TenantId, regex_skill_activation_enabled: bool, diff --git a/crates/ironclaw_reborn_composition/tests/facade_factory.rs b/crates/ironclaw_reborn_composition/tests/facade_factory.rs index 310a65ab07e..c2d94a5b560 100644 --- a/crates/ironclaw_reborn_composition/tests/facade_factory.rs +++ b/crates/ironclaw_reborn_composition/tests/facade_factory.rs @@ -1,3 +1,7 @@ +// arch-exempt: large_file, pre-existing ~1.9K-line facade test suite; this change is a net-zero rename of build_local_dev_secret_store_for_test call sites with no cases added, plan #6168 +// +// Decomposition of this suite travels with the composition god-crate shrink +// (#6168); do not add unrelated cases here. #[cfg(feature = "postgres")] #[path = "support/postgres.rs"] mod postgres_support; @@ -1311,7 +1315,7 @@ async fn local_dev_secret_store_falls_through_suppressed_keychain_to_dotfile() { let composite = std::sync::Arc::new(composite); let scoped = ironclaw_reborn_composition::wrap_scoped(std::sync::Arc::clone(&composite)); - ironclaw_reborn_composition::test_support::build_local_dev_secret_store_for_test( + ironclaw_reborn_composition::test_support::build_secret_store_for_test( root, std::sync::Arc::clone(&scoped), ) @@ -1323,7 +1327,7 @@ async fn local_dev_secret_store_falls_through_suppressed_keychain_to_dotfile() { ); let cached = std::fs::read_to_string(&key_path).expect("read generated dotfile"); - ironclaw_reborn_composition::test_support::build_local_dev_secret_store_for_test(root, scoped) + ironclaw_reborn_composition::test_support::build_secret_store_for_test(root, scoped) .await .expect("second store build must read the now-cached dotfile idempotently"); assert_eq!( diff --git a/tests/integration/CLAUDE.md b/tests/integration/CLAUDE.md index 3613252636c..c61fa23fc69 100644 --- a/tests/integration/CLAUDE.md +++ b/tests/integration/CLAUDE.md @@ -376,7 +376,7 @@ On a harness built from a `live_approvals` group: `ironclaw_reborn_composition::test_support` exposes: -- `build_local_dev_secret_store_for_test(root, scoped)` — constructs the `LocalDevSecretStore` used by production local-dev composition; for store read-back in secrets tests. +- `build_secret_store_for_test(root, scoped)` — constructs the `LocalDevSecretStore` used by production local-dev composition; for store read-back in secrets tests. `RebornServices` (returned by `build_reborn_services`/exposed via `RebornRuntime::services()`, methods defined in `crates/ironclaw_reborn_composition/src/runtime/test_support.rs`) exposes: diff --git a/tests/integration/secrets.rs b/tests/integration/secrets.rs index 35fcce2b355..ab706e7d75f 100644 --- a/tests/integration/secrets.rs +++ b/tests/integration/secrets.rs @@ -23,7 +23,7 @@ use ironclaw_filesystem::{CompositeRootFilesystem, LibSqlRootFilesystem}; use ironclaw_host_api::SecretHandle; use ironclaw_reborn_composition::test_support::{ LOCAL_DEV_DB_FILENAME, build_default_local_dev_database_roots_for_test, - build_local_dev_secret_store_for_test, mount_local_dev_database_roots_for_test, + build_secret_store_for_test, mount_local_dev_database_roots_for_test, }; use ironclaw_reborn_composition::wrap_scoped; use ironclaw_secrets::{SecretMaterial, SecretStore, SecretStoreError}; @@ -48,7 +48,7 @@ async fn secret_persists_across_libsql_reopen() { .expect("build default local-dev db roots"); let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); - let store = build_local_dev_secret_store_for_test(dir.path(), Arc::clone(&scoped)) + let store = build_secret_store_for_test(dir.path(), Arc::clone(&scoped)) .await .expect("build first secret store"); @@ -93,7 +93,7 @@ async fn secret_persists_across_libsql_reopen() { .expect("mount fresh composite"); let fresh_composite = Arc::new(fresh_composite); let fresh_scoped = wrap_scoped(Arc::clone(&fresh_composite)); - let fresh_store = build_local_dev_secret_store_for_test(dir.path(), fresh_scoped) + let fresh_store = build_secret_store_for_test(dir.path(), fresh_scoped) .await .expect("build fresh secret store (same root → same crypto key)"); @@ -127,7 +127,7 @@ async fn secret_read_back_fails_for_unknown_handle() { .expect("build default local-dev db roots"); let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); - let store = build_local_dev_secret_store_for_test(dir.path(), scoped) + let store = build_secret_store_for_test(dir.path(), scoped) .await .expect("build secret store"); @@ -174,7 +174,7 @@ async fn secret_read_back_fails_for_wrong_tenant_scope() { .expect("build default local-dev db roots"); let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); - let store = build_local_dev_secret_store_for_test(dir.path(), scoped) + let store = build_secret_store_for_test(dir.path(), scoped) .await .expect("build secret store"); diff --git a/tests/integration/support/harness/mod.rs b/tests/integration/support/harness/mod.rs index 02f5a27edf0..d734cec8f03 100644 --- a/tests/integration/support/harness/mod.rs +++ b/tests/integration/support/harness/mod.rs @@ -668,7 +668,7 @@ impl HostRuntimeCapabilityHarness { }) { let tenant = skill_activation_tenant .ok_or("skill_activation_tools harness requires with_skill_activation_tenant")?; - ironclaw_reborn_composition::test_support::build_local_dev_skill_context_source_for_test( + ironclaw_reborn_composition::test_support::build_skill_context_source_for_test( &services, &tenant, true, ) .map(Arc::new) @@ -1536,7 +1536,7 @@ impl HostRuntimeCapabilityHarness { // through the real activation handshake (`extension_surface_source`'s // `ExtensionCapabilitySurface::provider_trust()`, same // `extension_management` + grantee production's factory reads) -- - // queried the SAME way `build_local_dev_extension_management_for_test` + // queried the SAME way `build_extension_management_for_test` // -> `extension_surface_source` will read it downstream in // `create_refreshing_capability_port_for_test`. NOT the same // as "this harness has `reborn_services` wired": a harness can have @@ -1617,90 +1617,89 @@ impl HostRuntimeCapabilityHarness { } }) .collect(); - let parts = - ironclaw_reborn_composition::test_support::RefreshingCapabilityPortTestParts { - runtime: self.runtime.lock().unwrap().clone(), - run_context: run_context.clone(), - fallback_user_id: dispatch_user, - // All four mount views = this harness's single `mounts` view. - // Production splits skill/memory/system-extensions mounts off - // the local-dev workspace root, but this harness has ONE - // profile-built view and the pre-seam behavior was "every - // capability executes (and is granted) under `self.mounts` - // unless `capability_mount_overrides` says otherwise" — a - // `MountView::default()` here would instead OVERRIDE the - // memory/skill capability families down to an empty view via - // `build_inner`'s per-domain `with_capability_execution_mount` - // special-cases, silently blinding `builtin.memory_*`. - workspace_mounts: self.mounts.clone(), - skill_mounts: self.mounts.clone(), - memory_mounts: self.mounts.clone(), - system_extensions_lifecycle_mounts: self.mounts.clone(), - input_resolver, - result_writer, - milestone_sink: milestone_sink.clone() as Arc, - skill_activation_source: self.skill_activation_source.clone(), - project_service, - // result_read (durable tool-result projection seam, issue - // #5838): production always wires the run's session thread - // service into the synthetic `result_read` capability, so - // this is a required (non-`Option`) field. Harnesses that - // opted into `.with_durable_capability_io()` populate - // `durable_capability_io_thread_service` with the REAL group - // thread service; every other harness gets a fresh in-memory - // no-op service, mirroring the `project_service`/ - // `tool_permission_overrides` "no opinion -> default" pattern - // above -- `result_read` is simply never granted for those - // harnesses (not in `capability_ids`), so the default is - // never actually read. - thread_service: self - .durable_capability_io_thread_service - .lock() - .unwrap() - .clone() - .unwrap_or_else(|| { - Arc::new(ironclaw_threads::InMemorySessionThreadService::default()) - }), - trajectory_observer: None, - // Feeds the same active-extension authority (installed + - // activated extensions like `github`, `gmail`, MCP servers) - // production's `capability_wiring` folds into every refresh - // (`runtime/local_dev.rs:132-133`); `None` when this harness - // was built without `RebornServices` (mirrors the old - // `local_dev_active_extension_authority_for_test` early-return). - extension_management: self.reborn_services.as_ref().and_then(|services| { - ironclaw_reborn_composition::test_support::build_local_dev_extension_management_for_test( - services, - ) + let parts = ironclaw_reborn_composition::test_support::RefreshingCapabilityPortTestParts { + runtime: self.runtime.lock().unwrap().clone(), + run_context: run_context.clone(), + fallback_user_id: dispatch_user, + // All four mount views = this harness's single `mounts` view. + // Production splits skill/memory/system-extensions mounts off + // the local-dev workspace root, but this harness has ONE + // profile-built view and the pre-seam behavior was "every + // capability executes (and is granted) under `self.mounts` + // unless `capability_mount_overrides` says otherwise" — a + // `MountView::default()` here would instead OVERRIDE the + // memory/skill capability families down to an empty view via + // `build_inner`'s per-domain `with_capability_execution_mount` + // special-cases, silently blinding `builtin.memory_*`. + workspace_mounts: self.mounts.clone(), + skill_mounts: self.mounts.clone(), + memory_mounts: self.mounts.clone(), + system_extensions_lifecycle_mounts: self.mounts.clone(), + input_resolver, + result_writer, + milestone_sink: milestone_sink.clone() as Arc, + skill_activation_source: self.skill_activation_source.clone(), + project_service, + // result_read (durable tool-result projection seam, issue + // #5838): production always wires the run's session thread + // service into the synthetic `result_read` capability, so + // this is a required (non-`Option`) field. Harnesses that + // opted into `.with_durable_capability_io()` populate + // `durable_capability_io_thread_service` with the REAL group + // thread service; every other harness gets a fresh in-memory + // no-op service, mirroring the `project_service`/ + // `tool_permission_overrides` "no opinion -> default" pattern + // above -- `result_read` is simply never granted for those + // harnesses (not in `capability_ids`), so the default is + // never actually read. + thread_service: self + .durable_capability_io_thread_service + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| { + Arc::new(ironclaw_threads::InMemorySessionThreadService::default()) }), - outbound_preferences_facade, - outbound_delivery_target_set_requires_approval, - tool_permission_overrides, - auto_approve_settings, - persistent_approval_policies, - approval_requests, - capability_leases, - // `self.capability_mount_overrides` is the SAME per-capability - // mount-override list production's `skill_mounts`/`memory_mounts`/ - // `system_extensions_lifecycle_mounts` special-cases apply - // automatically for their own fixed capability-id lists; passing - // it here too (rather than splitting it across those three - // fields, which this harness has no per-domain tracking for) - // reaches the SAME final per-capability mount via the override - // map, applied after those defaults in `build_inner`. - capability_execution_mount_overrides: self - .capability_mount_overrides - .iter() - .cloned() - .collect(), - additional_provider_trust, - // Whole-set narrowing over the FULL granted-capability set to - // this harness's exhaustive `capability_ids` allowlist, - // including the empty case (zero grants). See - // `additional_capability_grants` doc for the invariant. - capability_id_filter: Some(self.capability_ids.iter().cloned().collect()), - additional_capability_grants, - }; + trajectory_observer: None, + // Feeds the same active-extension authority (installed + + // activated extensions like `github`, `gmail`, MCP servers) + // production's `capability_wiring` folds into every refresh + // (`runtime/local_dev.rs:132-133`); `None` when this harness + // was built without `RebornServices` (mirrors the old + // `local_dev_active_extension_authority_for_test` early-return). + extension_management: self.reborn_services.as_ref().and_then(|services| { + ironclaw_reborn_composition::test_support::build_extension_management_for_test( + services, + ) + }), + outbound_preferences_facade, + outbound_delivery_target_set_requires_approval, + tool_permission_overrides, + auto_approve_settings, + persistent_approval_policies, + approval_requests, + capability_leases, + // `self.capability_mount_overrides` is the SAME per-capability + // mount-override list production's `skill_mounts`/`memory_mounts`/ + // `system_extensions_lifecycle_mounts` special-cases apply + // automatically for their own fixed capability-id lists; passing + // it here too (rather than splitting it across those three + // fields, which this harness has no per-domain tracking for) + // reaches the SAME final per-capability mount via the override + // map, applied after those defaults in `build_inner`. + capability_execution_mount_overrides: self + .capability_mount_overrides + .iter() + .cloned() + .collect(), + additional_provider_trust, + // Whole-set narrowing over the FULL granted-capability set to + // this harness's exhaustive `capability_ids` allowlist, + // including the empty case (zero grants). See + // `additional_capability_grants` doc for the invariant. + capability_id_filter: Some(self.capability_ids.iter().cloned().collect()), + additional_capability_grants, + }; let port = ironclaw_reborn_composition::test_support::create_refreshing_capability_port_for_test( parts,