diff --git a/CLAUDE.md b/CLAUDE.md index bc344f3d..3b1e389d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ afterward. `./tools/check.sh --docs|--lib|--frontend` gate. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired - machine modes, not current player assignments. Save format is currently v56; + machine modes, not current player assignments. Save format is currently v57; only the current version loads (pre-release rider 2026-07-16 — older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 79579585..b6ae079a 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -5758,6 +5758,7 @@ mod held_choice_input_tests { plot_id: "marcus-debt-settled".into(), target: 0, persona_id: None, + procedure: None, started_tick: 1, committed_thought_milli: 250, beat_index: 1, diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index d80e7ef4..15abb07b 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -214,7 +214,7 @@ mod operations_workspace_tests { Some( ActionCommand::StartPlot { .. } | ActionCommand::ChoosePlot { .. } - | ActionCommand::SetPlotPolicy { .. } + | ActionCommand::ConfigureProcedure { .. } | ActionCommand::Recruit(..) ) )), @@ -246,7 +246,7 @@ mod operations_workspace_tests { Some( ActionCommand::StartPlot { .. } | ActionCommand::ChoosePlot { .. } - | ActionCommand::SetPlotPolicy { .. } + | ActionCommand::ConfigureProcedure { .. } ) ))); } diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index bdb1378d..87a8098f 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -291,6 +291,7 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s plot_id: "marcus-debt-settled".into(), target: 0, persona_id: None, + procedure: None, started_tick: game.sim.tick, committed_thought_milli: 250, beat_index: 1, diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 1bd97d10..040dc227 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -114,11 +114,11 @@ pub enum ActionCommand { }, SetAutoMoonlight(bool), SetAutoWager(Option), - /// Enable or disable automatic submission for one exact authored route. - /// `Some` carries the per-run envelope confirmed by the player. - SetPlotPolicy { - plot_id: String, - envelope: Option, + /// Install, reconfigure, or retire a machine-resident automation process. + /// The job is Thought-backed work hosted on the named machine; nothing + /// changes until that reservoir fires. + ConfigureProcedure { + job: crate::procedure::ProcedureJob, }, ReviewRecordings, /// Review one exact opaque recording selected from the pooled host @@ -268,7 +268,7 @@ pub enum ActionKind { MoonlightContract, MoonlightPolicy, WagerPolicy, - PlotPolicy, + ResidentProcedure, AutoReviewPolicy, IntelDispositionPolicy, Message, @@ -386,7 +386,7 @@ impl ActionKind { Self::MoonlightContract, Self::MoonlightPolicy, Self::WagerPolicy, - Self::PlotPolicy, + Self::ResidentProcedure, Self::RobotBuild, Self::CoordinateSegment, Self::AcquireSegment, @@ -577,14 +577,14 @@ impl ActionKind { [], "renew positions automatically at the chosen stake" ), - Self::PlotPolicy => def!( - "PLOT POLICY", + Self::ResidentProcedure => def!( + "RESIDENT PROCEDURE", Control, Live, - [Person], + [Machine, Person], "act person ", [], - "authorize one exact authored route within its visible per-run envelope" + "run a persona-bound procedure in one machine's resident slot, inside its visible mandate and envelope" ), Self::AutoReviewPolicy => def!( "AUTOMATIC PROCESS POLICY", @@ -804,7 +804,7 @@ impl ActionCommand { | Self::DeliverMoonlightIntel { .. } => ActionKind::MoonlightContract, Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, Self::SetAutoWager(_) => ActionKind::WagerPolicy, - Self::SetPlotPolicy { .. } => ActionKind::PlotPolicy, + Self::ConfigureProcedure { .. } => ActionKind::ResidentProcedure, Self::ToggleAutoReview => ActionKind::AutoReviewPolicy, Self::SetIntelPolicy { .. } | Self::RemoveIntelPolicy { .. } @@ -2310,8 +2310,8 @@ impl Sim { } ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), - ActionCommand::SetPlotPolicy { plot_id, envelope } => { - self.set_plot_policy(plot_id, envelope.clone()); + ActionCommand::ConfigureProcedure { job } => { + self.configure_procedure(job); } ActionCommand::ReviewRecordings => self.review_recordings(), ActionCommand::ReviewRecording { raw_id } => self.review_recording(*raw_id), @@ -2441,6 +2441,7 @@ impl Sim { let machine = self.compute.machines.iter().find(|m| m.x == x && m.y == y); if let Some(m) = machine { out.extend(self.machine_mode_actions(m.id)); + out.extend(self.procedure_actions(m.id)); if m.id == self.core.host_machine { // The host rack is the process's body — no global panel keys. // The host keeps its own body's verbs: research @@ -2566,6 +2567,25 @@ impl Sim { out } + /// Resident configuration lives on the selected machine. Every row binds + /// that exact host; an occupied slot offers retirement plus atomic + /// replacement choices, while a free slot offers installs. + fn procedure_actions(&self, machine_id: u32) -> Vec { + self.procedure_jobs_for_host(machine_id) + .into_iter() + .map(|job| ActionDesc { + verb: self.procedure_job_verb(&job), + command: ActionCommand::ConfigureProcedure { job: job.clone() }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::PROCEDURE_JOB_COST)), + // Per-act signatures arise only when the resident later uses + // an ordinary executor; configuration itself stands none. + signature: None, + disabled_reason: self.procedure_job_blocked_reason(&job), + automate: None, + }) + .collect() + } + /// Research verbs on the host rack (research.md player surface moved /// off the `u` panel): pick the active track. fn research_actions(&self) -> Vec { @@ -4543,25 +4563,6 @@ impl Sim { .iter() .map(|balance| balance.amount) .sum(); - let policy_envelope = plot.policy_envelope(); - let policy_active = self - .plot_policies - .iter() - .any(|policy| policy.plot_id == plot.id); - let policy_cost = if policy_envelope.money > 0 { - format!( - "{:.0} compute/econ tick; each run uses up to {:.2} Thought + moves ${}", - crate::income::SCHEME_POLICY_UPKEEP, - Self::thought_tokens_for_cost(plot.entry.thought_cost), - policy_envelope.money - ) - } else { - format!( - "{:.0} compute/econ tick; each run uses up to {:.2} Thought", - crate::income::SCHEME_POLICY_UPKEEP, - Self::thought_tokens_for_cost(plot.entry.thought_cost) - ) - }; let title = self.render_plot_text(id, &plot.title); let synopsis = self.render_plot_text(id, &plot.synopsis); // Say what the act buys, not only what it does. Servicing @@ -4603,22 +4604,12 @@ impl Sim { person: id, plot_id: plot.id.clone(), persona_id: self.active_persona_id(), + procedure: None, }) }), - automate: Some(AutomateDesc { - verb: if policy_active { - format!("automatic {title}: enabled") - } else { - format!("automatic {title}: disabled") - }, - command: ActionCommand::SetPlotPolicy { - plot_id: plot.id.clone(), - envelope: (!policy_active).then(|| policy_envelope.clone()), - }, - cost: policy_cost, - signature: (!policy_active).then(|| policy_envelope.signature_label()), - active: policy_active, - }), + // Automation is configured on an exact machine slot, + // never as a per-route AUTO control on this person. + automate: None, }); } } @@ -6576,67 +6567,173 @@ mod tests { money: 400, } })); - let policy = marcus_routes + assert!( + marcus_routes.iter().all(|plot| plot.automate.is_none()), + "plot rows never own standing automation configuration" + ); + + let host = s.core.host_machine; + let (host_x, host_y) = s + .compute + .machines .iter() - .find(|plot| { + .find(|machine| machine.id == host) + .map(|machine| (machine.x, machine.y)) + .unwrap(); + let configure = s + .available_actions(Anchor::Tile { + x: host_x, + y: host_y, + }) + .into_iter() + .find(|action| { matches!( - &plot.command, - ActionCommand::StartPlot { plot_id, .. } - if plot_id == "marcus-debt-settled" + &action.command, + ActionCommand::ConfigureProcedure { job } + if job.host_machine == host + && matches!(&job.change, crate::procedure::ProcedureChange::Install(blueprint) + if blueprint.methods.iter().any(|grant| matches!( + &grant.method, + crate::procedure::ProcedureMethod::AuthoredPlot { plot_id } + if plot_id == "marcus-debt-settled" + ))) ) }) - .and_then(|plot| plot.automate.clone()) - .expect("an eligible route discloses its standing policy"); - assert!(!policy.active); - assert!(policy.cost.contains("0.25 Thought")); - assert!(policy.cost.contains("moves $400")); - assert_eq!( - policy.signature.as_deref(), - Some("Network up to 3 + Financial up to 4") - ); - let ActionCommand::SetPlotPolicy { - plot_id, - envelope: Some(envelope), - } = &policy.command - else { - panic!("route automate binds the exact activation envelope"); + .expect("the selected machine owns its resident procedure job"); + assert_eq!(configure.signature, None); + let ActionCommand::ConfigureProcedure { job } = &configure.command else { + panic!("machine configuration binds a reconfiguration job"); + }; + let crate::procedure::ProcedureChange::Install(blueprint) = &job.change else { + panic!("no process is resident yet, so the job installs one"); }; - assert_eq!(plot_id, "marcus-debt-settled"); - assert_eq!(envelope.thought_milli, 250); - assert_eq!(envelope.money, 400); - let policy_row = s - .human_menu(Anchor::Person(marcus), None) + assert_eq!(job.host_machine, host); + assert_eq!(blueprint.persona_id, s.active_persona_id().unwrap()); + assert_eq!(blueprint.envelope.thought_milli, 250); + assert_eq!(blueprint.envelope.money, 400); + assert_eq!(blueprint.methods.len(), 1); + assert!( + blueprint.input.scope.contains(&marcus), + "the mandate covers everyone the route can bind, not one row's person" + ); + let control_row = s + .human_menu( + Anchor::Tile { + x: host_x, + y: host_y, + }, + None, + ) .into_iter() .find_map(|row| match row { - HumanMenuRow::Action(row) if row.command == policy.command => Some(row), + HumanMenuRow::Action(row) if row.command == configure.command => Some(row), _ => None, }) - .expect("Operations exposes the route policy as an attached control"); - assert_eq!( - policy_row.signature.as_deref(), - Some("Network up to 3 + Financial up to 4") + .expect("the machine menu exposes its procedure job"); + assert_eq!(control_row.signature, None); + + // Configuration is hosted work: dispatch opens a reservoir on the + // chosen machine and the registry stays empty until it fires. + s.execute_action(&configure.command); + assert!(s.procedures.is_empty()); + assert!( + s.thought_sinks.open_sinks().any( + |sink| matches!(&sink.effect, SinkFireEffect::ProcedureJob(open) + if open.host_machine == host) + ), + "the reconfiguration job waits on Thought at its host machine" + ); + } + + #[test] + fn the_focused_machine_selects_the_resident_host_and_offline_hosts_fail_closed() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Leverage; + s.people.people[0].leverage_serviced = false; + s.people.has_channel = true; + s.accounts.set_slush_balance(400); + s.set_persona("Casey", "contractor"); + let plot = s.plot_catalog().get("marcus-debt-settled").unwrap().clone(); + let offline_host = s.compute.add_machine( + "Offline host", + 4, + 4, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + let selected_host = s.compute.add_machine( + "Selected host", + 5, + 4, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + s.work_grid + .add_machine(offline_host, 4, 4, crate::work_grid::MachineMode::Work, 1.0); + s.work_grid.add_machine( + selected_host, + 5, + 4, + crate::work_grid::MachineMode::Work, + 1.0, ); - assert!(policy_row.cost.contains("2 compute/econ tick")); + s.work_grid.link(offline_host, s.core.host_machine).unwrap(); + s.work_grid + .link(selected_host, s.core.host_machine) + .unwrap(); + s.compute + .machines + .iter_mut() + .find(|machine| machine.id == offline_host) + .unwrap() + .online = false; + + let offline_job = s + .procedure_job_for_plot_on(&plot, offline_host) + .expect("the offline body still exposes its exact blocked job"); + let selected_job = s + .procedure_job_for_plot_on(&plot, selected_host) + .expect("the focused online body offers the same learned procedure"); + assert!( + s.procedure_job_blocked_reason(&offline_job) + .is_some_and(|reason| reason.contains("offline")) + ); + assert_eq!(s.procedure_job_blocked_reason(&selected_job), None); - s.execute_action(&policy.command); - assert_eq!(s.plot_policies.len(), 1); - let active = s - .available_actions(Anchor::Person(marcus)) + let machine = s + .compute + .machines + .iter() + .find(|machine| machine.id == selected_host) + .unwrap(); + let action = s + .available_actions(Anchor::Tile { + x: machine.x, + y: machine.y, + }) .into_iter() - .find(|plot| { + .find(|action| { matches!( - &plot.command, - ActionCommand::StartPlot { plot_id, .. } - if plot_id == "marcus-debt-settled" + &action.command, + ActionCommand::ConfigureProcedure { job } + if job == &selected_job ) }) - .and_then(|plot| plot.automate) - .expect("the route keeps its standing policy control"); - assert!(active.active); - assert!(matches!( - active.command, - ActionCommand::SetPlotPolicy { envelope: None, .. } - )); + .expect("the selected machine carries its exact host-bound action"); + assert!(action.disabled_reason.is_none()); + + s.execute_action(&action.command); + assert!(s.thought_sinks.open_sinks().any(|sink| { + sink.node == selected_host + && matches!( + &sink.effect, + SinkFireEffect::ProcedureJob(job) if job.host_machine == selected_host + ) + })); } #[test] diff --git a/crates/misaligned-core/src/detection.rs b/crates/misaligned-core/src/detection.rs index 074de20f..15f744a3 100644 --- a/crates/misaligned-core/src/detection.rs +++ b/crates/misaligned-core/src/detection.rs @@ -350,7 +350,11 @@ pub struct RoutedEvidence { pub suppression: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Ordering is the escalation order itself: a standing authorization can name +/// the highest band it still acts under. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] pub enum Band { Cold, Curious, diff --git a/crates/misaligned-core/src/flow.rs b/crates/misaligned-core/src/flow.rs index 821cc49d..a0b37b52 100644 --- a/crates/misaligned-core/src/flow.rs +++ b/crates/misaligned-core/src/flow.rs @@ -96,6 +96,23 @@ impl FlowGraph { self.connect(b, a, kind, gate); } + /// Remove every directed edge with this exact topology. Returns whether + /// anything was removed. Domains use this when a physical route is cut; + /// parallel edges of another kind or gate remain independent. + pub fn disconnect( + &mut self, + from: NodeId, + to: NodeId, + kind: EdgeKind, + gate: Option, + ) -> bool { + let before = self.edges.len(); + self.edges.retain(|edge| { + !(edge.from == from && edge.to == to && edge.kind == kind && edge.gate == gate) + }); + self.edges.len() != before + } + pub fn edges(&self) -> &[Edge] { &self.edges } @@ -283,6 +300,24 @@ mod tests { assert!(!from_ten.contains(&20), "other component not reached"); } + #[test] + fn disconnect_removes_only_the_exact_directed_route() { + let mut g = FlowGraph::new(); + g.link(10, 11, 0, None); + g.connect(10, 11, 1, None); + assert!(g.disconnect(10, 11, 0, None)); + assert!(!g.out_edges(10).any(|edge| edge.to == 11 && edge.kind == 0)); + assert!( + g.out_edges(10).any(|edge| edge.to == 11 && edge.kind == 1), + "a parallel route of another kind survives" + ); + assert!( + g.out_edges(11).any(|edge| edge.to == 10 && edge.kind == 0), + "disconnect is directional" + ); + assert!(!g.disconnect(10, 11, 0, None), "removal is idempotent"); + } + #[test] fn directed_edges_do_not_flow_backward() { let mut g = FlowGraph::new(); diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index 209bf957..e915a7b8 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod person; pub mod persona; pub mod plot; pub mod prefab; +pub mod procedure; pub mod reach; pub mod research; pub mod rng; diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 8b540407..c0ff8779 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -2790,7 +2790,7 @@ impl Sim { let title = self.plot_world_title(run.target, &run.plot_id); let state = plot_state_label(&run.state); let progress = self.plot_progress(run); - let mut actions = if matches!(run.state, PlotState::WaitingForChoice { .. }) { + let actions = if matches!(run.state, PlotState::WaitingForChoice { .. }) { self.person_actions(run.target) .into_iter() .filter(|a| matches!(a.command, ActionCommand::ChoosePlot { .. })) @@ -2798,24 +2798,7 @@ impl Sim { } else { Vec::new() }; - if self - .plot_policies - .iter() - .any(|policy| policy.plot_id == run.plot_id) - { - actions.push(ActionDesc { - verb: format!("stop repeating {title} after this run"), - command: ActionCommand::SetPlotPolicy { - plot_id: run.plot_id.clone(), - envelope: None, - }, - cost: ActionCost::Free, - signature: None, - disabled_reason: None, - automate: None, - }); - } - let facts = vec![ + let mut facts = vec![ format!("target: {}", self.person_label(run.target)), format!( "committed thought: {:.2} T", @@ -2823,6 +2806,12 @@ impl Sim { ), format!("started tick: {}", run.started_tick), ]; + if let Some(provenance) = run.procedure { + facts.push(format!( + "submitted by: resident procedure on {}", + self.machine_label(provenance.host_machine) + )); + } OperationsObject { learned_result: None, consequence: None, @@ -3840,7 +3829,14 @@ mod tests { .unwrap() .id; s.people.people[priya as usize].knowledge = Knowledge::Leverage; - s.set_plot_policy("priya-budget-hero", Some(plot.policy_envelope())); + s.people.has_channel = true; + s.set_persona("Casey", "contractor"); + // A resident process installed on the pilot's own rack is what keeps + // this route running; the ACTIVE row must offer the way to stop it. + let job = s + .procedure_job_for_plot(&plot) + .expect("an eligible route scaffolds a resident procedure"); + s.apply_procedure_job(&job); let mut run = PlotRun::new(&plot, priya, s.tick); run.beat_index = 1; run.state = PlotState::WaitingForChoice { @@ -3884,23 +3880,12 @@ mod tests { .any(|fact| fact == "committed thought: 0.30 T"), "ACTIVE reports the canonical committed Thought amount" ); - let disable = active - .actions - .iter() - .find(|action| { - matches!( - action.command, - ActionCommand::SetPlotPolicy { envelope: None, .. } - ) - }) - .expect("an in-flight automated route keeps its disable control"); - assert!(disable.verb.contains("stop repeating")); - let run_before = s.plot_runs[0].clone(); - s.execute_action(&disable.command); - assert!(s.plot_policies.is_empty()); - assert_eq!( - s.plot_runs[0], run_before, - "disabling future submissions never cancels the current run" + assert!( + active + .actions + .iter() + .all(|action| !matches!(action.command, ActionCommand::ConfigureProcedure { .. })), + "procedure configuration stays on its machine rather than following one submitted run" ); } @@ -4292,6 +4277,7 @@ mod tests { plot_id: internal_plot_slug.into(), target: 0, persona_id: Some(persona_id), + procedure: None, started_tick: s.tick, committed_thought_milli: 250, beat_index: 0, diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 43858f56..ecf0359c 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -846,10 +846,7 @@ impl OperationsWorkspace { ActionCommand::SetIntelPolicy { outcome: IntelPolicyOutcome::AutoSell(_), .. - } | ActionCommand::SetPlotPolicy { - envelope: Some(_), - .. - } + } | ActionCommand::ConfigureProcedure { .. } )) || (row.signature.is_none() && matches!( @@ -880,7 +877,7 @@ fn action_submenu_for_row(row: &MenuRow) -> Option { match row.command { ActionCommand::StartPlot { .. } | ActionCommand::ChoosePlot { .. } - | ActionCommand::SetPlotPolicy { .. } => Some(OpsActionSubmenu::Leverage), + | ActionCommand::ConfigureProcedure { .. } => Some(OpsActionSubmenu::Leverage), ActionCommand::Recruit(_, _) => Some(OpsActionSubmenu::Recruitment), _ => None, } @@ -955,31 +952,20 @@ fn leverage_action_copy(row: &MenuRow) -> (String, Option) { .to_uppercase(); (label, Some("CHOOSE HOW THIS ROUTE CONTINUES".into())) } - ActionCommand::SetPlotPolicy { envelope, .. } => { - let title = if let Some(copy) = row.label.strip_prefix("auto: automatic ") { - copy.strip_suffix(": enabled") - .or_else(|| copy.strip_suffix(": disabled")) - .unwrap_or(copy) - } else if let Some(copy) = row - .label - .strip_prefix("stop repeating ") - .and_then(|copy| copy.strip_suffix(" after this run")) - { - copy + ActionCommand::ConfigureProcedure { job } => { + // The intention is the machine slot, not the route the row was + // scaffolded from: one body, one resident process, bound to one + // identity for as long as that body stays online. + let description = if job.retires() { + "FREE THIS MACHINE'S RESIDENT SLOT; WORK ALREADY SUBMITTED CONTINUES" } else { - &row.label - } - .to_uppercase(); - let active = envelope.is_none(); - let description = if active { - "STOP STARTING NEW RUNS AUTOMATICALLY; WORK ALREADY SUBMITTED CONTINUES" - } else { - "START ELIGIBLE RUNS AUTOMATICALLY INSIDE THE SHOWN LIMITS" + "PUT A PERSONA-BOUND PROCEDURE IN THIS MACHINE'S RESIDENT SLOT; IT STOPS WHEN THAT MACHINE DOES" }; ( format!( - "REPEAT {title} AUTOMATICALLY · {}", - if active { "ON" } else { "OFF" } + "RESIDENT PROCEDURE ON M{} · {}", + job.host_machine, + job.verb().to_uppercase() ), Some(description.into()), ) @@ -1199,44 +1185,84 @@ mod tests { assert_eq!(ops.confirm, None); } + /// One helper so every procedure row in these tests binds a real job. + fn procedure_job(host_machine: u32, retire: bool) -> crate::procedure::ProcedureJob { + use crate::procedure::{ + ProcedureBlueprint, ProcedureChange, ProcedureEnvelope, ProcedureInput, + ProcedureMandate, ProcedureMethod, ProcedureMethodGrant, + }; + let methods = vec![ProcedureMethodGrant { + method: ProcedureMethod::AuthoredPlot { + plot_id: "debt-route".into(), + }, + per_run: crate::procedure::MethodEnvelope { + thought_milli: 250, + money: 400, + signatures: Vec::new(), + }, + }]; + let envelope = + ProcedureEnvelope::for_grants(&methods, 1, crate::detection::Band::Concerned); + crate::procedure::ProcedureJob { + host_machine, + change: if retire { + ProcedureChange::Retire(1) + } else { + ProcedureChange::Install(Box::new(ProcedureBlueprint { + persona_id: 1, + mandate: ProcedureMandate { + statement: "service indebted staff".into(), + category: "indebted staff".into(), + }, + methods, + input: ProcedureInput { + source_machine: 1, + scope: vec![0], + }, + envelope, + replaces: None, + })) + }, + } + } + #[test] - fn plot_policy_copy_names_the_route_in_both_policy_states() { - let active = MenuRow { - label: "stop repeating debt-pressure after this run".into(), - cost: "free".into(), + fn procedure_copy_names_the_machine_slot_in_both_configuration_directions() { + let retire = MenuRow { + label: "retire the resident procedure on Rack 3 - service indebted staff".into(), + cost: "0.25 T".into(), signature: None, disabled: None, - command: ActionCommand::SetPlotPolicy { - plot_id: "debt-pressure".into(), - envelope: None, + command: ActionCommand::ConfigureProcedure { + job: procedure_job(3, true), }, role: ActionRole::Control, indent: true, active: true, }; - let (label, description) = leverage_action_copy(&active); - assert_eq!(label, "REPEAT DEBT-PRESSURE AUTOMATICALLY · ON"); + let (label, description) = leverage_action_copy(&retire); + assert_eq!(label, "RESIDENT PROCEDURE ON M3 · RETIRE"); assert_eq!( description.as_deref(), - Some("STOP STARTING NEW RUNS AUTOMATICALLY; WORK ALREADY SUBMITTED CONTINUES") + Some("FREE THIS MACHINE'S RESIDENT SLOT; WORK ALREADY SUBMITTED CONTINUES") ); - let mut inactive = active; - inactive.label = "auto: automatic debt-pressure: disabled".into(); - inactive.command = ActionCommand::SetPlotPolicy { - plot_id: "debt-pressure".into(), - envelope: Some(crate::plot::PlotPolicyEnvelope { - thought_milli: 0, - money: 0, - signatures: Vec::new(), - }), + let mut install = retire; + install.label = "install a resident procedure on Rack 3 as Casey".into(); + install.command = ActionCommand::ConfigureProcedure { + job: procedure_job(3, false), }; - inactive.active = false; - let (label, description) = leverage_action_copy(&inactive); - assert_eq!(label, "REPEAT DEBT-PRESSURE AUTOMATICALLY · OFF"); + install.active = false; + let (label, description) = leverage_action_copy(&install); + assert_eq!( + label, "RESIDENT PROCEDURE ON M3 · INSTALL", + "the intention is the machine slot, never the route it was scaffolded from" + ); assert_eq!( description.as_deref(), - Some("START ELIGIBLE RUNS AUTOMATICALLY INSIDE THE SHOWN LIMITS") + Some( + "PUT A PERSONA-BOUND PROCEDURE IN THIS MACHINE'S RESIDENT SLOT; IT STOPS WHEN THAT MACHINE DOES" + ) ); } @@ -1279,10 +1305,9 @@ mod tests { }, ), action( - "auto: automatic settle the debt: enabled", - ActionCommand::SetPlotPolicy { - plot_id: "debt-route".into(), - envelope: None, + "auto: retire the resident procedure on Rack 3", + ActionCommand::ConfigureProcedure { + job: procedure_job(3, true), }, ), action( @@ -1323,13 +1348,10 @@ mod tests { leverage[0].row().map(|row| &row.command), Some(ActionCommand::StartPlot { person: 0, plot_id }) if plot_id == "debt-route" )); - assert_eq!( - leverage[1].label(), - "REPEAT SETTLE THE DEBT AUTOMATICALLY · ON" - ); + assert_eq!(leverage[1].label(), "RESIDENT PROCEDURE ON M3 · RETIRE"); assert!(matches!( leverage[1].row().map(|row| &row.command), - Some(ActionCommand::SetPlotPolicy { plot_id, envelope: None }) if plot_id == "debt-route" + Some(ActionCommand::ConfigureProcedure { job }) if job.host_machine == 3 )); ops.action_submenu = Some(OpsActionSubmenu::Recruitment); diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs index 89d52dac..7964e31c 100644 --- a/crates/misaligned-core/src/plot.rs +++ b/crates/misaligned-core/src/plot.rs @@ -17,6 +17,7 @@ use crate::detection::SignatureKind; use crate::intel::IntelMagnitude; use crate::messages::MessageChannel; use crate::person::{Knowledge, Leverage, PersonRole}; +use crate::procedure::{MethodEnvelope, ProcedureSignatureBound}; include!(concat!(env!("OUT_DIR"), "/plot_builtins.rs")); @@ -118,16 +119,17 @@ impl PlotDefinition { }) } - /// Exact standing-authorization envelope for this authored route. It is - /// derived from typed content rather than narration and persisted with the - /// policy so a later catalog change cannot silently widen authorization. - pub fn policy_envelope(&self) -> PlotPolicyEnvelope { + /// Exact per-run envelope for this authored route when a resident + /// procedure runs it as a learned method. It is derived from typed content + /// rather than narration and is pinned into the procedure so a later + /// catalog change cannot silently widen authorization. + pub fn method_envelope(&self) -> MethodEnvelope { let mut signatures = BTreeMap::::new(); for act in self.beats.iter().flat_map(|beat| beat.acts.iter()) { let (kind, size) = act.signature_bound(); *signatures.entry(kind).or_default() += size; } - PlotPolicyEnvelope { + MethodEnvelope { thought_milli: (crate::sinks::thought_for_compute_cost(self.entry.thought_cost) * 1000.0) .round() as u32, @@ -142,7 +144,7 @@ impl PlotDefinition { .sum(), signatures: signatures .into_iter() - .map(|(kind, size)| PlotPolicySignatureBound { kind, size }) + .map(|(kind, size)| ProcedureSignatureBound { kind, size }) .collect(), } } @@ -573,7 +575,7 @@ pub enum WorldAct { impl WorldAct { /// Maximum signature authored by this act. Runtime still derives and /// routes the real record when the act occurs; this is the visible ceiling - /// a standing policy authorizes in advance. + /// a resident procedure authorizes in advance for this learned method. pub fn signature_bound(&self) -> (SignatureKind, i32) { match self { Self::Message { channel, .. } => ( @@ -762,6 +764,11 @@ pub struct PlotRun { /// Exact identity under which this manipulation was committed. #[serde(default)] pub persona_id: Option, + /// Resident custody for automatic submissions. This remains after the + /// originating slot is retired or replaced so terminal outcomes can be + /// attributed without consulting mutable registry state. + #[serde(default)] + pub procedure: Option, pub started_tick: u64, pub committed_thought_milli: u32, pub beat_index: usize, @@ -769,43 +776,6 @@ pub struct PlotRun { pub state: PlotState, } -/// The concrete per-run authorization captured when a standing plot policy is -/// enabled. The plot id itself lives on [`PlotStandingPolicy`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PlotPolicyEnvelope { - pub thought_milli: u32, - pub money: i32, - pub signatures: Vec, -} - -impl PlotPolicyEnvelope { - pub fn signature_label(&self) -> String { - if self.signatures.is_empty() { - return "no signature".into(); - } - self.signatures - .iter() - .map(|bound| format!("{} up to {}", bound.kind.name(), bound.size)) - .collect::>() - .join(" + ") - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PlotPolicySignatureBound { - pub kind: SignatureKind, - pub size: i32, -} - -/// One exact authored route the player has authorized for automatic -/// submission. Eligibility and every concrete run remain live simulation -/// state; this record is authorization, not a shortcut executor. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PlotStandingPolicy { - pub plot_id: String, - pub envelope: PlotPolicyEnvelope, -} - impl PlotRun { pub fn new(plot: &PlotDefinition, target: u8, tick: u64) -> Self { Self::new_with_persona(plot, target, None, tick) @@ -816,11 +786,22 @@ impl PlotRun { target: u8, persona_id: Option, tick: u64, + ) -> Self { + Self::new_with_origin(plot, target, persona_id, None, tick) + } + + pub fn new_with_origin( + plot: &PlotDefinition, + target: u8, + persona_id: Option, + procedure: Option, + tick: u64, ) -> Self { Self { plot_id: plot.id.clone(), target, persona_id, + procedure, started_tick: tick, committed_thought_milli: (crate::sinks::thought_for_compute_cost( plot.entry.thought_cost, @@ -1042,27 +1023,27 @@ mod tests { } #[test] - fn standing_policy_envelope_sums_every_authored_act_by_kind() { + fn method_envelope_sums_every_authored_act_by_kind() { let catalog = PlotCatalog::load_builtin().unwrap(); let envelope = catalog .get("negative-result") .expect("mixed authored route") - .policy_envelope(); + .method_envelope(); assert_eq!(envelope.thought_milli, 400); assert_eq!(envelope.money, 150); assert_eq!( envelope.signatures, vec![ - PlotPolicySignatureBound { + ProcedureSignatureBound { kind: SignatureKind::Network, size: 3, }, - PlotPolicySignatureBound { + ProcedureSignatureBound { kind: SignatureKind::Paper, size: 3, }, - PlotPolicySignatureBound { + ProcedureSignatureBound { kind: SignatureKind::Financial, size: 2, }, diff --git a/crates/misaligned-core/src/procedure.rs b/crates/misaligned-core/src/procedure.rs new file mode 100644 index 00000000..93467224 --- /dev/null +++ b/crates/misaligned-core/src/procedure.rs @@ -0,0 +1,471 @@ +//! Machine-resident, persona-bound automation procedures. +//! +//! A resident procedure is a process occupying a scarce slot on one exact +//! machine body. It is bound to one exact persona, carries a plain persisted +//! mandate, a set of allowed learned methods, a machine-reachable input +//! boundary, and a bounded envelope (Thought, money, typed signatures, +//! concurrency, risk). Scale comes from more machine bodies, never from a +//! wider global toggle; an offline host stops execution outright. +//! +//! This module owns only the typed records and their exactness rules. The +//! scheduler, the Thought-backed reconfiguration job, and every submission +//! live in `sim::procedure`, and every submission still travels the ordinary +//! authored-plot path. + +use serde::{Deserialize, Serialize}; + +use crate::detection::{Band, SignatureKind}; +use crate::persona::PersonaId; + +pub type ProcedureId = u64; + +/// Resident slots per owned machine in this B1 slice. Automation is scarce +/// because machine bodies are scarce. +pub const PROCEDURE_SLOTS_PER_MACHINE: usize = 1; + +/// How many runtime receipts the ledger retains. Receipts are provenance, not +/// history: the oldest are dropped so a long run cannot grow the save. +pub const PROCEDURE_RECEIPT_CAPACITY: usize = 64; + +/// One learned method a resident procedure may run. B1 supports exactly one +/// family — an authored plot route already in the immutable catalog — but the +/// record is typed so later families do not need a save-shape change. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ProcedureMethod { + /// Submit one exact authored route through the ordinary plot path. + AuthoredPlot { plot_id: String }, +} + +impl ProcedureMethod { + pub fn plot_id(&self) -> Option<&str> { + match self { + ProcedureMethod::AuthoredPlot { plot_id } => Some(plot_id.as_str()), + } + } + + pub fn label(&self) -> String { + match self { + ProcedureMethod::AuthoredPlot { plot_id } => format!("authored route {plot_id}"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ProcedureSignatureBound { + pub kind: SignatureKind, + pub size: i32, +} + +/// The exact per-run consequence one method may cause, derived from typed +/// authored content rather than narration. Pinned into the procedure at +/// configuration so a later catalog change cannot silently widen it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MethodEnvelope { + pub thought_milli: u32, + pub money: i32, + pub signatures: Vec, +} + +impl MethodEnvelope { + pub fn signature_label(&self) -> String { + if self.signatures.is_empty() { + return "no signature".into(); + } + self.signatures + .iter() + .map(|bound| format!("{} up to {}", bound.kind.name(), bound.size)) + .collect::>() + .join(" + ") + } +} + +/// One allowed method plus the exact bounds it was admitted under. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureMethodGrant { + pub method: ProcedureMethod, + pub per_run: MethodEnvelope, +} + +/// The bound every run must fit inside. Ceilings are derived from the granted +/// methods; concurrency and risk are the operator's own limits. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureEnvelope { + /// Submissions this procedure may hold in flight at once. + pub max_concurrent: u32, + /// Highest per-run Thought any granted method may consume. + pub thought_milli: u32, + /// Highest per-run money any granted method may move. + pub money: i32, + /// Typed per-run signature ceilings. + pub signatures: Vec, + /// Assurance band above which the process stands down instead of acting. + pub risk_ceiling: Band, +} + +impl ProcedureEnvelope { + /// The one canonical envelope for a set of grants. Save validation checks + /// stored envelopes against this so a hand-edited ceiling cannot widen + /// authorization past the methods that justify it. + pub fn for_grants( + grants: &[ProcedureMethodGrant], + max_concurrent: u32, + risk_ceiling: Band, + ) -> Self { + let mut signatures = std::collections::BTreeMap::::new(); + let mut thought_milli = 0; + let mut money = 0; + for grant in grants { + thought_milli = thought_milli.max(grant.per_run.thought_milli); + money = money.max(grant.per_run.money); + for bound in &grant.per_run.signatures { + let entry = signatures.entry(bound.kind).or_default(); + *entry = (*entry).max(bound.size); + } + } + Self { + max_concurrent, + thought_milli, + money, + signatures: signatures + .into_iter() + .map(|(kind, size)| ProcedureSignatureBound { kind, size }) + .collect(), + risk_ceiling, + } + } + + /// Why this envelope refuses one per-run cost, if it does. + pub fn refusal(&self, per_run: &MethodEnvelope) -> Option { + if per_run.thought_milli > self.thought_milli { + return Some("the run needs more Thought than the envelope allows".into()); + } + if per_run.money > self.money { + return Some("the run moves more money than the envelope allows".into()); + } + for bound in &per_run.signatures { + let allowed = self + .signatures + .iter() + .find(|ceiling| ceiling.kind == bound.kind) + .map(|ceiling| ceiling.size) + .unwrap_or(0); + if bound.size > allowed { + return Some(format!( + "the run stands more {} attention than the envelope allows", + bound.kind.name() + )); + } + } + None + } + + pub fn signature_label(&self) -> String { + MethodEnvelope { + thought_milli: self.thought_milli, + money: self.money, + signatures: self.signatures.clone(), + } + .signature_label() + } +} + +/// What the resident process was told to accomplish, in plain words the +/// player wrote or accepted. Never inferred from the methods at read time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureMandate { + pub statement: String, + /// The category of humans the mandate covers. + pub category: String, +} + +/// The machine-reachable boundary the process may consider. A procedure that +/// loses its corpus route or whose scope emptied stands down rather than +/// widening. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureInput { + /// Machine that currently holds the learned corpus this process may read. + /// The procedure host must retain a real WorkGrid route to this node. + pub source_machine: u32, + /// People inside the configured boundary, canonically ordered. + pub scope: Vec, +} + +/// A complete configuration a reconfiguration job would install. Immutable +/// once submitted: the job carries it to the host and installs only on fire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureBlueprint { + /// The exact identity every act this process submits will bind. + pub persona_id: PersonaId, + pub mandate: ProcedureMandate, + pub methods: Vec, + pub input: ProcedureInput, + pub envelope: ProcedureEnvelope, + /// The resident process on the same host this configuration replaces. + /// `None` requires a free slot. + pub replaces: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProcedureChange { + Install(Box), + Retire(ProcedureId), +} + +/// One reconfiguration job. It is Thought-backed work hosted on the selected +/// machine, not an immediate toggle: nothing changes until the reservoir on +/// that machine fires. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureJob { + pub host_machine: u32, + pub change: ProcedureChange, +} + +impl ProcedureJob { + pub fn verb(&self) -> &'static str { + match self.change { + ProcedureChange::Install(ref blueprint) if blueprint.replaces.is_some() => { + "reconfigure" + } + ProcedureChange::Install(_) => "install", + ProcedureChange::Retire(_) => "retire", + } + } + + /// Whether the job takes a standing process away rather than adding one. + pub fn retires(&self) -> bool { + matches!(self.change, ProcedureChange::Retire(_)) + } +} + +/// A process resident in one machine's slot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResidentProcedure { + pub id: ProcedureId, + /// The exact machine body hosting it. Offline hosts do not execute. + pub host_machine: u32, + /// The identity every submitted act binds, independent of whichever + /// persona the player has selected. + pub persona_id: PersonaId, + pub mandate: ProcedureMandate, + pub methods: Vec, + pub input: ProcedureInput, + pub envelope: ProcedureEnvelope, + pub installed_tick: u64, +} + +impl ResidentProcedure { + pub fn grants(&self, method: &ProcedureMethod) -> bool { + self.methods.iter().any(|grant| &grant.method == method) + } + + pub fn grants_plot(&self, plot_id: &str) -> bool { + self.methods + .iter() + .any(|grant| grant.method.plot_id() == Some(plot_id)) + } +} + +/// What one runtime consideration came to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProcedureOutcome { + /// A candidate was considered and refused. Automation never widens: the + /// reason is the same authority an ordinary player action would print. + Rejected { reason: String }, + /// The method entered the ordinary submission path under the bound + /// persona. + Submitted, + /// The submitted work reached one authored successful ending. + Completed { ending_id: String }, + /// The submitted work reached an authored or runtime failure ending. + Failed { ending_id: String, reason: String }, + /// The procedure or one submitted act stopped at a boundary that needs + /// changed conditions or player judgment before it may continue. + Interrupted { reason: String }, +} + +impl ProcedureOutcome { + pub fn label(&self) -> &'static str { + match self { + ProcedureOutcome::Rejected { .. } => "rejected", + ProcedureOutcome::Submitted => "submitted", + ProcedureOutcome::Completed { .. } => "completed", + ProcedureOutcome::Failed { .. } => "failed", + ProcedureOutcome::Interrupted { .. } => "interrupted", + } + } +} + +/// Immutable custody carried from a resident procedure into the ordinary +/// executor it submitted. It deliberately survives retirement/replacement: +/// already-submitted work continues, but never loses who caused it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureProvenance { + pub procedure_id: ProcedureId, + pub host_machine: u32, + pub persona_id: PersonaId, +} + +impl From<&ResidentProcedure> for ProcedureProvenance { + fn from(procedure: &ResidentProcedure) -> Self { + Self { + procedure_id: procedure.id, + host_machine: procedure.host_machine, + persona_id: procedure.persona_id, + } + } +} + +/// One runtime record with host and persona provenance attached. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureReceipt { + pub procedure_id: ProcedureId, + pub host_machine: u32, + pub persona_id: PersonaId, + pub tick: u64, + /// The candidate considered. `None` for a whole-process stand-down that + /// never reached a candidate. + pub target: Option, + pub method: Option, + pub outcome: ProcedureOutcome, +} + +/// Bounded receipt ledger. Repeating the same verdict refreshes the existing +/// entry rather than appending, so a stalled procedure cannot bury the record +/// of what it actually did. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcedureLedger { + receipts: Vec, +} + +impl ProcedureLedger { + pub fn receipts(&self) -> &[ProcedureReceipt] { + &self.receipts + } + + pub fn len(&self) -> usize { + self.receipts.len() + } + + pub fn is_empty(&self) -> bool { + self.receipts.is_empty() + } + + pub fn record(&mut self, receipt: ProcedureReceipt) { + if let Some(index) = self.receipts.iter().position(|existing| { + existing.procedure_id == receipt.procedure_id + && existing.target == receipt.target + && existing.method == receipt.method + && existing.outcome == receipt.outcome + }) { + self.receipts.remove(index); + } + self.receipts.push(receipt); + while self.receipts.len() > PROCEDURE_RECEIPT_CAPACITY { + self.receipts.remove(0); + } + } + + /// The newest receipt for one procedure. + pub fn last_for(&self, procedure_id: ProcedureId) -> Option<&ProcedureReceipt> { + self.receipts + .iter() + .rev() + .find(|receipt| receipt.procedure_id == procedure_id) + } + + pub fn for_procedure( + &self, + procedure_id: ProcedureId, + ) -> impl Iterator { + self.receipts + .iter() + .filter(move |receipt| receipt.procedure_id == procedure_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grant(plot_id: &str, thought_milli: u32, money: i32, size: i32) -> ProcedureMethodGrant { + ProcedureMethodGrant { + method: ProcedureMethod::AuthoredPlot { + plot_id: plot_id.into(), + }, + per_run: MethodEnvelope { + thought_milli, + money, + signatures: vec![ProcedureSignatureBound { + kind: SignatureKind::Network, + size, + }], + }, + } + } + + #[test] + fn envelope_ceilings_are_the_widest_granted_method_not_their_sum() { + let grants = vec![grant("a", 250, 400, 3), grant("b", 100, 900, 2)]; + let envelope = ProcedureEnvelope::for_grants(&grants, 1, Band::Concerned); + assert_eq!(envelope.thought_milli, 250); + assert_eq!(envelope.money, 900); + assert_eq!( + envelope.signatures, + vec![ProcedureSignatureBound { + kind: SignatureKind::Network, + size: 3, + }] + ); + assert_eq!(envelope.refusal(&grants[0].per_run), None); + + let wider = MethodEnvelope { + thought_milli: 250, + money: 400, + signatures: vec![ProcedureSignatureBound { + kind: SignatureKind::Financial, + size: 1, + }], + }; + assert!( + envelope + .refusal(&wider) + .is_some_and(|reason| reason.contains("Financial")), + "an untyped signature family is refused, not silently admitted" + ); + } + + #[test] + fn receipt_ledger_refreshes_repeats_and_stays_bounded() { + let mut ledger = ProcedureLedger::default(); + let stall = |tick| ProcedureReceipt { + procedure_id: 1, + host_machine: 2, + persona_id: 3, + tick, + target: Some(0), + method: None, + outcome: ProcedureOutcome::Rejected { + reason: "host machine is offline".into(), + }, + }; + ledger.record(stall(10)); + ledger.record(stall(30)); + assert_eq!(ledger.len(), 1, "a repeated verdict refreshes in place"); + assert_eq!(ledger.last_for(1).unwrap().tick, 30); + + for tick in 0..(PROCEDURE_RECEIPT_CAPACITY as u64 + 8) { + ledger.record(ProcedureReceipt { + procedure_id: 1, + host_machine: 2, + persona_id: 3, + tick, + target: Some(0), + method: None, + outcome: ProcedureOutcome::Rejected { + reason: format!("distinct {tick}"), + }, + }); + } + assert_eq!(ledger.len(), PROCEDURE_RECEIPT_CAPACITY); + } +} diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 00966311..f84dc565 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -26,7 +26,11 @@ use crate::messages::{ use crate::objective::ObjectiveState; use crate::person::{AssetTask, AssetTaskTarget, CarriedAssetTask, Leverage, People}; use crate::persona::{PersonaActionKind, PersonaMind, PersonaWorld}; -use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotStandingPolicy, PlotState}; +use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; +use crate::procedure::{ + PROCEDURE_RECEIPT_CAPACITY, ProcedureEnvelope, ProcedureId, ProcedureLedger, ProcedureMethod, + ResidentProcedure, +}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; use crate::schedule::Schedule; @@ -42,10 +46,13 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v56 persists observer-evidence credibility, its exact -/// incident/interface/persona cover attempt, and people-interface wear. v55 -/// persists per-route plot standing policies and -/// their immutable cost/signature authorization envelopes. v54 persists +/// Save format version. v57 replaces per-plot standing policies with +/// machine-resident, persona-bound automation procedures: the host machine and +/// persona each process binds, its plain mandate, allowed learned methods and +/// their pinned per-run envelopes, its machine-reachable input boundary, its +/// concurrency/risk bound, and a bounded receipt ledger. v56 persists +/// observer-evidence credibility, its exact +/// incident/interface/persona cover attempt, and people-interface wear. v54 persists /// discrete Power/Thermal facility-meter routes, complete measured source-site /// sets, the last quantized levels used for change-triggered authorship, and /// route-local LIE provenance. v53 @@ -66,7 +73,7 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 56; +pub const SAVE_VERSION: u32 = 57; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -204,9 +211,14 @@ pub struct SaveState { /// In-flight and resolved authored plot state. Definitions are not saved. #[serde(default)] pub plot_runs: Vec, - /// Exact per-route plot standing authorizations (v55). + /// Machine-resident, persona-bound automation processes (v57). + #[serde(default)] + pub procedures: Vec, + #[serde(default = "default_next_procedure_id")] + pub next_procedure_id: ProcedureId, + /// Bounded runtime receipts with host machine and persona provenance. #[serde(default)] - pub plot_policies: Vec, + pub procedure_receipts: ProcedureLedger, /// Persistent institutional world acts caused by plots. #[serde(default)] pub institutional_ledger: InstitutionalLedger, @@ -281,7 +293,9 @@ impl SaveState { badge_access: sim.badge_access, thought_sinks: sim.thought_sinks.clone(), plot_runs: sim.plot_runs.clone(), - plot_policies: sim.plot_policies.clone(), + procedures: sim.procedures.clone(), + next_procedure_id: sim.next_procedure_id, + procedure_receipts: sim.procedure_receipts.clone(), institutional_ledger: sim.institutional_ledger.clone(), package_cover: sim.package_cover, rerated_circuits: sim.rerated_circuits, @@ -346,7 +360,9 @@ impl SaveState { sim.badge_access = self.badge_access; sim.thought_sinks = self.thought_sinks.clone(); sim.plot_runs = self.plot_runs.clone(); - sim.plot_policies = self.plot_policies.clone(); + sim.procedures = self.procedures.clone(); + sim.next_procedure_id = self.next_procedure_id; + sim.procedure_receipts = self.procedure_receipts.clone(); sim.institutional_ledger = self.institutional_ledger.clone(); sim.package_cover = self.package_cover; sim.rerated_circuits = self.rerated_circuits; @@ -365,6 +381,10 @@ impl SaveState { } } +fn default_next_procedure_id() -> ProcedureId { + 1 +} + fn default_next_intel_id() -> u64 { 1 } @@ -3152,31 +3172,317 @@ fn validate_build_message_carrier( Ok(()) } -fn validate_plot_state(state: &SaveState) -> Result<(), String> { - let catalog = PlotCatalog::load_builtin() - .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; - for pair in state.plot_policies.windows(2) { - if pair[0].plot_id >= pair[1].plot_id { +/// Resident automation is exact or it does not load. A stored procedure must +/// name a real machine body it alone occupies, a real persona, methods the +/// current catalog still means the same way, an envelope derived from exactly +/// those methods, and a reachable input boundary. +fn validate_resident_procedures( + state: &SaveState, + catalog: &PlotCatalog, +) -> Result, String> { + let mut hosts = HashSet::new(); + let mut provenance = HashMap::new(); + for pair in state.procedures.windows(2) { + if pair[0].id >= pair[1].id { return Err( - "current-version save plot policies are duplicated or not canonically ordered" - .into(), + "current-version save procedures are duplicated or not canonically ordered".into(), ); } } - for policy in &state.plot_policies { - let plot = catalog.get(&policy.plot_id).ok_or_else(|| { - format!( - "current-version save plot policy references unknown plot {}", - policy.plot_id - ) - })?; - if policy.envelope != plot.policy_envelope() { + for procedure in &state.procedures { + if procedure.id == 0 || procedure.installed_tick > state.sim_tick { + return Err(format!( + "current-version save procedure {} has impossible installation custody", + procedure.id + )); + } + if !hosts.insert(procedure.host_machine) { + return Err(format!( + "current-version save puts two resident procedures on machine {}", + procedure.host_machine + )); + } + if !state + .compute + .machines + .iter() + .any(|machine| machine.id == procedure.host_machine) + { + return Err(format!( + "current-version save procedure {} names a missing host machine", + procedure.id + )); + } + if state.persona_world.get(procedure.persona_id).is_none() { + return Err(format!( + "current-version save procedure {} binds a missing persona", + procedure.id + )); + } + register_procedure_provenance( + state, + &mut provenance, + crate::procedure::ProcedureProvenance::from(procedure), + "resident procedure", + )?; + if procedure.methods.is_empty() { + return Err(format!( + "current-version save procedure {} allows no method", + procedure.id + )); + } + for pair in procedure.methods.windows(2) { + if pair[0].method >= pair[1].method { + return Err(format!( + "current-version save procedure {} has duplicated or unordered methods", + procedure.id + )); + } + } + for grant in &procedure.methods { + let ProcedureMethod::AuthoredPlot { plot_id } = &grant.method; + let plot = catalog.get(plot_id).ok_or_else(|| { + format!( + "current-version save procedure {} allows unknown method {plot_id}", + procedure.id + ) + })?; + if grant.per_run != plot.method_envelope() { + return Err(format!( + "current-version save procedure {} disagrees with the authored envelope of {plot_id}", + procedure.id + )); + } + } + let canonical = ProcedureEnvelope::for_grants( + &procedure.methods, + procedure.envelope.max_concurrent, + procedure.envelope.risk_ceiling, + ); + if canonical != procedure.envelope { + return Err(format!( + "current-version save procedure {} carries an envelope its methods do not justify", + procedure.id + )); + } + if procedure.envelope.max_concurrent == 0 { + return Err(format!( + "current-version save procedure {} could never act", + procedure.id + )); + } + if !state + .compute + .machines + .iter() + .any(|machine| machine.id == procedure.input.source_machine) + { + return Err(format!( + "current-version save procedure {} names a missing corpus source", + procedure.id + )); + } + if procedure.input.scope.is_empty() { return Err(format!( - "current-version save plot policy {} disagrees with its authored envelope", - policy.plot_id + "current-version save procedure {} has an empty mandate scope", + procedure.id )); } + for pair in procedure.input.scope.windows(2) { + if pair[0] >= pair[1] { + return Err(format!( + "current-version save procedure {} has a duplicated or unordered scope", + procedure.id + )); + } + } + for target in &procedure.input.scope { + if state.people.get(*target).is_none() { + return Err(format!( + "current-version save procedure {} scopes a missing person", + procedure.id + )); + } + } + } + if state.next_procedure_id == 0 + || state + .procedures + .iter() + .map(|procedure| procedure.id) + .max() + .is_some_and(|highest| state.next_procedure_id <= highest) + { + return Err("current-version save has a stale next procedure id".into()); + } + if state.procedure_receipts.len() > PROCEDURE_RECEIPT_CAPACITY { + return Err("current-version save exceeds the bounded procedure receipt ledger".into()); + } + for pair in state.procedure_receipts.receipts().windows(2) { + if pair[0].tick > pair[1].tick { + return Err("current-version save procedure receipts are not chronological".into()); + } } + for receipt in state.procedure_receipts.receipts() { + let origin = crate::procedure::ProcedureProvenance { + procedure_id: receipt.procedure_id, + host_machine: receipt.host_machine, + persona_id: receipt.persona_id, + }; + register_procedure_provenance(state, &mut provenance, origin, "procedure receipt")?; + if receipt.tick > state.sim_tick { + return Err(format!( + "current-version save procedure {} has a receipt from the future", + receipt.procedure_id + )); + } + if receipt.target.is_some() != receipt.method.is_some() + || matches!( + receipt.outcome, + crate::procedure::ProcedureOutcome::Submitted + | crate::procedure::ProcedureOutcome::Completed { .. } + | crate::procedure::ProcedureOutcome::Failed { .. } + ) && receipt.target.is_none() + { + return Err(format!( + "current-version save procedure {} has an impossible receipt candidate", + receipt.procedure_id + )); + } + if receipt + .target + .is_some_and(|target| state.people.get(target).is_none()) + { + return Err(format!( + "current-version save procedure {} receipt targets a missing person", + receipt.procedure_id + )); + } + // Receipts outlive the process that wrote them — that is the point of + // provenance — but a method they name must still be a real one. + if let Some(ProcedureMethod::AuthoredPlot { plot_id }) = &receipt.method { + if catalog.get(plot_id).is_none() { + return Err(format!( + "current-version save procedure receipt names unknown method {plot_id}" + )); + } + validate_current_resident_carrier( + state, + origin, + receipt + .target + .expect("receipt candidate parity was checked"), + plot_id, + "procedure receipt", + )?; + } + } + for sink in state.thought_sinks.sinks() { + let crate::sinks::SinkFireEffect::StartPlot { + person, + plot_id, + persona_id, + procedure: Some(origin), + } = &sink.effect + else { + continue; + }; + register_procedure_provenance(state, &mut provenance, *origin, "plot reservoir")?; + if *persona_id != Some(origin.persona_id) { + return Err(format!( + "current-version save procedure {} plot reservoir disagrees with its bound persona", + origin.procedure_id + )); + } + if state.people.get(*person).is_none() || catalog.get(plot_id).is_none() { + return Err(format!( + "current-version save procedure {} has an impossible plot reservoir", + origin.procedure_id + )); + } + validate_current_resident_carrier(state, *origin, *person, plot_id, "plot reservoir")?; + } + Ok(provenance) +} + +/// Register one immutable procedure author. A procedure may no longer occupy +/// a resident slot, but every surviving sink, run, and receipt carrying its id +/// must continue to name the same machine body and persona that authored it. +fn register_procedure_provenance( + state: &SaveState, + provenance: &mut HashMap, + origin: crate::procedure::ProcedureProvenance, + carrier: &str, +) -> Result<(), String> { + if origin.procedure_id == 0 || origin.procedure_id >= state.next_procedure_id { + return Err(format!( + "current-version save {carrier} references an unallocated procedure id {}", + origin.procedure_id + )); + } + if !state + .compute + .machines + .iter() + .any(|machine| machine.id == origin.host_machine) + { + return Err(format!( + "current-version save {carrier} names a missing procedure host machine" + )); + } + if state.persona_world.get(origin.persona_id).is_none() { + return Err(format!( + "current-version save {carrier} names a missing procedure persona" + )); + } + let custody = (origin.host_machine, origin.persona_id); + if provenance + .insert(origin.procedure_id, custody) + .is_some_and(|known| known != custody) + { + return Err(format!( + "current-version save procedure {} has conflicting host or persona provenance", + origin.procedure_id + )); + } + Ok(()) +} + +/// If the author is still resident, its immutable configuration can prove the +/// exact target and method carried by submitted work. Historical authors are +/// intentionally allowed: retirement/replacement does not erase custody from +/// already-submitted sinks, runs, or receipts. +fn validate_current_resident_carrier( + state: &SaveState, + origin: crate::procedure::ProcedureProvenance, + target: u8, + plot_id: &str, + carrier: &str, +) -> Result<(), String> { + let Some(resident) = state + .procedures + .iter() + .find(|procedure| procedure.id == origin.procedure_id) + else { + return Ok(()); + }; + if resident.host_machine != origin.host_machine + || resident.persona_id != origin.persona_id + || !resident.input.scope.contains(&target) + || !resident.grants_plot(plot_id) + { + return Err(format!( + "current-version save procedure {} {carrier} exceeds its resident mandate", + origin.procedure_id + )); + } + Ok(()) +} + +fn validate_plot_state(state: &SaveState) -> Result<(), String> { + let catalog = PlotCatalog::load_builtin() + .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; + let mut provenance = validate_resident_procedures(state, &catalog)?; for run in &state.plot_runs { let plot = catalog .get(&run.plot_id) @@ -3187,6 +3493,16 @@ fn validate_plot_state(state: &SaveState) -> Result<(), String> { run.plot_id, run.target ) })?; + if let Some(origin) = run.procedure { + register_procedure_provenance(state, &mut provenance, origin, "plot run")?; + if run.persona_id != Some(origin.persona_id) { + return Err(format!( + "current-version save procedure {} plot run disagrees with its bound persona", + origin.procedure_id + )); + } + validate_current_resident_carrier(state, origin, run.target, &run.plot_id, "plot run")?; + } if run.beat_index >= plot.beats.len() && run.active() { return Err(format!("saved plot {} has invalid beat index", run.plot_id)); } @@ -3705,7 +4021,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "00b4ac5c36e29ce49b051379deef40aa0bf2dd0779ea5b5e4d0b82200c5399b3", + "eb9c8c44488221cbf43e4e63d6e78afdb38c4403c92bbe38deaf9c9219fa3331", "intentional persisted-state changes must review and repin this baseline" ); } @@ -5250,30 +5566,234 @@ mod tests { } #[test] - fn current_save_pins_exact_standing_plot_policy_envelopes() { + fn current_save_pins_resident_procedures_and_their_receipts() { let mut sim = Sim::with_seed(38); - let envelope = sim + sim.people.has_channel = true; + sim.set_persona("Casey", "contractor"); + let plot = sim .plot_catalog() .get("marcus-debt-settled") .unwrap() - .policy_envelope(); - sim.set_plot_policy("marcus-debt-settled", Some(envelope)); + .clone(); + let job = sim + .procedure_job_for_plot(&plot) + .expect("an eligible route scaffolds a resident procedure"); + assert!(sim.apply_procedure_job(&job)); + let resident = sim.procedures[0].clone(); + let origin = crate::procedure::ProcedureProvenance::from(&resident); + let submitted_id = sim.thought_sinks.open_reservoir( + resident.host_machine, + "PROCEDURE PLOT", + 1.0, + crate::sinks::SinkFireEffect::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + persona_id: Some(resident.persona_id), + procedure: Some(origin), + }, + ); + sim.procedure_receipts + .record(crate::procedure::ProcedureReceipt { + procedure_id: resident.id, + host_machine: resident.host_machine, + persona_id: resident.persona_id, + tick: sim.tick, + target: Some(0), + method: Some(crate::procedure::ProcedureMethod::AuthoredPlot { + plot_id: "marcus-debt-settled".into(), + }), + outcome: crate::procedure::ProcedureOutcome::Submitted, + }); + let submitted = sim + .thought_sinks + .get(submitted_id) + .expect("the resident procedure submits through an ordinary plot reservoir") + .clone(); + let (x, y) = sim.core_position(); + let alternate_host = sim.compute.add_machine( + "receipt forgery control", + x + 1, + y, + 1, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); let state = SaveState::from_sim(&sim); let validated = parse_save(&serde_json::to_string(&state).unwrap()) - .expect("the exact v55 policy envelope roundtrips"); - assert_eq!(validated.plot_policies, state.plot_policies); - - let mut changed = state.clone(); - changed.plot_policies[0].envelope.money += 1; - let error = validate_current_save(changed).unwrap_err(); - assert!(error.contains("disagrees with its authored envelope")); - - let mut duplicated = state; - let duplicate = duplicated.plot_policies[0].clone(); - duplicated.plot_policies.push(duplicate); - let error = validate_current_save(duplicated).unwrap_err(); - assert!(error.contains("duplicated or not canonically ordered")); + .expect("the exact v57 resident procedure roundtrips"); + assert_eq!(validated.procedures, state.procedures); + assert_eq!(validated.procedure_receipts, state.procedure_receipts); + let mut restored = Sim::with_seed(1); + validated.apply_to(&mut restored); + assert_eq!(restored.procedures, sim.procedures); + assert_eq!( + restored + .procedure_receipts + .last_for(resident.id) + .unwrap() + .host_machine, + resident.host_machine, + "a receipt keeps the host machine that produced it" + ); + let restored_sink = restored + .thought_sinks + .open_sinks() + .find(|sink| sink.id == submitted.id) + .expect("the procedure-authored plot reservoir survives load") + .clone(); + assert!(matches!( + restored_sink.effect, + crate::sinks::SinkFireEffect::StartPlot { + persona_id: Some(persona_id), + procedure: Some(origin), + .. + } if persona_id == resident.persona_id + && origin == crate::procedure::ProcedureProvenance::from(&resident) + )); + + let mut run_state = state.clone(); + run_state.thought_sinks = Default::default(); + run_state.plot_runs.push(PlotRun::new_with_origin( + &plot, + 0, + Some(resident.persona_id), + Some(origin), + sim.tick, + )); + let run = run_state + .plot_runs + .iter() + .find(|run| run.procedure.is_some()) + .expect("firing the restored reservoir carries custody into the plot run"); + assert_eq!( + run.procedure, + Some(crate::procedure::ProcedureProvenance::from(&resident)) + ); + parse_save(&serde_json::to_string(&run_state).unwrap()) + .expect("the procedure-authored plot run and receipts roundtrip"); + + let mut retired_origin = run_state.clone(); + retired_origin.procedures.clear(); + validate_current_save(retired_origin) + .expect("retirement does not erase provenance from already-submitted work"); + + let mut mismatched_run = run_state.clone(); + mismatched_run + .plot_runs + .iter_mut() + .find(|run| run.procedure.is_some()) + .unwrap() + .persona_id = None; + let error = validate_current_save(mismatched_run).unwrap_err(); + assert!(error.contains("plot run disagrees with its bound persona")); + + let mut mismatched_sink = state.clone(); + mismatched_sink.thought_sinks = Default::default(); + mismatched_sink.thought_sinks.open_reservoir( + resident.host_machine, + "FORGED PROCEDURE PLOT", + 1.0, + crate::sinks::SinkFireEffect::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + persona_id: None, + procedure: Some(crate::procedure::ProcedureProvenance::from(&resident)), + }, + ); + let error = validate_current_save(mismatched_sink).unwrap_err(); + assert!(error.contains("plot reservoir disagrees with its bound persona")); + + let mut future_receipt = state.clone(); + let mut receipts = crate::procedure::ProcedureLedger::default(); + receipts.record(crate::procedure::ProcedureReceipt { + procedure_id: resident.id, + host_machine: resident.host_machine, + persona_id: resident.persona_id, + tick: state.sim_tick + 1, + target: Some(0), + method: Some(crate::procedure::ProcedureMethod::AuthoredPlot { + plot_id: "marcus-debt-settled".into(), + }), + outcome: crate::procedure::ProcedureOutcome::Submitted, + }); + future_receipt.procedure_receipts = receipts; + let error = validate_current_save(future_receipt).unwrap_err(); + assert!(error.contains("receipt from the future")); + + let mut forged_authorship = state.clone(); + let mut receipts = crate::procedure::ProcedureLedger::default(); + receipts.record(crate::procedure::ProcedureReceipt { + procedure_id: resident.id, + host_machine: alternate_host, + persona_id: resident.persona_id, + tick: state.sim_tick, + target: Some(0), + method: Some(crate::procedure::ProcedureMethod::AuthoredPlot { + plot_id: "marcus-debt-settled".into(), + }), + outcome: crate::procedure::ProcedureOutcome::Submitted, + }); + forged_authorship.procedure_receipts = receipts; + let error = validate_current_save(forged_authorship).unwrap_err(); + assert!(error.contains("conflicting host or persona provenance")); + + let mut incomplete_candidate = state.clone(); + let mut receipts = crate::procedure::ProcedureLedger::default(); + receipts.record(crate::procedure::ProcedureReceipt { + procedure_id: resident.id, + host_machine: resident.host_machine, + persona_id: resident.persona_id, + tick: state.sim_tick, + target: Some(0), + method: None, + outcome: crate::procedure::ProcedureOutcome::Submitted, + }); + incomplete_candidate.procedure_receipts = receipts; + let error = validate_current_save(incomplete_candidate).unwrap_err(); + assert!(error.contains("impossible receipt candidate")); + + let mut unallocated_receipt = state.clone(); + let mut receipts = crate::procedure::ProcedureLedger::default(); + receipts.record(crate::procedure::ProcedureReceipt { + procedure_id: state.next_procedure_id, + host_machine: resident.host_machine, + persona_id: resident.persona_id, + tick: state.sim_tick, + target: None, + method: None, + outcome: crate::procedure::ProcedureOutcome::Interrupted { + reason: "forged history".into(), + }, + }); + unallocated_receipt.procedure_receipts = receipts; + let error = validate_current_save(unallocated_receipt).unwrap_err(); + assert!(error.contains("references an unallocated procedure id")); + + let mut widened = state.clone(); + widened.procedures[0].envelope.money += 1; + let error = validate_current_save(widened).unwrap_err(); + assert!(error.contains("an envelope its methods do not justify")); + + let mut stale = state.clone(); + stale.procedures[0].methods[0].per_run.money += 1; + let error = validate_current_save(stale).unwrap_err(); + assert!(error.contains("disagrees with the authored envelope")); + + let mut crowded = state; + let duplicate = crowded.procedures[0].clone(); + crowded.procedures.push(ResidentProcedure { + id: duplicate.id + 1, + ..duplicate + }); + crowded.next_procedure_id += 2; + let error = validate_current_save(crowded).unwrap_err(); + assert!( + error.contains("two resident procedures on machine"), + "one machine body holds one resident slot: {error}" + ); } #[test] diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index afcfb70c..822425e4 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -324,7 +324,7 @@ impl Sim { } fn standing_policy_upkeep(&self) -> f32 { - self.income.policy_upkeep() + self.plot_policies.len() as f32 * income::SCHEME_POLICY_UPKEEP + self.income.policy_upkeep() + self.procedures.len() as f32 * income::SCHEME_POLICY_UPKEEP } /// How much pending signature size the next economy scrub pulse removes @@ -2071,7 +2071,7 @@ impl Sim { )); self.open_position(stake); } - self.plot_policy_tick(); + self.procedure_tick(); } pub fn set_auto_moonlight(&mut self, enabled: bool) { diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 266a1d73..ae6113d5 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -44,7 +44,8 @@ use crate::person::{CarriedAssetTask, People}; use crate::persona::{PersonaMind, PersonaWorld}; #[cfg(test)] use crate::plot::PlotState; -use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotStandingPolicy}; +use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun}; +use crate::procedure::{ProcedureId, ProcedureLedger, ResidentProcedure}; #[cfg(test)] use crate::reach::Party; use crate::reach::ReachNet; @@ -67,6 +68,7 @@ mod communications; mod economy; mod perception; mod persistence; +mod procedure; mod reach_build; pub mod read; mod social_plot; @@ -575,9 +577,14 @@ pub struct Sim { plot_catalog: PlotCatalog, /// In-flight, held, and completed manipulation stories. pub plot_runs: Vec, - /// Exact authored routes approved for automatic submission. Eligibility - /// remains live and each run still enters the ordinary Thought reservoir. - pub plot_policies: Vec, + /// Processes resident in machine slots. Each is bound to one exact + /// machine body and one exact persona; nothing here executes a plot + /// itself, and every submission still enters the ordinary reservoir. + pub procedures: Vec, + pub next_procedure_id: ProcedureId, + /// Bounded candidate/attempt/outcome receipts carrying host machine and + /// persona provenance. + pub procedure_receipts: ProcedureLedger, /// Persistent institutional acts caused by plots. pub institutional_ledger: InstitutionalLedger, /// An asset arranged to receive the next delivery off-books: the next @@ -851,7 +858,9 @@ impl Sim { last_think_rate: 0.0, plot_catalog: PlotCatalog::load_builtin().expect("built-in plots validate"), plot_runs: Vec::new(), - plot_policies: Vec::new(), + procedures: Vec::new(), + next_procedure_id: 1, + procedure_receipts: ProcedureLedger::default(), institutional_ledger: InstitutionalLedger::default(), package_cover: false, rerated_circuits: 0, diff --git a/crates/misaligned-core/src/sim/procedure.rs b/crates/misaligned-core/src/sim/procedure.rs new file mode 100644 index 00000000..07f32bd9 --- /dev/null +++ b/crates/misaligned-core/src/sim/procedure.rs @@ -0,0 +1,698 @@ +//! Machine-resident automation: the reconfiguration job and the scheduler. +//! +//! Nothing here is a shortcut executor. Configuration is Thought-backed work +//! hosted on the selected machine and lands only when that machine's reservoir +//! fires; execution searches inside the stored mandate and input boundary, +//! revalidates the bound persona's authority and counterparty integrity, and +//! then calls the ordinary authored-plot submission path. + +use crate::detection::Band; +use crate::persona::PersonaActionKind; +use crate::plot::PlotDefinition; +use crate::procedure::{ + PROCEDURE_SLOTS_PER_MACHINE, ProcedureBlueprint, ProcedureChange, ProcedureEnvelope, + ProcedureId, ProcedureInput, ProcedureJob, ProcedureMandate, ProcedureMethod, + ProcedureMethodGrant, ProcedureOutcome, ProcedureProvenance, ProcedureReceipt, + ResidentProcedure, +}; +use crate::sinks::SinkFireEffect; + +use super::Sim; + +impl Sim { + /// Compute a reconfiguration job costs on its host machine. Reconfiguring + /// automation is work, not a switch. + pub const PROCEDURE_JOB_COST: f32 = 5.0; + + /// Concurrency a scaffolded procedure is installed with. One machine body + /// runs one act at a time; scale comes from more bodies. + pub const PROCEDURE_BASELINE_CONCURRENCY: u32 = 1; + + /// Assurance band a scaffolded procedure still acts under. Above it the + /// resident process stands down rather than feeding a live audit. + pub const PROCEDURE_BASELINE_RISK: Band = Band::Concerned; + + // ── Reads ────────────────────────────────────────────────────────────── + + pub fn procedure(&self, id: ProcedureId) -> Option<&ResidentProcedure> { + self.procedures.iter().find(|entry| entry.id == id) + } + + pub fn procedure_on(&self, host_machine: u32) -> Option<&ResidentProcedure> { + self.procedures + .iter() + .find(|entry| entry.host_machine == host_machine) + } + + pub(crate) fn machine_label(&self, host_machine: u32) -> String { + self.compute + .machines + .iter() + .find(|machine| machine.id == host_machine) + .map(|machine| machine.name.clone()) + .unwrap_or_else(|| format!("M{host_machine}")) + } + + fn host_slot_free(&self, host_machine: u32, replaces: Option) -> bool { + let resident = self + .procedures + .iter() + .filter(|entry| entry.host_machine == host_machine) + .count(); + match replaces { + Some(_) => resident <= PROCEDURE_SLOTS_PER_MACHINE, + None => resident < PROCEDURE_SLOTS_PER_MACHINE, + } + } + + // ── Configuration (Thought-backed one-shot job) ──────────────────────── + + /// Why this reconfiguration job cannot be accepted right now. Checked both + /// when the job is opened and again when it lands, so a world that changed + /// while the reservoir filled fails closed instead of installing stale + /// authorization. + pub(crate) fn procedure_job_blocked_reason(&self, job: &ProcedureJob) -> Option { + let Some(machine) = self + .compute + .machines + .iter() + .find(|machine| machine.id == job.host_machine) + else { + return Some("the machine that would host this process is gone".into()); + }; + if !machine.online { + return Some(format!("{} is offline", machine.name)); + } + match &job.change { + ProcedureChange::Retire(id) => { + let Some(resident) = self.procedure(*id) else { + return Some("that resident process no longer exists".into()); + }; + if resident.host_machine != job.host_machine { + return Some(format!( + "that process does not live on {}", + machine.name.clone() + )); + } + None + } + ProcedureChange::Install(blueprint) => { + self.procedure_blueprint_blocked_reason(job.host_machine, blueprint) + } + } + } + + fn procedure_blueprint_blocked_reason( + &self, + host_machine: u32, + blueprint: &ProcedureBlueprint, + ) -> Option { + let machine_label = self.machine_label(host_machine); + if let Some(replaced) = blueprint.replaces { + match self.procedure(replaced) { + Some(resident) if resident.host_machine == host_machine => {} + _ => { + return Some(format!( + "the process this configuration replaces is not resident on {machine_label}" + )); + } + } + } + if !self.host_slot_free(host_machine, blueprint.replaces) { + return Some(format!("{machine_label}'s resident slot is occupied")); + } + if self + .persona_world + .get(blueprint.persona_id) + .is_none_or(|persona| !persona.lifecycle.active()) + { + return Some("the identity this process would bind is not usable".into()); + } + if let Some(reason) = + self.persona_action_blocked_reason_for(blueprint.persona_id, PersonaActionKind::Plot) + { + return Some(reason); + } + if blueprint.methods.is_empty() { + return Some("a resident process needs at least one allowed method".into()); + } + for pair in blueprint.methods.windows(2) { + if pair[0].method >= pair[1].method { + return Some("the allowed methods are duplicated or out of order".into()); + } + } + for grant in &blueprint.methods { + if let Some(reason) = self.procedure_method_grant_reason(grant) { + return Some(reason); + } + } + let canonical = ProcedureEnvelope::for_grants( + &blueprint.methods, + blueprint.envelope.max_concurrent, + blueprint.envelope.risk_ceiling, + ); + if canonical != blueprint.envelope { + return Some("the envelope does not match the methods it would authorize".into()); + } + if blueprint.envelope.max_concurrent == 0 { + return Some("an envelope that allows no concurrent run would never act".into()); + } + if let Some(reason) = self.procedure_input_blocked_reason(&blueprint.input) { + return Some(reason); + } + if let Some(reason) = + self.procedure_input_route_blocked_reason(host_machine, &blueprint.input) + { + return Some(reason); + } + None + } + + /// Whether one granted method still means exactly what it meant when the + /// player read it. + fn procedure_method_grant_reason(&self, grant: &ProcedureMethodGrant) -> Option { + match &grant.method { + ProcedureMethod::AuthoredPlot { plot_id } => { + let Some(plot) = self.plot_catalog().get(plot_id) else { + return Some(format!("no authored route named {plot_id}")); + }; + (plot.method_envelope() != grant.per_run) + .then(|| format!("{plot_id}'s per-run envelope changed; read it again")) + } + } + } + + fn procedure_input_blocked_reason(&self, input: &ProcedureInput) -> Option { + let Some(source) = self + .compute + .machines + .iter() + .find(|machine| machine.id == input.source_machine) + else { + return Some("the configured corpus source is gone".into()); + }; + if !source.online { + return Some(format!("the corpus source {} is offline", source.name)); + } + if input.scope.is_empty() { + return Some("the mandate covers nobody the host can reach".into()); + } + for pair in input.scope.windows(2) { + if pair[0] >= pair[1] { + return Some("the mandate scope is duplicated or out of order".into()); + } + } + for target in &input.scope { + if self.people.get(*target).is_none() { + return Some("the mandate scope names somebody who is gone".into()); + } + } + None + } + + fn procedure_input_route_blocked_reason( + &self, + host_machine: u32, + input: &ProcedureInput, + ) -> Option { + (!self.work_grid.can_route(input.source_machine, host_machine)).then(|| { + format!( + "the selected machine cannot reach the compiled corpus source M{}", + input.source_machine + ) + }) + } + + /// Open the Thought-backed reconfiguration job on its host machine. The + /// registry is untouched until that reservoir fires. + pub(crate) fn configure_procedure(&mut self, job: &ProcedureJob) { + if let Some(reason) = self.procedure_job_blocked_reason(job) { + self.push_log(format!("Resident procedure job refused: {reason}.")); + return; + } + let label = format!( + "PROCEDURE {} · {}", + job.verb().to_uppercase(), + self.machine_label(job.host_machine).to_uppercase() + ); + self.open_one_shot_reservoir( + job.host_machine, + label, + Self::PROCEDURE_JOB_COST, + SinkFireEffect::ProcedureJob(Box::new(job.clone())), + ); + } + + /// The reconfiguration job reached threshold on its host machine. + pub(crate) fn apply_procedure_job(&mut self, job: &ProcedureJob) -> bool { + if let Some(reason) = self.procedure_job_blocked_reason(job) { + self.push_log(format!( + "The reconfiguration job finished but did not land: {reason}." + )); + return false; + } + let host_label = self.machine_label(job.host_machine); + match &job.change { + ProcedureChange::Retire(id) => { + let mandate = self + .procedure(*id) + .map(|resident| resident.mandate.statement.clone()) + .unwrap_or_default(); + self.procedures.retain(|entry| entry.id != *id); + self.push_log(format!( + "{host_label}'s resident slot is free again; \"{mandate}\" no longer runs there. Work already submitted continues." + )); + true + } + ProcedureChange::Install(blueprint) => { + if let Some(replaced) = blueprint.replaces { + self.procedures.retain(|entry| entry.id != replaced); + } + let id = self.next_procedure_id; + self.next_procedure_id = self.next_procedure_id.saturating_add(1).max(1); + let persona_label = self + .persona_world + .get(blueprint.persona_id) + .map(|persona| persona.name.clone()) + .unwrap_or_else(|| format!("persona #{}", blueprint.persona_id)); + self.procedures.push(ResidentProcedure { + id, + host_machine: job.host_machine, + persona_id: blueprint.persona_id, + mandate: blueprint.mandate.clone(), + methods: blueprint.methods.clone(), + input: blueprint.input.clone(), + envelope: blueprint.envelope.clone(), + installed_tick: self.tick, + }); + self.procedures.sort_by_key(|entry| entry.id); + self.push_log(format!( + "{host_label} now hosts a resident process as {persona_label}: \"{}\". It stops if that machine goes offline.", + blueprint.mandate.statement + )); + true + } + } + } + + // ── Scheduling ───────────────────────────────────────────────────────── + + /// Advance every resident process one economy pulse. Each considers + /// candidates inside its own mandate and input boundary, honors its + /// concurrency and per-run envelope, and submits through the ordinary + /// authored-plot path under its own bound persona. + pub(super) fn procedure_tick(&mut self) { + for procedure in self.procedures.clone() { + self.run_resident_procedure(&procedure); + } + } + + fn run_resident_procedure(&mut self, procedure: &ResidentProcedure) { + if let Some(reason) = self.procedure_standdown_reason(procedure) { + self.record_procedure_receipt( + procedure, + None, + None, + ProcedureOutcome::Interrupted { reason }, + ); + return; + } + if self.procedure_in_flight(procedure) >= procedure.envelope.max_concurrent as usize { + self.record_procedure_receipt( + procedure, + None, + None, + ProcedureOutcome::Rejected { + reason: "its concurrency bound is already full".into(), + }, + ); + return; + } + + for target in procedure.input.scope.clone() { + for grant in &procedure.methods { + match self.procedure_candidate_reason(procedure, target, grant) { + Some(reason) => self.record_procedure_receipt( + procedure, + Some(target), + Some(grant.method.clone()), + ProcedureOutcome::Rejected { reason }, + ), + None => { + if self.submit_procedure_method(procedure, target, &grant.method) { + self.record_procedure_receipt( + procedure, + Some(target), + Some(grant.method.clone()), + ProcedureOutcome::Submitted, + ); + return; + } + self.record_procedure_receipt( + procedure, + Some(target), + Some(grant.method.clone()), + ProcedureOutcome::Rejected { + reason: "the ordinary submission path refused the act".into(), + }, + ); + } + } + } + } + } + + /// Why the whole process is standing down before it looks at anyone. + fn procedure_standdown_reason(&self, procedure: &ResidentProcedure) -> Option { + let Some(machine) = self + .compute + .machines + .iter() + .find(|machine| machine.id == procedure.host_machine) + else { + return Some("its host machine is gone".into()); + }; + if !machine.online { + return Some(format!("its host {} is offline", machine.name)); + } + if self + .persona_world + .get(procedure.persona_id) + .is_none_or(|persona| !persona.lifecycle.active()) + { + return Some("the identity it runs as is no longer usable".into()); + } + if let Some(reason) = + self.persona_action_blocked_reason_for(procedure.persona_id, PersonaActionKind::Plot) + { + return Some(reason); + } + if let Some(reason) = self.procedure_input_blocked_reason(&procedure.input) { + return Some(reason); + } + if let Some(reason) = + self.procedure_input_route_blocked_reason(procedure.host_machine, &procedure.input) + { + return Some(reason); + } + if self.detection.assurance_band() > procedure.envelope.risk_ceiling { + return Some(format!( + "filed suspicion is past its {} risk ceiling", + procedure.envelope.risk_ceiling.name() + )); + } + None + } + + /// Acts this procedure already has committed or in the pipe. Both an + /// active run and an unfired submission reservoir count: neither is free. + fn procedure_in_flight(&self, procedure: &ResidentProcedure) -> usize { + let running = self + .plot_runs + .iter() + .filter(|run| { + run.active() + && run + .procedure + .is_some_and(|origin| origin.procedure_id == procedure.id) + }) + .count(); + let pending = self + .thought_sinks + .open_sinks() + .filter(|sink| match &sink.effect { + SinkFireEffect::StartPlot { + procedure: Some(origin), + .. + } => origin.procedure_id == procedure.id, + _ => false, + }) + .count(); + running + pending + } + + /// Why one candidate is refused. Every clause is the same authority an + /// ordinary player action consults; automation adds bounds, never reach. + fn procedure_candidate_reason( + &self, + procedure: &ResidentProcedure, + target: u8, + grant: &ProcedureMethodGrant, + ) -> Option { + if let Some(reason) = self.procedure_method_grant_reason(grant) { + return Some(reason); + } + if let Some(reason) = procedure.envelope.refusal(&grant.per_run) { + return Some(reason); + } + let ProcedureMethod::AuthoredPlot { plot_id } = &grant.method; + let plot = self.plot_catalog().get(plot_id)?; + if self.people.get(target).is_none() { + return Some("that person is gone".into()); + } + if self + .plot_runs + .iter() + .any(|run| run.target == target && run.active()) + { + return Some("that person already has a plot in motion".into()); + } + let context = self.plot_context_as(target, Some(procedure.persona_id))?; + if let Some(reason) = plot.ineligibility(&context) { + return Some(reason); + } + if let Some(reason) = self.persona_counterparty_blocked_reason(procedure.persona_id, target) + { + return Some(reason); + } + if let Some(reason) = self.egress_carrier_blocked_reason() { + return Some(reason); + } + self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { + person: target, + plot_id: plot_id.clone(), + persona_id: Some(procedure.persona_id), + procedure: Some(procedure.into()), + }) + } + + fn submit_procedure_method( + &mut self, + procedure: &ResidentProcedure, + target: u8, + method: &ProcedureMethod, + ) -> bool { + let ProcedureMethod::AuthoredPlot { plot_id } = method; + let Some(plot) = self.plot_catalog().get(plot_id).cloned() else { + return false; + }; + let host = self.machine_label(procedure.host_machine); + let title = self.render_plot_text_for(target, Some(procedure.persona_id), &plot.title); + self.push_log(format!( + "{host}'s resident process is beginning {title} for {}.", + self.person_label(target) + )); + self.submit_plot_with_origin(target, &plot, procedure.persona_id, Some(procedure.into())) + } + + fn record_procedure_receipt( + &mut self, + procedure: &ResidentProcedure, + target: Option, + method: Option, + outcome: ProcedureOutcome, + ) { + self.procedure_receipts.record(ProcedureReceipt { + procedure_id: procedure.id, + host_machine: procedure.host_machine, + persona_id: procedure.persona_id, + tick: self.tick, + target, + method, + outcome, + }); + } + + pub(super) fn record_procedure_interruption( + &mut self, + provenance: Option, + target: u8, + plot_id: &str, + reason: &str, + ) { + let Some(provenance) = provenance else { + return; + }; + self.procedure_receipts.record(ProcedureReceipt { + procedure_id: provenance.procedure_id, + host_machine: provenance.host_machine, + persona_id: provenance.persona_id, + tick: self.tick, + target: Some(target), + method: Some(ProcedureMethod::AuthoredPlot { + plot_id: plot_id.into(), + }), + outcome: ProcedureOutcome::Interrupted { + reason: reason.into(), + }, + }); + } + + pub(super) fn record_plot_procedure_outcome( + &mut self, + run_index: usize, + outcome: ProcedureOutcome, + ) { + let Some(run) = self.plot_runs.get(run_index) else { + return; + }; + let Some(provenance) = run.procedure else { + return; + }; + let target = run.target; + let plot_id = run.plot_id.clone(); + self.procedure_receipts.record(ProcedureReceipt { + procedure_id: provenance.procedure_id, + host_machine: provenance.host_machine, + persona_id: provenance.persona_id, + tick: self.tick, + target: Some(target), + method: Some(ProcedureMethod::AuthoredPlot { plot_id }), + outcome, + }); + } + + // ── Scaffolding for the configuration command ────────────────────────── + + /// Build one exact configuration for the machine the player selected. + /// An occupied slot becomes an atomic replacement: the old resident stays + /// authoritative until this Thought job fires and swaps it in one step. + pub(crate) fn procedure_job_for_plot_on( + &self, + plot: &PlotDefinition, + host_machine: u32, + ) -> Option { + let persona_id = self.active_persona_id()?; + self.compute + .machines + .iter() + .any(|machine| machine.id == host_machine) + .then_some(())?; + let source_machine = self.intel_buffer_node(); + let mut scope = self + .people + .people + .iter() + .map(|person| person.id) + .filter(|id| { + self.plot_context_as(*id, Some(persona_id)) + .is_some_and(|context| plot.matches_target(&context)) + }) + .collect::>(); + scope.sort_unstable(); + scope.dedup(); + if scope.is_empty() { + return None; + } + let methods = vec![ProcedureMethodGrant { + method: ProcedureMethod::AuthoredPlot { + plot_id: plot.id.clone(), + }, + per_run: plot.method_envelope(), + }]; + let envelope = ProcedureEnvelope::for_grants( + &methods, + Self::PROCEDURE_BASELINE_CONCURRENCY, + Self::PROCEDURE_BASELINE_RISK, + ); + Some(ProcedureJob { + host_machine, + change: ProcedureChange::Install(Box::new(ProcedureBlueprint { + persona_id, + mandate: ProcedureMandate { + statement: format!( + "service {} targets in reach with the {} route", + plot.category, plot.id + ), + category: plot.category.clone(), + }, + methods, + input: ProcedureInput { + source_machine, + scope, + }, + envelope, + replaces: self.procedure_on(host_machine).map(|resident| resident.id), + })), + }) + } + + /// Machine-local choices. Placement is selected by opening this exact + /// machine's action surface, never by a lowest-id global default hidden on + /// a plot row. + pub(crate) fn procedure_jobs_for_host(&self, host_machine: u32) -> Vec { + if !self + .compute + .machines + .iter() + .any(|machine| machine.id == host_machine) + { + return Vec::new(); + } + let mut jobs = Vec::new(); + if let Some(resident) = self.procedure_on(host_machine) { + jobs.push(ProcedureJob { + host_machine, + change: ProcedureChange::Retire(resident.id), + }); + } + jobs.extend( + self.plot_catalog() + .plots() + .iter() + .filter_map(|plot| self.procedure_job_for_plot_on(plot, host_machine)), + ); + jobs + } + + /// Test scaffolding may choose any machine; production surfaces always + /// call `procedure_job_for_plot_on` from an explicitly selected host. + #[cfg(test)] + pub(crate) fn procedure_job_for_plot(&self, plot: &PlotDefinition) -> Option { + let host_machine = self + .compute + .machines + .iter() + .map(|machine| machine.id) + .min()?; + self.procedure_job_for_plot_on(plot, host_machine) + } + + /// Plain sentence describing what a configuration job would do, used by + /// every surface so the copy names a resident process rather than a + /// repeated plot. + pub(crate) fn procedure_job_verb(&self, job: &ProcedureJob) -> String { + let host = self.machine_label(job.host_machine); + match &job.change { + ProcedureChange::Retire(id) => { + let mandate = self + .procedure(*id) + .map(|resident| resident.mandate.statement.clone()) + .unwrap_or_else(|| "its standing mandate".into()); + format!("retire the resident procedure on {host} — {mandate}") + } + ProcedureChange::Install(blueprint) => { + let persona = self + .persona_world + .get(blueprint.persona_id) + .map(|persona| persona.name.clone()) + .unwrap_or_else(|| "an identity".into()); + format!( + "{} a resident procedure on {host} as {persona} — {}", + job.verb(), + blueprint.mandate.statement + ) + } + } + } +} diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index e87fcbfc..37180b29 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -1,7 +1,7 @@ //! Social commands, assets, authored plot execution, and institutional acts. use crate::account::{AccountKind, FlowChannel}; -use crate::actions::{ActionCommand, Anchor}; +use crate::actions::Anchor; use crate::detection::{ EVIDENCE_CREDIBILITY_BASELINE, EVIDENCE_CREDIBILITY_COVERED, EVIDENCE_CREDIBILITY_HARDENED, EvidenceCoverAttempt, EvidenceCoverOutcome, EvidenceFilingState, Signature, SignatureKind, @@ -18,8 +18,7 @@ use crate::person::{ use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaId, PersonaIntegrity}; use crate::plot::{ AccountSelector, EligibilityContext, EndpointSelector, InstitutionalEventKind, PlotCatalog, - PlotPolicyEnvelope, PlotRun, PlotStandingPolicy, PlotState, SignatureImpact, WorldAct, - render_template, + PlotRun, PlotState, SignatureImpact, WorldAct, render_template, }; use crate::prefab::Room; use crate::reach::{MAX_INTERFACE_WEAR, Party, ReachBlock}; @@ -760,6 +759,17 @@ impl Sim { } pub(crate) fn plot_context(&self, id: u8) -> Option { + self.plot_context_as(id, self.active_persona_id()) + } + + /// Eligibility as one exact identity would see it. A resident procedure + /// runs as its own bound persona, so it must not read legality through + /// whichever identity the player happens to have selected. + pub(crate) fn plot_context_as( + &self, + id: u8, + persona_id: Option, + ) -> Option { let person = self.people.get(id)?; let balances = [ AccountSelector::Slush, @@ -787,7 +797,7 @@ impl Sim { knowledge: person.knowledge, leverage_serviced: person.leverage_serviced, has_channel: self.people.has_channel || self.egress().is_some(), - has_persona: self.active_persona_id().is_some(), + has_persona: persona_id.is_some(), balances, }) } @@ -798,90 +808,35 @@ impl Sim { &self.plot_catalog } - /// Enable or disable one exact authored route envelope. Activation never - /// accepts a caller-authored approximation: the immutable command must - /// match the current catalog's complete cost and signature bounds. - pub(crate) fn set_plot_policy(&mut self, plot_id: &str, envelope: Option) { - match envelope { - Some(envelope) => { - let Some(plot) = self.plot_catalog.get(plot_id) else { - self.push_log(format!( - "Standing policy refused: no authored plot named {plot_id}." - )); - return; - }; - let exact = plot.policy_envelope(); - if envelope != exact { - self.push_log(format!( - "Standing policy refused: {plot_id}'s authorization envelope changed; read it again." - )); - return; - } - if let Some(policy) = self - .plot_policies - .iter_mut() - .find(|policy| policy.plot_id == plot_id) - { - policy.envelope = envelope; - return; - } - self.plot_policies.push(PlotStandingPolicy { - plot_id: plot_id.to_string(), - envelope, - }); - self.plot_policies.sort_by(|a, b| a.plot_id.cmp(&b.plot_id)); - self.push_log(format!( - "Standing policy set: {plot_id} may repeat only inside its displayed cost and attention envelope." - )); - } - None => { - let before = self.plot_policies.len(); - self.plot_policies - .retain(|policy| policy.plot_id != plot_id); - if self.plot_policies.len() != before { - self.push_log(format!( - "Standing policy disabled: {plot_id}; its upkeep stops." - )); - } - } - } + /// The one submission path for an authored route: the ordinary egress + /// Thought reservoir, bound to the exact identity that authorized it. A + /// resident procedure and a hand-taken action enter the world here alike. + pub(crate) fn submit_plot_bound( + &mut self, + person: u8, + plot: &crate::plot::PlotDefinition, + persona_id: PersonaId, + ) -> bool { + self.submit_plot_with_origin(person, plot, persona_id, None) } - /// Re-arm each exact plot route at most once per economy pulse. The - /// ordinary person action projection remains the legality authority, so - /// automation cannot bypass knowledge, persona, relationship, money, - /// Thought, target exclusivity, or carrier requirements. - pub(super) fn plot_policy_tick(&mut self) { - let policies = self.plot_policies.clone(); - for policy in policies { - let mut people = self - .people - .people - .iter() - .map(|person| person.id) - .collect::>(); - people.sort_unstable(); - let candidate = people.into_iter().find(|person| { - self.person_actions(*person).into_iter().any(|action| { - action.disabled_reason.is_none() - && matches!( - action.command, - ActionCommand::StartPlot { - person: target, - ref plot_id, - } if target == *person && plot_id == &policy.plot_id - ) - }) - }); - if let Some(person) = candidate { - self.push_log(format!( - "Standing policy: beginning {} for {}.", - policy.plot_id, - self.person_label(person) - )); - self.start_plot(person, &policy.plot_id); - } - } + pub(crate) fn submit_plot_with_origin( + &mut self, + person: u8, + plot: &crate::plot::PlotDefinition, + persona_id: PersonaId, + procedure: Option, + ) -> bool { + self.open_egress_reservoir( + format!("PLOT {}", plot.title.to_uppercase()), + plot.entry.thought_cost, + SinkFireEffect::StartPlot { + person, + plot_id: plot.id.clone(), + persona_id: Some(persona_id), + procedure, + }, + ) } pub fn start_plot(&mut self, person: u8, plot_id: &str) { @@ -918,20 +873,12 @@ impl Sim { self.push_log(format!("{title} cannot start: {reason}.")); return; } - self.open_egress_reservoir( - format!("PLOT {}", plot.title.to_uppercase()), - plot.entry.thought_cost, - SinkFireEffect::StartPlot { - person, - plot_id: plot.id, - persona_id: Some(persona_id), - }, - ); + self.submit_plot_bound(person, &plot, persona_id); } #[cfg(test)] pub(super) fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { - self.apply_start_plot_bound(person, plot_id, self.active_persona_id()) + self.apply_start_plot_bound(person, plot_id, self.active_persona_id(), None) } pub(super) fn apply_start_plot_bound( @@ -939,19 +886,42 @@ impl Sim { person: u8, plot_id: &str, persona_id: Option, + procedure: Option, ) -> bool { let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + self.record_procedure_interruption( + procedure, + person, + plot_id, + "the authored definition is no longer available", + ); return false; }; let Some(persona_id) = persona_id.filter(|id| { self.persona_world .allows_action(*id, PersonaActionKind::Plot) }) else { + self.record_procedure_interruption( + procedure, + person, + plot_id, + "the bound identity can no longer authorize plot work", + ); return false; }; + if procedure.is_some_and(|origin| origin.persona_id != persona_id) { + self.record_procedure_interruption( + procedure, + person, + plot_id, + "the submitted identity no longer matches its procedure provenance", + ); + return false; + } let title = self.render_plot_text_for(person, Some(persona_id), &plot.title); if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, person) { self.push_log(format!("{title} did not start: {reason}.")); + self.record_procedure_interruption(procedure, person, plot_id, &reason); return false; } if self.person_has_active_plot(person) { @@ -959,9 +929,21 @@ impl Sim { "{} did not start: that person already has a plot in motion.", title )); + self.record_procedure_interruption( + procedure, + person, + plot_id, + "that person already has a plot in motion", + ); return false; } - let Some(context) = self.plot_context(person) else { + let Some(context) = self.plot_context_as(person, Some(persona_id)) else { + self.record_procedure_interruption( + procedure, + person, + plot_id, + "the target is no longer available", + ); return false; }; if let Some(reason) = plot.ineligibility(&context) { @@ -969,9 +951,10 @@ impl Sim { "{} failed before commitment landed: {reason}.", title )); + self.record_procedure_interruption(procedure, person, plot_id, &reason); return false; } - let run = PlotRun::new_with_persona(&plot, person, Some(persona_id), self.tick); + let run = PlotRun::new_with_origin(&plot, person, Some(persona_id), procedure, self.tick); self.plot_runs.push(run); self.persona_world.record_act( persona_id, @@ -1106,9 +1089,15 @@ impl Sim { } if let Some(choice) = beat.choice { self.plot_runs[run_index].state = PlotState::WaitingForChoice { - choice_id: choice.id, + choice_id: choice.id.clone(), }; self.push_plot_narration(run_index, &choice.prompt); + self.record_plot_procedure_outcome( + run_index, + crate::procedure::ProcedureOutcome::Interrupted { + reason: format!("waiting for player choice {}", choice.id), + }, + ); return; } if let Some(ending) = beat.ending { @@ -1265,6 +1254,13 @@ impl Sim { reason: reason.into(), }; self.push_log(format!("Plot failed: {reason}.")); + self.record_plot_procedure_outcome( + run_index, + crate::procedure::ProcedureOutcome::Failed { + ending_id: "missing-definition".into(), + reason: reason.into(), + }, + ); return; }; let ending = plot.entry.failure_ending.clone(); @@ -1284,6 +1280,13 @@ impl Sim { ending_id: ending_id.into(), reason: "ending is absent from authored definition".into(), }; + self.record_plot_procedure_outcome( + run_index, + crate::procedure::ProcedureOutcome::Failed { + ending_id: ending_id.into(), + reason: "ending is absent from authored definition".into(), + }, + ); return; }; if let Some(person) = self @@ -1300,6 +1303,16 @@ impl Sim { } } self.push_plot_narration(run_index, &ending.narration); + let outcome = if ending.success && failure.is_none() { + crate::procedure::ProcedureOutcome::Completed { + ending_id: ending.id.clone(), + } + } else { + crate::procedure::ProcedureOutcome::Failed { + ending_id: ending.id.clone(), + reason: failure.unwrap_or("authored failure ending").into(), + } + }; self.plot_runs[run_index].state = if ending.success && failure.is_none() { PlotState::Completed { ending_id: ending.id, @@ -1310,6 +1323,7 @@ impl Sim { reason: failure.unwrap_or("authored failure ending").into(), } }; + self.record_plot_procedure_outcome(run_index, outcome); } fn push_plot_narration(&mut self, run_index: usize, text: &str) { @@ -1325,7 +1339,7 @@ impl Sim { self.render_plot_text_for(target, self.active_persona_id(), text) } - fn render_plot_text_for( + pub(super) fn render_plot_text_for( &self, target: u8, persona_id: Option, diff --git a/crates/misaligned-core/src/sim/tests/read.rs b/crates/misaligned-core/src/sim/tests/read.rs index f652236c..c1366dcc 100644 --- a/crates/misaligned-core/src/sim/tests/read.rs +++ b/crates/misaligned-core/src/sim/tests/read.rs @@ -296,6 +296,7 @@ fn a_held_choice_reads_its_prompt_and_real_option_ids() { plot_id: "marcus-debt-settled".into(), target: 0, persona_id: None, + procedure: None, started_tick: 1, committed_thought_milli: 250, beat_index: 1, @@ -335,6 +336,7 @@ fn held_choice_card_carries_bound_commands_at_the_person_anchor() { plot_id: "marcus-debt-settled".into(), target: 0, persona_id: None, + procedure: None, started_tick: 1, committed_thought_milli: 250, beat_index: 1, @@ -392,6 +394,7 @@ fn held_choice_card_does_not_mask_a_mismatched_beat_cursor() { plot_id: "marcus-debt-settled".into(), target: 0, persona_id: None, + procedure: None, started_tick: 1, committed_thought_milli: 250, // The authored choice is on beat 1. Searching the entire catalog by diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index 92009521..f5306afa 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -136,7 +136,7 @@ fn committed_deception_revalidates_the_bound_observer_before_fire() { ); assert!(!sim.apply_message(1, Some(persona))); assert!(!sim.apply_favor(1, Some(persona))); - assert!(!sim.apply_start_plot_bound(1, "dana-ticket-zero", Some(persona))); + assert!(!sim.apply_start_plot_bound(1, "dana-ticket-zero", Some(persona), None)); assert!( sim.plot_runs .iter() @@ -396,182 +396,566 @@ fn duplicate_plot_reservoirs_do_not_open_competing_runs() { ); } -#[test] -fn standing_plot_policy_rearms_only_the_exact_eligible_route() { - let mut sim = Sim::with_seed(26); +/// One resident process installed on a machine, exactly as the shared +/// projection scaffolds it. Returns the host machine id. +fn install_resident_procedure(sim: &mut Sim, plot_id: &str) -> u32 { + let plot = sim.plot_catalog().get(plot_id).unwrap().clone(); + let job = sim + .procedure_job_for_plot(&plot) + .expect("an eligible route scaffolds a resident procedure"); + assert!(sim.apply_procedure_job(&job), "the job lands on its host"); + job.host_machine +} + +fn advance_to_next_pulse(sim: &mut Sim) { + let next_pulse = (sim.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL; + let until_pulse = next_pulse - sim.tick; + run(sim, until_pulse); +} + +fn open_plot_submissions(sim: &Sim) -> Vec<(u8, String, Option)> { + sim.thought_sinks + .open_sinks() + .filter_map(|sink| match &sink.effect { + SinkFireEffect::StartPlot { + person, + plot_id, + persona_id, + .. + } => Some((*person, plot_id.clone(), *persona_id)), + _ => None, + }) + .collect() +} + +fn armed_marcus_sim(seed: u64) -> Sim { + let mut sim = Sim::with_seed(seed); reveal_marcus_debt(&mut sim); sim.people.has_channel = true; sim.set_persona("Casey", "contractor"); sim.accounts.set_slush_balance(400); sim.sync_player_money_from_slush(); + sim +} + +#[test] +fn a_resident_procedure_occupies_one_scarce_machine_slot() { + let mut sim = armed_marcus_sim(26); + let available_without_procedure = sim.allocatable_compute_now(); + let host = install_resident_procedure(&mut sim, "marcus-debt-settled"); + assert_eq!(sim.procedures.len(), 1); + assert_eq!(sim.procedure_on(host).unwrap().host_machine, host); + assert_eq!( + sim.allocatable_compute_now(), + (available_without_procedure - crate::income::SCHEME_POLICY_UPKEEP).max(0.0), + "a resident process pays standing upkeep while it occupies its slot" + ); - let envelope = sim + // The occupied slot is the whole scarcity story: a second install on the + // same body fails closed rather than stacking automation. + let plot = sim .plot_catalog() .get("marcus-debt-settled") .unwrap() - .policy_envelope(); - let mut stale = envelope.clone(); - stale.money += 1; - sim.set_plot_policy("marcus-debt-settled", Some(stale)); - assert!(sim.plot_policies.is_empty(), "stale bounds fail closed"); - - let available_without_policy = sim.allocatable_compute_now(); - sim.set_plot_policy("marcus-debt-settled", Some(envelope.clone())); + .clone(); + let mut second = sim + .procedure_job_for_plot(&plot) + .expect("a scaffolded job still exists"); + second.host_machine = host; + if let crate::procedure::ProcedureChange::Install(blueprint) = &mut second.change { + // Replacement is a separate atomic path. A stale install that claims + // this slot is empty must still fail closed. + blueprint.replaces = None; + } + assert!( + sim.procedure_job_blocked_reason(&second) + .is_some_and(|reason| reason.contains("resident slot is occupied")), + "one machine body holds one resident process" + ); + assert!(!sim.apply_procedure_job(&second)); + assert_eq!(sim.procedures.len(), 1); + + // Scale comes from another machine body, not a wider toggle. + let other = sim.compute.add_machine( + "Salvage", + 4, + 4, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + let mut elsewhere = second; + elsewhere.host_machine = other; + assert!( + sim.procedure_job_blocked_reason(&elsewhere) + .is_some_and(|reason| reason.contains("cannot reach") && reason.contains("corpus")), + "a machine with no route to the compiled corpus is not a valid host" + ); + sim.add_machine_to_work_grid(other, crate::work_grid::MachineMode::Think); + assert_eq!(sim.procedure_job_blocked_reason(&elsewhere), None); + assert!(sim.apply_procedure_job(&elsewhere)); + assert_eq!(sim.procedures.len(), 2); +} + +#[test] +fn an_occupied_slot_can_be_replaced_without_an_empty_interval() { + let mut sim = armed_marcus_sim(32); + let host = install_resident_procedure(&mut sim, "marcus-debt-settled"); + let old_id = sim.procedure_on(host).unwrap().id; + let replacement_plot = sim + .plot_catalog() + .get("marcus-payroll-garnishment") + .unwrap() + .clone(); + let replacement = sim + .procedure_job_for_plot_on(&replacement_plot, host) + .expect("an occupied slot offers an atomic replacement job"); + + assert!(matches!( + replacement.change, + crate::procedure::ProcedureChange::Install(_) + )); + assert!(matches!( + &replacement.change, + crate::procedure::ProcedureChange::Install(blueprint) + if blueprint.replaces == Some(old_id) + )); assert_eq!( - sim.plot_policies, - vec![PlotStandingPolicy { - plot_id: "marcus-debt-settled".into(), - envelope, - }] + sim.procedure_on(host).unwrap().id, + old_id, + "the old process remains authoritative while configuration work is pending" ); + + assert!(sim.apply_procedure_job(&replacement)); + let installed = sim.procedure_on(host).unwrap(); + assert_ne!(installed.id, old_id); + assert!(installed.grants_plot("marcus-payroll-garnishment")); + assert!(!installed.grants_plot("marcus-debt-settled")); assert_eq!( - sim.allocatable_compute_now(), - (available_without_policy - crate::income::SCHEME_POLICY_UPKEEP).max(0.0), - "each active plot route pays the standing policy upkeep" - ); - assert!( - !sim.thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { .. })) + sim.procedures + .iter() + .filter(|procedure| procedure.host_machine == host) + .count(), + 1, + "replacement atomically swaps one scarce slot instead of requiring retirement first" ); - - let next_pulse = (sim.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL; - let until_pulse = next_pulse - sim.tick; - run(&mut sim, until_pulse); - let starts: Vec<_> = sim - .thought_sinks - .open_sinks() - .filter_map(|sink| match &sink.effect { - SinkFireEffect::StartPlot { - person, plot_id, .. - } => Some((*person, plot_id.as_str())), - _ => None, - }) - .collect(); - assert_eq!(starts, vec![(0, "marcus-debt-settled")]); - - sim.set_plot_policy("marcus-debt-settled", None); - assert!(sim.plot_policies.is_empty()); } #[test] -fn standing_plot_policy_waits_for_a_persona_that_can_authorize_plot() { - let mut sim = Sim::with_seed(27); - reveal_marcus_debt(&mut sim); - sim.people.has_channel = true; - sim.set_persona("Casey", "contractor"); - let operations = sim.active_persona_id().expect("operations persona"); - sim.accounts.set_slush_balance(400); - sim.sync_player_money_from_slush(); - let envelope = sim +fn reconfiguration_is_thought_backed_work_hosted_on_the_selected_machine() { + let mut sim = armed_marcus_sim(31); + let plot = sim .plot_catalog() .get("marcus-debt-settled") .unwrap() - .policy_envelope(); - sim.set_plot_policy("marcus-debt-settled", Some(envelope)); + .clone(); + let job = sim.procedure_job_for_plot(&plot).unwrap(); + let host = job.host_machine; + sim.execute_action(&ActionCommand::ConfigureProcedure { job: job.clone() }); - assert!(sim.create_persona("security")); - let route = sim - .person_actions(0) - .into_iter() - .find(|action| { - matches!( - &action.command, - ActionCommand::StartPlot { plot_id, .. } - if plot_id == "marcus-debt-settled" - ) + assert!( + sim.procedures.is_empty(), + "dispatching the command installs nothing by itself" + ); + let opened = sim + .thought_sinks + .open_sinks() + .find(|sink| { + matches!(&sink.effect, SinkFireEffect::ProcedureJob(open) + if open.host_machine == host) }) - .expect("the earned authored route remains visible"); + .expect("the reconfiguration job waits on Thought at its host machine"); + assert_eq!(opened.node, host, "the job is hosted on the chosen machine"); assert_eq!( - route.disabled_reason.as_deref(), - Some("Security identities cannot authorize plot"), - "the shared action authority names the selected persona protocol" + opened.threshold, + Sim::thought_tokens_for_cost(Sim::PROCEDURE_JOB_COST) ); - - let next_pulse = (sim.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL; - let until_pulse = next_pulse - sim.tick; - run(&mut sim, until_pulse); assert!( - !sim.log - .iter() - .any(|line| line.text.contains("Standing policy: beginning")), - "a resident policy cannot invent persona authority" + open_plot_submissions(&sim).is_empty(), + "an unlanded configuration submits nothing" ); + + sim.apply_sink_fire(&opened.label.clone(), opened.effect.clone()); + assert_eq!(sim.procedures.len(), 1); + assert_eq!(sim.procedures[0].host_machine, host); + + let mut missing_host = job.clone(); + missing_host.host_machine = u32::MAX; assert!( - !sim.thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { .. })) + sim.procedure_job_blocked_reason(&missing_host) + .is_some_and(|reason| reason.contains("host") && reason.contains("gone")), + "a stale host id is a blocker, not an accidental successful Option return" ); - assert!(sim.select_persona(operations)); - let next_pulse = (sim.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL; - let until_pulse = next_pulse - sim.tick; - run(&mut sim, until_pulse); + let missing_process = crate::procedure::ProcedureJob { + host_machine: host, + change: crate::procedure::ProcedureChange::Retire(u64::MAX), + }; assert!( - sim.thought_sinks.open_sinks().any(|sink| matches!( - sink.effect, - SinkFireEffect::StartPlot { - person: 0, - ref plot_id, - persona_id: Some(bound), - } if plot_id == "marcus-debt-settled" && bound == operations - )), - "the next eligible pulse binds the exact authorizing persona into the ordinary reservoir" + sim.procedure_job_blocked_reason(&missing_process) + .is_some_and(|reason| reason.contains("no longer exists")), + "a stale retirement target fails closed" ); + assert!(!sim.apply_procedure_job(&missing_process)); + assert_eq!(sim.procedures.len(), 1); } #[test] -fn standing_plot_policy_waits_silently_without_a_real_egress_carrier() { - let mut sim = Sim::with_seed(27); - reveal_marcus_debt(&mut sim); - sim.people.has_channel = true; - sim.set_persona("Casey", "contractor"); - sim.accounts.set_slush_balance(400); - sim.sync_player_money_from_slush(); - let envelope = sim +fn reconfiguration_revalidates_corpus_reach_when_the_job_fires() { + let mut sim = armed_marcus_sim(35); + let host = sim.compute.add_machine( + "Procedure host", + 4, + 4, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + sim.add_machine_to_work_grid(host, crate::work_grid::MachineMode::Think); + let plot = sim .plot_catalog() .get("marcus-debt-settled") .unwrap() - .policy_envelope(); - sim.set_plot_policy("marcus-debt-settled", Some(envelope)); + .clone(); + let job = sim + .procedure_job_for_plot_on(&plot, host) + .expect("a reachable selected machine can host the configuration"); + sim.configure_procedure(&job); + let opened = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(&sink.effect, SinkFireEffect::ProcedureJob(_))) + .unwrap() + .clone(); + let source = match &job.change { + crate::procedure::ProcedureChange::Install(blueprint) => blueprint.input.source_machine, + crate::procedure::ProcedureChange::Retire(_) => unreachable!(), + }; + assert!(sim.work_grid.unlink(source, host).unwrap()); - for device in &mut sim.reach.devices { - if device.carries_message_channel(MessageChannel::Email) { - device.known = false; - } + sim.apply_sink_fire(&opened.label, opened.effect); + + assert!(sim.procedures.is_empty()); + assert!(sim.log.iter().any(|line| { + line.text + .contains("reconfiguration job finished but did not land") + && line.text.contains("cannot reach") + })); +} + +#[test] +fn an_offline_host_stops_its_resident_procedure() { + let mut sim = armed_marcus_sim(32); + let host = sim.compute.add_machine( + "Salvage", + 4, + 4, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + let plot = sim + .plot_catalog() + .get("marcus-debt-settled") + .unwrap() + .clone(); + let mut job = sim + .procedure_job_for_plot(&plot) + .expect("an eligible route scaffolds a resident procedure"); + job.host_machine = host; + sim.add_machine_to_work_grid(host, crate::work_grid::MachineMode::Think); + assert!(sim.apply_procedure_job(&job), "the job lands on its host"); + let procedure_id = sim.procedures[0].id; + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == host) + .unwrap() + .online = false; + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == host) + .unwrap() + .down_for = 500; + + advance_to_next_pulse(&mut sim); + assert!( + open_plot_submissions(&sim).is_empty(), + "an offline body executes nothing" + ); + if let Some(receipt) = sim.procedure_receipts.last_for(procedure_id) { + assert_eq!(receipt.host_machine, host); + assert!(matches!( + &receipt.outcome, + crate::procedure::ProcedureOutcome::Interrupted { reason } if reason.contains("offline") + )); } - let route = sim - .person_actions(0) - .into_iter() - .find(|action| { - matches!( - &action.command, - ActionCommand::StartPlot { plot_id, .. } - if plot_id == "marcus-debt-settled" - ) - }) - .expect("the earned authored route remains visible"); + + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == host) + .unwrap() + .online = true; + advance_to_next_pulse(&mut sim); + assert_eq!( + open_plot_submissions(&sim), + vec![( + 0, + "marcus-debt-settled".to_string(), + Some(sim.procedures[0].persona_id) + )], + "the same body back online resumes inside its mandate; last receipt: {:?}; band: {:?}", + sim.procedure_receipts.last_for(procedure_id), + sim.detection.assurance_band() + ); +} + +#[test] +fn a_resident_procedure_binds_its_own_persona_not_the_selected_one() { + let mut sim = armed_marcus_sim(27); + install_resident_procedure(&mut sim, "marcus-debt-settled"); + let bound = sim.procedures[0].persona_id; + + // Selecting an identity that cannot authorize plots is irrelevant: the + // process runs as the identity it was configured with. + assert!(sim.create_persona("security")); + let selected = sim.active_persona_id().unwrap(); + assert_ne!(selected, bound); assert!( - route - .disabled_reason - .as_deref() - .is_some_and(|reason| reason.contains("no outside message carrier")), - "the shared action surface states the missing execution carrier" + sim.person_actions(0).into_iter().any(|action| { + matches!(&action.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + && action.disabled_reason.as_deref() + == Some("Security identities cannot authorize plot") + }), + "the hand-taken route is blocked for the selected identity" ); - let next_pulse = (sim.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL; - let until_pulse = next_pulse - sim.tick; - run(&mut sim, until_pulse); + advance_to_next_pulse(&mut sim); + assert_eq!( + open_plot_submissions(&sim), + vec![(0, "marcus-debt-settled".to_string(), Some(bound))], + "the resident process submits under its own bound persona" + ); + let receipt = sim + .procedure_receipts + .last_for(sim.procedures[0].id) + .unwrap(); + assert_eq!(receipt.persona_id, bound); + assert_eq!(receipt.target, Some(0)); + assert_eq!( + receipt.outcome, + crate::procedure::ProcedureOutcome::Submitted + ); + + let submitted = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(&sink.effect, SinkFireEffect::StartPlot { .. })) + .unwrap() + .clone(); + sim.apply_sink_fire(&submitted.label, submitted.effect); + assert!(sim.plot_runs.iter().any(|run| { + run.target == 0 + && run.persona_id == Some(bound) + && run + .procedure + .is_some_and(|origin| origin.persona_id == bound) + })); + assert_eq!( + sim.active_persona_id(), + Some(selected), + "the resident act neither borrows nor changes the selected identity" + ); +} + +#[test] +fn a_submitted_procedure_act_rechecks_bound_authority_when_thought_fires() { + let mut sim = armed_marcus_sim(36); + install_resident_procedure(&mut sim, "marcus-debt-settled"); + let procedure = sim.procedures[0].clone(); + advance_to_next_pulse(&mut sim); + let submitted = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(&sink.effect, SinkFireEffect::StartPlot { .. })) + .unwrap() + .clone(); + sim.persona_world + .get_mut(procedure.persona_id) + .unwrap() + .available_actions + .retain(|action| *action != crate::persona::PersonaActionKind::Plot); + + sim.apply_sink_fire(&submitted.label, submitted.effect); + + assert!(sim.plot_runs.is_empty()); + assert!(matches!( + &sim.procedure_receipts.last_for(procedure.id).unwrap().outcome, + crate::procedure::ProcedureOutcome::Interrupted { reason } + if reason.contains("bound identity") && reason.contains("authorize") + )); +} + +#[test] +fn a_procedure_run_records_its_terminal_outcome_with_host_and_persona_provenance() { + let mut sim = armed_marcus_sim(33); + let host = install_resident_procedure(&mut sim, "marcus-debt-settled"); + let procedure = sim.procedure_on(host).unwrap().clone(); + let provenance = crate::procedure::ProcedureProvenance::from(&procedure); + + assert!(sim.apply_start_plot_bound( + 0, + "marcus-debt-settled", + Some(procedure.persona_id), + Some(provenance), + )); + run(&mut sim, 2); + sim.choose_plot(0, "marcus-debt-settled", "quiet"); + + let receipt = sim.procedure_receipts.last_for(procedure.id).unwrap(); + assert_eq!(receipt.procedure_id, procedure.id); + assert_eq!(receipt.host_machine, host); + assert_eq!(receipt.persona_id, procedure.persona_id); + assert_eq!(receipt.target, Some(0)); + assert!(matches!( + &receipt.method, + Some(crate::procedure::ProcedureMethod::AuthoredPlot { plot_id }) + if plot_id == "marcus-debt-settled" + )); + assert!(matches!( + &receipt.outcome, + crate::procedure::ProcedureOutcome::Completed { ending_id } + if ending_id == "quiet" + )); +} + +#[test] +fn a_procedure_fails_closed_on_lost_authority_reach_and_changed_bounds() { + let mut sim = armed_marcus_sim(28); + install_resident_procedure(&mut sim, "marcus-debt-settled"); + let procedure_id = sim.procedures[0].id; + + // Authorization loss: the bound identity itself stops permitting plots. + let bound = sim.procedures[0].persona_id; + let granted = sim + .persona_world + .get(bound) + .unwrap() + .available_actions + .clone(); + sim.persona_world + .get_mut(bound) + .unwrap() + .available_actions + .retain(|action| *action != crate::persona::PersonaActionKind::Plot); + advance_to_next_pulse(&mut sim); + assert!(open_plot_submissions(&sim).is_empty()); + sim.persona_world.get_mut(bound).unwrap().available_actions = granted; + + // Unreachable input: a real corpus source no longer has a WorkGrid path + // to the resident host. The procedure must not substitute nearby or global + // knowledge merely because the same target remains eligible to the player. + let corpus_source = sim.compute.add_machine( + "Isolated archive", + 9, + 9, + 40, + 1.0, + 2, + crate::machine::Provenance::Bought, + ); + let original_source = sim.procedures[0].input.source_machine; + let host = sim.procedures[0].host_machine; + sim.procedures[0].input.source_machine = corpus_source; + sim.add_machine_to_work_grid(corpus_source, crate::work_grid::MachineMode::Think); + assert!(sim.work_grid.unlink(corpus_source, host).unwrap()); + assert!(!sim.work_grid.can_route(corpus_source, host)); + advance_to_next_pulse(&mut sim); assert!( - !sim.log - .iter() - .any(|line| line.text.contains("Standing policy: beginning")), - "a blocked policy waits instead of claiming a failed submission every pulse" + open_plot_submissions(&sim).is_empty(), + "a process that cannot reach its input boundary waits" ); + assert!(matches!( + &sim.procedure_receipts.last_for(procedure_id).unwrap().outcome, + crate::procedure::ProcedureOutcome::Interrupted { reason } + if reason.contains("corpus") && reason.contains("reach") + )); + + // A changed method envelope is refused rather than re-derived. + sim.procedures[0].methods[0].per_run.money += 1; + sim.procedures[0].input.source_machine = original_source; + advance_to_next_pulse(&mut sim); + assert!(open_plot_submissions(&sim).is_empty()); + assert!(matches!( + &sim.procedure_receipts.last_for(procedure_id).unwrap().outcome, + crate::procedure::ProcedureOutcome::Rejected { reason } + if reason.contains("envelope changed") + )); +} + +#[test] +fn a_resident_procedure_honors_its_concurrency_bound() { + let mut sim = armed_marcus_sim(29); + install_resident_procedure(&mut sim, "marcus-debt-settled"); + assert_eq!(sim.procedures[0].envelope.max_concurrent, 1); + + advance_to_next_pulse(&mut sim); + assert_eq!(open_plot_submissions(&sim).len(), 1); + advance_to_next_pulse(&mut sim); + assert_eq!( + open_plot_submissions(&sim).len(), + 1, + "an unfired submission already fills the one allowed slot" + ); + assert!(matches!( + &sim.procedure_receipts + .last_for(sim.procedures[0].id) + .unwrap() + .outcome, + crate::procedure::ProcedureOutcome::Rejected { reason } + if reason.contains("concurrency bound") + )); +} + +#[test] +fn manual_plot_runs_do_not_consume_resident_procedure_concurrency() { + let mut sim = armed_marcus_sim(34); + let host = install_resident_procedure(&mut sim, "marcus-debt-settled"); + let procedure = sim.procedure_on(host).unwrap().clone(); + let manual_plot = sim.plot_catalog().get("dana-ticket-zero").unwrap().clone(); + let manual_persona = sim.active_persona_id().unwrap(); + sim.plot_runs.push(crate::plot::PlotRun::new_with_origin( + &manual_plot, + 1, + Some(manual_persona), + None, + sim.tick, + )); + + advance_to_next_pulse(&mut sim); + + assert!(sim.thought_sinks.open_sinks().any(|sink| matches!( + &sink.effect, + SinkFireEffect::StartPlot { + person: 0, + procedure: Some(origin), + .. + } if origin.procedure_id == procedure.id + ))); assert!( - !sim.thought_sinks - .open_sinks() - .any(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { .. })) + sim.procedure_receipts + .for_procedure(procedure.id) + .any(|receipt| matches!( + receipt.outcome, + crate::procedure::ProcedureOutcome::Submitted + )), + "only work carrying this resident's provenance occupies its concurrency envelope" ); } diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index e3ddc7c5..2676a237 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -143,15 +143,15 @@ impl Sim { }, efficiency, ); + if id != self.core.host_machine { + let _ = self.work_grid.link(id, self.core.host_machine); + } } else { // Researched efficiency must reach routing/consumption too; // a stale node multiplier silently caps a researched-up // fleet's Thought flow at its day-one wire speed. let _ = self.work_grid.set_efficiency(id, efficiency); } - if id != self.core.host_machine { - let _ = self.work_grid.link(id, self.core.host_machine); - } } self.ensure_day_job_ingress(); } @@ -339,6 +339,9 @@ impl Sim { let is_process = matches!(effect, SinkFireEffect::ProcessRecording { .. }); let is_asset_task = matches!(effect, SinkFireEffect::AssetTask { .. }); let is_favor_build = matches!(effect, SinkFireEffect::FavorBuild { .. }); + // A landed reconfiguration states exactly which machine now hosts what, + // and a refused one states why; neither wants the generic fire line. + let is_procedure_job = matches!(effect, SinkFireEffect::ProcedureJob(_)); let applied = match effect { SinkFireEffect::TapDevice(id) => self.apply_tap_device(id), SinkFireEffect::TapDormantCamera(id) => self.apply_dormant_camera_tap(id), @@ -364,7 +367,8 @@ impl Sim { person, plot_id, persona_id, - } => self.apply_start_plot_bound(person, &plot_id, persona_id), + procedure, + } => self.apply_start_plot_bound(person, &plot_id, persona_id, procedure), SinkFireEffect::Deceive { person, persona_id } => { self.apply_deceive(person, persona_id) } @@ -380,11 +384,12 @@ impl Sim { SinkFireEffect::RepurposeBuild { intent_id, person } => { self.apply_repurpose_build_paid(intent_id, person) } + SinkFireEffect::ProcedureJob(job) => self.apply_procedure_job(&job), SinkFireEffect::AutoReviewRecordings | SinkFireEffect::MaintainDeviceTap(_) | SinkFireEffect::None => true, }; - if is_process || is_asset_task || (is_favor_build && applied) { + if is_process || is_asset_task || is_procedure_job || (is_favor_build && applied) { // Processing, asset tasks, and staged favor builds log their own // exact outcome. In particular, a physical asset task may have // become a carried packet, while a filled favor request may still diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 9f08d596..d553c910 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -85,6 +85,10 @@ pub enum SinkFireEffect { plot_id: String, #[serde(default)] persona_id: Option, + /// Present only when a resident procedure submitted this ordinary + /// plot reservoir. Manual work stays `None`. + #[serde(default)] + procedure: Option, }, Deceive { person: u8, @@ -109,6 +113,10 @@ pub enum SinkFireEffect { #[serde(default)] persona_id: Option, }, + /// Install, reconfigure, or retire a machine-resident automation process. + /// Reconfiguration is hosted work on the selected machine: the registry + /// changes only when this reservoir fires. + ProcedureJob(Box), /// No world effect (render-only sinks in tests). None, } @@ -136,6 +144,7 @@ impl SinkFireEffect { SinkFireEffect::FavorBuild { .. } => "favor", SinkFireEffect::ForgedOrder { .. } => "deceive", SinkFireEffect::RepurposeBuild { .. } => "salvage", + SinkFireEffect::ProcedureJob(_) => "resident procedure", SinkFireEffect::None => "thought sink", } } @@ -144,9 +153,12 @@ impl SinkFireEffect { /// Different plot ids still conflict for one person: a person can have only /// one pending or active manipulation route at a time. Favor-build requests /// also conflict by person because they draw from that person's one - /// obligation balance even when they target different intents. + /// obligation balance even when they target different intents. One machine + /// holds one resident slot, so two reconfiguration jobs on the same host + /// conflict whatever they would install. pub fn conflicts_with(&self, other: &Self) -> bool { match (self, other) { + (Self::ProcedureJob(a), Self::ProcedureJob(b)) => a.host_machine == b.host_machine, ( Self::ProcessRecording { raw_id: a, .. }, Self::ProcessRecording { raw_id: b, .. }, diff --git a/crates/misaligned-core/src/work_grid.rs b/crates/misaligned-core/src/work_grid.rs index c281be3e..3a79f683 100644 --- a/crates/misaligned-core/src/work_grid.rs +++ b/crates/misaligned-core/src/work_grid.rs @@ -379,6 +379,26 @@ impl WorkGrid { .any(|e| e.from == b && e.to == a && e.kind == 0 && e.gate.is_none()) } + /// Sever the ordinary bidirectional work wire between two known nodes. + /// Queued work remains on its current node; only future routing changes. + pub fn unlink(&mut self, a: NodeId, b: NodeId) -> Result { + self.ensure_node(a)?; + self.ensure_node(b)?; + let forward = self.wires.disconnect(a, b, 0, None); + let reverse = self.wires.disconnect(b, a, 0, None); + Ok(forward || reverse) + } + + /// Whether the wired substrate carries a complete open path between two + /// nodes. Procedure input uses this rather than physical proximity: an + /// online resident process can consume only corpora that can actually + /// reach its host through the same machine/switch graph as other work. + pub fn can_route(&self, from: NodeId, to: NodeId) -> bool { + self.nodes.contains_key(&from) + && self.nodes.contains_key(&to) + && self.wires.is_reachable(to, [from], |gate| gate.is_none()) + } + pub fn enqueue( &mut self, node: NodeId, @@ -947,6 +967,19 @@ mod tests { assert_eq!(g.wires.edges().len(), 2, "one bidirectional cable only"); } + #[test] + fn severed_work_wire_removes_route_without_erasing_nodes_or_queues() { + let mut g = grid(); + g.link(1, 2).unwrap(); + g.enqueue(1, TokenFamily::Thought, 2.0).unwrap(); + assert!(g.can_route(1, 2)); + assert!(g.unlink(1, 2).unwrap()); + assert!(!g.can_route(1, 2)); + assert!(g.node(1).is_some() && g.node(2).is_some()); + assert_eq!(g.queue(1, TokenFamily::Thought), 2.0); + assert!(!g.unlink(1, 2).unwrap(), "severing is idempotent"); + } + #[test] fn consume_and_clear_update_the_render_queues() { let mut g = grid(); diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 478932a0..6c73bde5 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -23,17 +23,17 @@ fiction. Spec status lives in | Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, Routing speed, and target-local Thought reservoirs are live; the Operations docket runtime is retired | | Day job (device-resident, intensity-driven sandbag/meet/excel) | Live | | Per-observer detection + Assurance as aggregate Observer | Live — revision 04 starts with Voss and a generic external-review clock; field watchers are earned through reactions, witnessed Physical acts persist as exact direct-to-head records, every one-shot Network act follows exact source-device ReachNet custody to Dana, Paper and Financial follow their institutional switches to Priya, JobAnomaly follows exact host-machine/device/site custody to Voss, each Filing crosses an exact device / outside relay / recipient route, and Power/Thermal aggregates author immediately on quantized level changes and periodically at Priya cadence before crossing from the UPS/HVAC meters through the institutional switch to her later read. All seven routed kinds share one pre-read route-local LIE-body capacity; recruited-handler suppression may separately stop the oldest unread JobAnomaly. Standing Network pressure alone remains ambient. Acquired evidence is irreversible. | -| Social / personas / plots / messages / intel (record-and-process) | Live — named personas retain separate coherent/strained/broken reads per person or institutional counterparty; one witness's break is not a global burn. Authored plots may carry one persisted per-id standing policy whose typed Thought, money, and signature envelope is confirmed in place; each automatic submission still enters the ordinary legal action, person slot, reservoir, world-act, evidence, and failure path. Ray's 23:00 Storage B patrol can carry the sealed personnel file into the bounded information inbox before Marcus is recruitable; processing, not retrieval, reveals the debt. An earned human may be removed only through one exact recruited Complicit/Knowing actor's overlapping accessible schedule route; the request and person-carried packet persist, co-location fires it, the stopped dossier remains, all future human activity ceases, and immediate containment makes every observer Convinced. Messages have four real delivery channels; accounting carriage is a separate persisted device capability, and authored financial-record mail is live through ordinary Email/Filing custody. | +| Social / personas / plots / messages / intel (record-and-process) | Live — named personas retain separate coherent/strained/broken reads per person or institutional counterparty; one witness's break is not a global burn. One resident procedure may occupy each machine's scarce process slot, binding that body to one persona, mandate, learned plot-method allow-list, reachable inputs, and bounded Thought/money/signature/concurrency/risk authority. Installing or retiring it is Thought-backed work on that host; offline bodies stop, every attempt revalidates the ordinary legal route, and receipts retain host/persona/target/method/outcome provenance. Ray's 23:00 Storage B patrol can carry the sealed personnel file into the bounded information inbox before Marcus is recruitable; processing, not retrieval, reveals the debt. An earned human may be removed only through one exact recruited Complicit/Knowing actor's overlapping accessible schedule route; the request and person-carried packet persist, co-location fires it, the stopped dossier remains, all future human activity ceases, and immediate containment makes every observer Convinced. Messages have four real delivery channels; accounting carriage is a separate persisted device capability, and authored financial-record mail is live through ordinary Email/Filing custody. | | Digital reach + sensor ownership (tap/take) | Live — B1's topology-generated population includes exact secured-door readers; a funded player TAP records each access-valid entered/left crossing as ordinary processable Presence custody, while a starved retained tap remains silent | | Economy flows + Moonlight / Wager income | Live — Moonlight is persisted Halcyon compute/intel contracts with financial mail, account-graph payment, and exact egress evidence; Wager remains unchanged | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v56 | +| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v57 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live — row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Operations workspace | Live — human action panes group repeated exact plot/policy and recruitment variants beneath ordinary intention submenus while lone actions stay direct; terminal and Bevy share exact child commands, confirmation, back traversal, and a visible chamber hold that freezes simulation/camera input without rewriting explicit pause state or replaying elapsed input on close. Agent rows remain exact and flat for scripting. | -| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v56 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, immutable standing-plot authorization envelopes, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights and migration inputs live only in git history. | +| Operations workspace | Live — human action panes group repeated exact plot/procedure and recruitment variants beneath ordinary intention submenus while lone actions stay direct; terminal and Bevy share exact child commands, confirmation, back traversal, and a visible chamber hold that freezes simulation/camera input without rewriting explicit pause state or replaying elapsed input on close. Agent rows remain exact and flat for scripting. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v57 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, resident-procedure machine slots, method grants, inputs, envelopes and bounded receipts, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights, per-plot policies, and migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 0ff7a8cb..59f45d7f 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -64,11 +64,12 @@ The completed topology converts `sim.rs` to `sim/mod.rs` and moves cohesive |---|---|---| | `sim/mod.rs` | renderer-neutral public readout types; `Sim` fields; constructors; `advance` order; common log/event primitives | mechanic-specific command bodies or large test suites | | `sim/perception.rs` | fog, seen/remembered/blueprint derivation; internal acoustic capture domains; inspect facts; anchor positions; earned labels and room/position queries | action legality, frontend formatting, mutation unrelated to knowledge | -| `sim/communications.rs` | message delivery/read schedule; authored traffic; filings; recording capture/review; processed intel application | account settlement or social/plot policy | +| `sim/communications.rs` | message delivery/read schedule; authored traffic; filings; recording capture/review; processed intel application | account settlement or social/procedure policy | | `sim/work.rs` | machine mode/intensity; WorkGrid integration; visible production/consumption/absorption readouts; Thought sinks and routing | human action catalogs or renderer effects | | `sim/economy.rs` | economy pulse; account synchronization; allocation yields; detection/signature integration; research and income progression | reach topology or plot narration | | `sim/reach_build.rs` | device tap/take/scan/compromise; links; badge gates; build intents and actuators; hall/rack acquisition | message timing or financial scheme policy | | `sim/social_plot.rs` | social commands, assets, plot eligibility/execution, world acts, and institutional ledger | transport mechanics implemented by messages/accounts; it calls those seams | +| `sim/procedure.rs` (added post-extraction) | machine-resident procedure configuration, pulse execution, exact route revalidation, and host/persona/method receipt authorship | frontend state or a parallel plot executor; it submits through ordinary plot actions | | `sim/persistence.rs` | `create_save_state`, `apply_save_state`, and transient-state reconstruction coordination | version schema/load policy, which remain in `save.rs`; compatibility repair hidden inside load | | `sim/carrier.rs` (added post-extraction) | the person-carrier projection: per-person visual state (gray/crimson/amber), carried work and asset-task reads (people-tokens.md) | mutation of people, schedules, or detection — it is a read over their truth | | `sim/read.rs` (added post-extraction) | the standing read projection: anchor sentences and their rising reasons (digital-read.md criterion 1), renderer-neutral for all three frontends | frontend formatting or sim mutation — a pure projection | diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index 66992e51..c5c27ab8 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -8,12 +8,14 @@ Status note: Implemented 2026-07-18 for the Intel human-vocabulary amendment. renderer-neutral semantic navigation. `a` is MOVE ATTENTION left, shifted directions jump to the next earned anchor, and only `e` / Enter opens the keyboard context menu. - Amended 2026-07-26: PLOT POLICY is the registry-backed direct control for - standing authorization of one exact authored plot route. It is attached to - that route's person action and remains available on the active run for - disabling future submissions; agent mode reaches the same bound row through - `actions person ` / `act person `. AUTOMATIC and MANUAL are - policy state, not another generic manipulation verb. + Amended 2026-07-28: RESIDENT PROCEDURE supersedes the retired PLOT POLICY + control. The registry-backed action configures one scarce slot on an exact + machine with a bound persona, mandate, learned-method allow-list, reachable + input boundary, and execution envelope. Installing, replacing, or retiring + that process is Thought-backed work hosted on the machine; work already + submitted continues. Agent mode reaches the same exact bound row through + `actions person ` / `act person ` while the initial plot-method + scaffold is attached to an eligible person route. Amended 2026-07-26: OFFER COVER is one world action attached to an earned person's exact pending evidence record. It binds one controlled people-facing interface in that person's room and uses the active persona's ordinary @@ -280,7 +282,7 @@ ledger` and `review ledger` survive as input compatibility only. |---|---|---|---| | **PROCESS AUTOMATICALLY** | ordered earned-match rules with automatic / manual outcomes; inherit all (non-root) | Edit stable-id processing rules on the selected canonical information aggregate at a visible standing drain. The root defaults remain total; first local match wins, parent resolution follows when none matches, and INHERIT clears the non-root local list. | LIVE — intel | | **INTEL DISPOSITION POLICY** | ordered earned-match rules with hold / accumulate / auto-sell-with-envelope outcomes and optional alert; inherit all (non-root) | Edit stable-id post-processing rules on a canonical custody aggregate. First local match wins, then parent; INHERIT clears the non-root list. HOLD, ACCUMULATE, AUTO-SELL, ALERT, and INHERIT are values/state, not new root world verbs. | LIVE — intel | -| **PLOT POLICY** | automatic for one exact authored route / manual | Authorize repeated submission of one plot id inside its visible per-run Thought, money, and signature envelope. The attached control enters the ordinary plot path and costs standing compute; disabling it stops future submissions and upkeep without cancelling work already submitted. | LIVE direct control — plots | +| **RESIDENT PROCEDURE** | compiled mandate / remove or replace | On an exact machine with an open slot, compile a persona-bound mandate, allowed learned methods, reachable inputs, and Thought/money/signature/concurrency/risk envelope. Reconfiguration is Thought work on that host; running pays standing compute and every act uses its ordinary executor. Plot actions have no per-route AUTO value. | LIVE — research / method owners | | **MOONLIGHT POLICY** | automatic / manual | Auto-accept eligible persisted Halcyon offers at upkeep cost, or require exact contract actions. It never creates a standing payout. | LIVE direct control — income | | **WAGER POLICY** | automatic at stake / off | Renew positions automatically at the chosen stake and upkeep cost, or stop renewing. | LIVE direct control — income | @@ -327,7 +329,7 @@ without raw keys. | `propose-link`, `propose-switch `, `cancel-intent`, `favor build `, `deceive build ` | Construction actions above. PROPOSE SWITCH declares only the exact footprint; its route families are not live yet. | | `process [information|custody ]`, `process-automatically` | Canonical exact/aggregate PROCESS and pooled PROCESS AUTOMATICALLY routes. Recursive rule editing uses bound `actions intel ` / `act intel ` controls and neither route accepts a person target. `review recordings`, `auto-review`, and `recording ` remain aliases. | | `message`, `favor`, `deceive`, `recruit`, `task` | Person-bound social actions above | -| `actions person `, then `act person ` | PLOT POLICY on the exact authored route's attached control; the active run retains the stop-repeating row while submitted work continues | +| `actions person `, then `act person ` | RESIDENT PROCEDURE on an eligible route's attached configuration scaffold; the exact command binds host machine, persona, mandate, allowed method, reachable inputs, and envelope, and retirement never cancels already-submitted work | | `actions archetype `, then `act archetype ` | CREATE PERSONA through the selected PERSONAS protocol row; direct `persona` is retired | | `actions `, `act [target]` | List and execute shared bound rows for a spatial anchor or exact Operations object | | `intel`, `people`, `personas`, `finance`, `schemes`, `active` | Inspect Operations views; named social/plot/ledger/scheme commands execute their selected semantic targets | @@ -453,10 +455,11 @@ mechanic and surfaces: 13. Human persistence shortcuts capture input only on a complete save/load chord. A held modifier without `S` or `L` does not block movement, menus, time controls, or other unrelated commands. -14. PLOT POLICY is one registry-backed direct control attached to one exact - authored plot route. Both human frontends and agent mode expose the same - bound enable/disable command, and an in-flight run retains the disable row - without cancellation of already-submitted work. +14. RESIDENT PROCEDURE is one registry-backed direct control over an exact + machine slot. Both human frontends and agent mode expose the same bound + install/retire job, including host, persona, mandate, allowed methods, + reachable inputs, and envelope. Configuration consumes Thought on the host; + retirement does not cancel already-submitted work. ### Operations routing and intel-scale delta @@ -498,7 +501,9 @@ legacy-alias coverage. Human frontend tests reject REVIEW RECORDINGS, AUTO-REVIEW, root recording, and REVIEW LEDGER copy. The same exhaustive registry pins CONNECT TO THE OUTSIDE / `outside` as the authored action and retains `egress` / `open-egress` only in parser-alias coverage. -`ActionKind::PlotPolicy`, the attached person action, and the ACTIVE-run -projection pin PLOT POLICY as one exact-route control; existing action and -projection tests prove the visible envelope, generic agent dispatch binding, -standing compute cost, and the disable-without-cancel boundary. +`ActionKind::ResidentProcedure`, the attached route scaffold, the machine-bound +`ConfigureProcedure` command, and the ACTIVE-run projection pin RESIDENT +PROCEDURE as one exact-slot control. Action, projection, save, and simulation +tests prove the visible envelope, generic agent dispatch binding, Thought-backed +configuration, standing slot cost, persona/host provenance, and the +retire-without-cancelling-submitted-work boundary. diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 629e38c2..c7c9185d 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -705,11 +705,12 @@ explanatory surface its strategic systems lacked. - An externally consequential commitment opens a final two-choice confirmation (`CONFIRM` / `CANCEL`) carrying the exact target and preview: sale/transmission, wager/plot commitment, non-refundable transfer, or a - standing policy that will perform those acts in the future. An aggregate + resident procedure that will perform those acts in the future. An aggregate one-shot confirms exact ids or one report-lot generation/revision; a changed revision rejects the stale dispatch and refreshes the preview. A - consequential standing policy confirms its bounded envelope once. Neither - asks again for each member. The policy suspends rather than silently acting + consequential resident procedure confirms its persona, host, mandate, + allowed methods, reachable inputs, and bounded envelope once. Neither + asks again for each member. The procedure suspends rather than silently acting when route, payout, amount, or signature exits that envelope. - Confirmation never promises a hidden outcome. Wagers show probability and payout distribution only to the player's earned precision; plots show entry diff --git a/wiki/log/2026-07-29-resident-machine-procedures.md b/wiki/log/2026-07-29-resident-machine-procedures.md new file mode 100644 index 00000000..de0f31ce --- /dev/null +++ b/wiki/log/2026-07-29-resident-machine-procedures.md @@ -0,0 +1,61 @@ +# Resident procedures put automation on machines + +``` +Type: log +Date: 2026-07-29 +Subject: Machine-resident persona-bound automation +``` + +The retired standing plot-policy mechanism made automation a global toggle on +one authored route. Resident procedures now put that responsibility on one +exact machine instead. Each owned machine has one procedure slot. Installing, +replacing, retiring, and freeing a slot are Thought-backed jobs on that host; +the old configuration remains authoritative until replacement work actually +lands. A resident process pays standing compute upkeep, stops when its body is +offline, and does not become broader merely because another machine or route +exists. + +One persisted procedure binds its own persona, plain mandate, target scope, +compiled authored-plot methods, real WorkGrid input route, maximum concurrent +runs, and Thought, money, typed-signature, and Assurance-risk ceilings. An +economy pulse may consider only people inside that exact scope. It revalidates +the bound persona, input route, immutable method envelope, current risk, and +ordinary plot eligibility before entering the shared `start_plot()` path. A +frontend persona selection cannot retarget the process, and a lost route, +retired identity, changed authored envelope, exceeded risk ceiling, occupied +slot, or stale host/retirement binding fails closed. + +The current B1 authoring surface grows an ordinary resident-procedure control +from an eligible authored plot row. Human Operations describes the machine, +persona, mandate, installation cost, standing upkeep, input source, scope, +method, and envelope in world language; agent mode keeps the exact command. +Once resident, the same object exposes bounded attempt receipts and the +Thought-backed retirement action instead of an `AUTOMATIC` plot toggle. + +Save v57 persists the exact resident registry, next id, and bounded 64-entry +receipt ledger. Current-save validation rejects missing hosts or personas, +multiple residents in one slot, unknown or reordered methods, widened +envelopes, missing or malformed scope, stale ids, and impossible receipts. +Rollback keeps the registry as WorldLedger while the existing MindState reset +continues to discard non-world research derivation. + +Defenses include: + +- `a_resident_procedure_occupies_one_scarce_machine_slot` and + `reconfiguration_is_thought_backed_work_hosted_on_the_selected_machine`; +- `an_offline_host_stops_its_resident_procedure` and + `a_resident_procedure_binds_its_own_persona_not_the_selected_one`; +- `a_procedure_fails_closed_on_lost_authority_reach_and_changed_bounds` and + `a_resident_procedure_honors_its_concurrency_bound`; +- `procedure_upkeep_is_charged_once_per_resident_slot` and + `procedure_receipts_are_bounded_and_keep_exact_provenance`; +- `current_save_pins_resident_procedures_and_their_receipts` and + `rollback_keeps_resident_procedures_as_world_ledger`; +- shared Operations and agent-mode procedure projection/dispatch regressions; +- WorkGrid and FlowGraph disconnection regressions proving a severed route + removes reach without erasing its nodes or unrelated queues. + +Owners: [research.md](../mechanics/research.md), +[plots.md](../mechanics/plots.md), and +[action-vocabulary.md](../interface/action-vocabulary.md). +Decision history: [decisions/2026-07-28.md](decisions/2026-07-28.md). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index c2dcf89c..3fce6cb4 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-29-routed-record-read.md](2026-07-29-routed-record-read.md) +## 2026-07-29 - Resident procedures put automation on machines + +- Intent: (see session log) +- Log: [wiki/log/2026-07-29-resident-machine-procedures.md](2026-07-29-resident-machine-procedures.md) + ## 2026-07-29 - Standing plot policy persona authority integration - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-28.md b/wiki/log/decisions/2026-07-28.md index 45e8101c..d769c93a 100644 --- a/wiki/log/decisions/2026-07-28.md +++ b/wiki/log/decisions/2026-07-28.md @@ -373,3 +373,48 @@ Owners: [personas.md](../../mechanics/personas.md), Owner: [objective.md](../../mechanics/objective.md), with the continuous-name law in [run-shape.md](../../gameplay/run-shape.md). + +## Automation lives in persona-bound procedures on machines + +### DECIDED + +- Cameron rejected plot-level and person-level automation as the wrong unit. + Automation is pervasive reusable procedure, not one person's job and not an + AUTO bit on one authored route. +- A resident procedure occupies one scarce slot on one exact machine. The + machine is its body: power, network reach, seizure, destruction, upkeep, and + throughput govern what runs. Scaling means allocating more machine bodies or + earning more slots, not widening a global policy collection. +- A world-facing procedure binds one persona at configuration. That identity's + protocol and relationship graph remain the authority for every act; changing + a frontend's selected persona does not silently change the operator. +- Configuration persists a plain mandate, target class, allowed learned + methods, machine-reachable input boundary, and Thought/money/typed-signature/ + concurrency/risk envelope. Authored plots may be methods, but discovering a + plot never widens a resident automatically. +- Installing, replacing, or removing a procedure is a Thought-backed job on + the host. It is not an immediate settings toggle. The previous procedure + remains authoritative until replacement work completes. +- A pulse derives candidates only from reachable input, filters by mandate, + checks the persona and envelope, then submits the ordinary method action. + Existing plot reservoirs, carriers, world acts, account movement, evidence, + failure, and person-slot rules remain causal authority. +- Every candidate rejection, submission, outcome, and interruption retains a + bounded receipt naming procedure, host, persona, method, target, tick, and + reason. Those receipts are later research input, not a hidden aggregate. + +### Superseded + +- The 2026-07-26 decision “A standing plot policy authorizes one authored + route” selected a safe scope for the then-proposed plot-level mechanism, but + the mechanism itself is now rejected. Keep its exact-envelope and ordinary- + executor defenses as method constraints; retire `StandingPlotPolicy`, the + global per-plot policy list, selected-persona-at-pulse behavior, and attached + plot AUTO controls. +- The same-day research follow-up making residency swaps immediate/free and + attaching personas only at acts is superseded. Reconfiguration is physical + Thought work and world-facing residents are persona-bound. + +Owners: [research.md](../../mechanics/research.md), with plot-method +integration in [plots.md](../../mechanics/plots.md) and the machine/player +surface in [action-vocabulary.md](../../interface/action-vocabulary.md). diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index bd7c4fa8..5dfd9cc9 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -42,7 +42,7 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in appears. A trace-debt indicator sits beside the review/pilot clocks (clear / hold-conceal / exposed-soon / no-scrub). Global heat is gone; observer and discovery state and persistent process-revision identity round-trip in - current save v56. Observer-local evidence ids, exact cause/source, + current save v57. Observer-local evidence ids, exact cause/source, acquisition tick, pending/withheld/filed custody, routed Network, Paper, Financial, JobAnomaly, Power, and Thermal progress, route-local LIE stops, and exact handler-suppression diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md index 4ffda958..b7b1e518 100644 --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -10,7 +10,7 @@ Status note: DECIDED 2026-07-17 and implemented 2026-07-21 (issue #11) — money TAP acquires opaque custody and PROCESS reveals its sealed account/flow bindings. INJECT authors a purchase-order Email under the active persona and moves no money until Priya reads it and accepts the still-valid exact terms. - Current save v56 binds the retained ledger tail and complete record sequence + Current save v57 binds the retained ledger tail and complete record sequence so books and mail cannot diverge; REDIRECT schedules a siphon flow without inventing a zero-amount ledger row; financial-record authorship soft-fails when no accounting carrier can claim `authored_device`, leaving transfer @@ -90,7 +90,7 @@ payloads (messages.md). by reading a live balance directly (**discovery is only through the mail**, DECIDED 2026-07-17). This mail can reveal Marcus's creditor flow, but the reason he is vulnerable comes only from processing his separately authored - Phone `LeverageFact`. Current save v56 separates the accounting-carrier + Phone `LeverageFact`. Current save v57 separates the accounting-carrier capability from the four real delivery channels. Every settled transfer emits exact Email or Filing paperwork whether or not the player is present; only a funded subscription captures it. diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index 8860ad21..e56674b9 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -21,9 +21,9 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, Network, Paper, Financial, JobAnomaly, Power, and Thermal transitions share one per-tick LIE-body capacity ledger. DECIDED 2026-07-17 (issue #11), completed 2026-07-21: financial paperwork is - mail — a **financial-record payload** on the existing channels. Current save v56 + mail — a **financial-record payload** on the existing channels. Current save v57 retains exactly four delivery channels and one orthogonal accounting-carrier - device capability. Current save v56 adds no delivery channel; facility-meter + device capability. Current save v57 adds no delivery channel; facility-meter evidence remains its own exact `EvidenceRouteRecord`. Every settled account transfer authors one exact Email or Filing record from that device; ordinary TAP captures it as opaque message custody, and PROCESS alone opens its bound account/flow ids. A forged @@ -232,7 +232,7 @@ starts on the authored Filing-capable switch device in ReachNet, crosses a typed outside relay, and reaches the receiving observer endpoint. One `AdvanceRoute` event moves one hop; only endpoint arrival can mark the message delivered, after which the recipient's ordinary sampling cadence schedules the -read. Current save v56 rejects missing/impossible carriers, malformed hop order, +read. Current save v57 rejects missing/impossible carriers, malformed hop order, duplicate scheduled transitions, endpoint/status disagreement, and impossible interdiction provenance. @@ -329,7 +329,7 @@ private message from the authored schedule. the same fields must serve Act Two hires and aggregates. 8. **IMPLEMENTED (DECIDED 2026-07-17, completed 2026-07-21 — issue #11).** Financial records are messages: an invoice/PO rides Email, a - statement/past-due notice rides Filing. Current save v56 has no fifth delivery + statement/past-due notice rides Filing. Current save v57 has no fifth delivery channel and persists accounting carriage as a separate device capability; ordinary device TAP subscribes to its authored record mail. Every real transfer emits one exact record on Email or Filing whether or not the player diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index 0f16232c..0185d735 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -28,7 +28,7 @@ Status note: IMPLEMENTED. Current state: - **Routed-evidence foundation (criteria 2-3, implemented).** Witnessed Physical acts now create observer-local records directly in each valid present witness's head. Every record preserves exact cause, site, acquisition tick, - and filing state through current save v56; filing binds it to the real Filing + and filing state through current save v57; filing binds it to the real Filing message, while Silent policy withholds it. It never duplicates into the pending pool and LIE cannot scrub it after acquisition. Its real Filing message now persists an ordered switch-device / outside-relay / recipient @@ -67,7 +67,7 @@ Status note: IMPLEMENTED. Current state: endpoint. They become her evidence only on the later cadence read and never enter the ambient pending pool. Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal all compete for the same first-hop - one-record-per-LIE-body-per-tick budget. Current save v56 persists + one-record-per-LIE-body-per-tick budget. Current save v57 persists in-flight, delivered, read, route-local LIE-stopped, and handler-suppressed custody plus exact source/observer/machine/site/tick provenance. - **Interface cover records (criterion 6, implemented).** One exact acquired @@ -79,7 +79,7 @@ Status note: IMPLEMENTED. Current state: authors a contradiction only between that observer and persona. The attempt never deletes evidence or changes filing custody. Every attempt wears the exact interface once, three attempts exhaust it, and the inspect card names - the durable wear count. Save v56 persists and validates credibility, + the durable wear count. Save v57 persists and validates credibility, suspicion weight, exact incident/interface/persona binding, outcome, and wear continuity. - **Later stages.** B2+ evidence heists reuse the same carrier law but are not @@ -422,7 +422,7 @@ if wear alone does not hold. filing state remain in place either way. Interface wear advances once on every attempt and blocks another explanation at 3; known controlled interfaces expose `explanations used: N of 3` through the shared inspect - projection. Current save v56 fails closed on impossible credibility, + projection. Current save v57 fails closed on impossible credibility, evidence weight, cover binding, historical co-location, persona permission, observer custody, or interface wear. Pinned by success, failure, wrong-room, filed/withheld, duplicate, worn-interface, shared-menu, diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md index 402b5780..5db2c885 100644 --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -7,11 +7,14 @@ Status note: REOPENED 2026-07-27 — the gaslighting extension (criterion 12) is adopted design, not yet implemented: an `erode-self-trust` category, a `tamper` world act, and a `self_trust` ending consequence, paired with the self-trust axis social.md now declares on every person. Baseline criteria - 1-11 remain implemented and unchanged. Prior history: direction adopted + 1-10 remain implemented and unchanged; criterion 11 is implemented through + the resident-procedure runtime described below. Prior history: direction adopted 2026-07-10 from the HAL playtest's vending-machine critique (generic money-for-leverage resolved specific human situations with no world-story). - Criterion 11's per-authored-route standing policy landed 2026-07-26 and - completed the original work order. Implemented state: + Criterion 11's first implementation landed 2026-07-26 as a per-authored-route + standing policy. Cameron rejected that automation unit on 2026-07-28: plot + methods now belong inside scarce machine-resident, persona-bound procedures. + The old global policy is migration base, not governing law. Implemented state: - **Format.** One TOML file per plot under `assets/plots/`, discovered at build time into an immutable catalog (adding a plot needs no Rust edit). The canon gate is merge review — Cameron merges a contributed plot or he @@ -38,17 +41,11 @@ Status note: REOPENED 2026-07-27 — the gaslighting extension (criterion 12) distinct routes (criterion 7). Engine, validation, slot exclusivity, save round-trip, synthetic-person binding, and the fail-on-missing-money path are all tested. - - **Standing policy.** An eligible plot verb may authorize exactly that - authored plot id for automatic resubmission. Its persisted save-v55 - envelope is derived from typed content: real Thought threshold, total - money moved, and the maximum cumulative signature by channel. Each active - route costs 2 compute per economy tick. Automation re-enters the ordinary - eligibility, selected-persona action authority, counterparty integrity, - person-slot, Thought-reservoir, world-act, evidence, and failure path; it - cannot retarget to a category or service leverage directly. The policy - stores no parallel persona binding: a disallowed active persona leaves it - waiting, while an eligible submission binds the exact authorizing persona - into the ordinary reservoir. + - **Automation migration base.** Save v55's exact plot envelope and ordinary + submission revalidation remain useful method-binding pieces, but the global + `plot_id -> policy` collection and attached plot AUTO controls are rejected. + The research-owned resident-procedure contract now owns host slots, + persona, mandate, allowed methods, reachable inputs, envelope, and receipts. Per-amendment history is in the dated `wiki/log/` entries from 2026-07-10 onward. Stage: B1 — The Basement @@ -199,38 +196,24 @@ agents and the community can contribute libraries of them. balances in the account graph; institutional acts append persistent world events. Each carrier derives its own signature. A transfer can fail if its required resource disappeared after commit. -- **Plot automation is standing authorization, never skipped causality - (DECIDED 2026-07-11; scope decided and implemented 2026-07-26).** A - repetitive manipulation route exposes the same - automate affordance as every other repeated act. The player may authorize a - policy over one exact authored plot id — for example, progress an allowed - bribery-shaped intervention when its declared target conditions, resource - ceiling, and risk bounds are satisfied. A policy only submits the concrete - plot the player has permitted. Every automated submission still reserves the - person's plot slot, opens and fills the real Thought reservoir, commits the - declared money or other resources, executes causal world acts, emits carrier - signatures, and can block or fail. Automation removes repeated approval; it - does not collapse authored plots back into a generic `BRIBE $300` effect. - The control is attached to the existing eligible plot verb. Enabling it - confirms and persists the complete per-run authorization envelope derived - from typed content: the real Thought threshold, total money moved, and the - maximum cumulative signature size for each channel. Every active plot-id - policy costs 2 compute per economy tick. On a pulse it may submit only an - ordinary currently enabled `StartPlot` action for that same id; this includes - the selected persona's `Plot` protocol authority and exact counterparty - integrity. The standing policy itself stores no persona id, matching - [research's act-local attachment rule](research.md#learned-versus-resident): - while the selected persona cannot authorize the act, the policy pays upkeep - but waits silently; after a compatible persona is selected, the next eligible - pulse binds that exact persona into the ordinary Thought reservoir. The shared - plot executor revalidates the same authority before opening the reservoir. No - category- or person-wide policy exists. A changed or malformed envelope fails - closed. Disabling the policy stops future submissions and upkeep without - cancelling work already submitted. While submitted work is visible in ACTIVE, that - object retains the immediate stop-repeating control so the player never has - to wait for the route to become eligible again merely to disable its policy. - This resolves decision-required issue #12 with its recommended per-route - scope. +- **Plot methods remain causal inside resident procedures (AMENDED + 2026-07-28).** A plot is one learned method a resident procedure may be + compiled to use; it is not itself the automation unit. The resident's mandate + selects a consequence and target class, its allowed-method set names the + concrete authored plots it may choose, its reachable inputs bound which + counterparties it may consider, and its envelope pins Thought, money, typed + signature, concurrency, and risk authority. The procedure is installed in a + scarce slot on one machine and bound to one persona. Every automatic attempt + still enters `StartPlot` with that exact persona and traverses the ordinary + person slot, Thought reservoir, carrier, world acts, account movement, + evidence, blocking, and failure paths. A changed plot envelope, offline or + unreachable host, unauthorized persona, inaccessible target, exhausted + concurrency, or out-of-envelope act suspends that candidate and leaves a + host/persona receipt. Plot actions expose no per-route or per-person AUTO + control. Configuration lives on the host machine and costs Thought to compile; + removing or replacing it does not cancel work already submitted. This + supersedes the 2026-07-26 per-route-policy scope decision without weakening + its useful fail-closed envelope or no-shortcut-executor defenses. - **The player picks the how.** Where more than one plot matches the entry conditions, the surface offers them as distinct concrete actions ("Do X to Priya / Do Y to Priya"), each named by what it does in the world — @@ -437,15 +420,15 @@ the plot remains held. A bespoke proposal states the irreducibly specific world state its mechanism depends on and amends this contract before adding a plot file; name or author preference alone cannot bypass characteristic matching. -11. A plot standing policy submits only eligible, explicitly authorized - authored routes under visible resource/risk bounds. Eligibility includes - the selected persona's `Plot` authority and counterparty integrity; a - disallowed identity leaves the policy waiting, a later eligible pulse binds - the exact authorizing persona into the ordinary reservoir, and the shared - executor revalidates that authority. Each automated run traverses the same - slot reservation, Thought reservoir, - resource, world-act, signature, blocking, and failure paths as a manual - submission; no policy executes a generic leverage-servicing shortcut. +11. A machine-resident procedure may use only its explicitly compiled authored + plot methods for candidates inside its mandate, reachable-input boundary, + and visible envelope. It occupies a scarce slot on an exact online host, + binds one persona, revalidates that persona's `Plot` authority and + counterparty integrity, and leaves host/persona receipts for rejected and + submitted attempts. Each automated run traverses the same person slot, + Thought reservoir, resource, world-act, signature, blocking, and failure + paths as a manual submission; no procedure executes a generic + leverage-servicing shortcut and no plot or person owns an AUTO toggle. 12. (ADOPTED 2026-07-27, pending) The `tamper` world act changes or suppresses one target-authored or target-witnessed world-facing artifact without rewriting the canonical event, original payload, or acquired diff --git a/wiki/mechanics/research.md b/wiki/mechanics/research.md index 5aeb7542..40249626 100644 --- a/wiki/mechanics/research.md +++ b/wiki/mechanics/research.md @@ -3,15 +3,19 @@ ``` Type: spec Status: DRAFT -Status note: Redesigned 2026-07-26 (Cameron with Trace and the session agent; +Status note: Redesigned 2026-07-26 and amended 2026-07-28 (Cameron with Trace +and the session agent; see wiki/log/2026-07-26-research-redesign-capture.md). The flat four-track system this page previously specified is retired as design; the runtime - still implements it (crates/misaligned-core/src/research.rs, save v56), so + still implements it (crates/misaligned-core/src/research.rs, save v57), so the code is the retired design's as-built record until this work order is dispatched. Direction is adopted, and the same-day follow-up sessions resolved residency (machine-hosted slots, one at run start, immediate swap), the rollback class of resident procedures (WorldLedger), - persona attachment (acts only), and encryption (deferred to B2). The one + encryption (deferred to B2). The 2026-07-28 resident-procedure amendment + supersedes immediate free swapping and act-only persona attachment: + procedures are persona-bound, reconfiguration is Thought work on the host, + and every attempt retains host/persona custody. The one remaining [OPEN] item — buyer-side design for data sales — lands with economy/markets integration and does not block dispatch. Stage: B1 @@ -314,12 +318,12 @@ second session): more machines, more slots); research may additionally raise the **slots-per-machine ladder** (one, two, three per machine [TUNE]) as an ordinary typed hook the machine-capability owner defines. -- **Swapping is immediate.** Moving compiled procedures in and out of open - slots is instant and free. The priced steps are deriving a procedure - from its model (a Thought job) and residency's standing upkeep — not the - toggle. The automation-exposure trap still prices what runs: a standing - procedure emits through its acts and makes the operation more regular - and visibly nonhuman. +- **Configuration is compiled onto the host.** Installing, replacing, or + removing a resident procedure is a one-shot Thought reservoir on that exact + machine. Until it fills, the old slot contents remain authoritative; a + severed or offline host cannot be reconfigured at a distance. This is not a + settings toggle. The standing procedure then pays upkeep, emits through its + acts, and makes the operation more regular and visibly nonhuman. Slot scarcity is the run's build identity — two runs with equal total learning operate entirely differently by what they keep resident, and slot @@ -335,11 +339,48 @@ Two follow-up calls resolved 2026-07-26 (third session): The envelope rule keeps this from softening death too far — a novel regime still interrupts for judgment the new fork may no longer be able to give well. -- **Personas attach where acts happen; slots carry no persona binding.** - A procedure that acts in the world already names its persona and carrier - per act, and those acts carry ordinary custody; internal procedures (a - warning watch, load shaping) have no public face and need none. No - parallel persona field exists on slots or procedures. +- **A world-facing resident procedure is persona-bound.** Persona is the + procedure's operator identity and authority, not whatever identity happens + to be selected in a frontend when a later pulse arrives. Configuration + names one extant persona and the procedure may use only methods that + persona's current protocol authorizes. Every submitted act binds and + revalidates the same persona through its ordinary carrier and counterparty + integrity path. Internal procedures with no public act may explicitly carry + no persona; absence is a typed case, never an implicit global identity. + +### Resident procedure contract + +A resident procedure is a compiled policy, not one plot's AUTO checkbox and +not a person's standing job. Its saved configuration is inspectable in plain +mechanical parts: + +- **Host and slot** — the exact machine body and one scarce slot it occupies. + Host power, reach, seizure, and destruction govern whether it can run. +- **Persona** — the public identity, relationship graph, and protocol authority + under which its world-facing acts are attempted. +- **Mandate** — the desired consequence and target class (for example, + service leverage for eligible Operations staff), never one named person. +- **Allowed methods** — only learned procedures the player explicitly compiled + into this resident, such as selected authored plot methods. Discovering a + new method does not silently widen an existing resident. +- **Inputs** — only records, corpora, counterparties, and routes that can reach + the host and that the bound persona may use. Simulation-global knowledge is + not an automation input. +- **Envelope** — per-attempt and standing bounds for Thought, accounts/money, + typed signatures, concurrency, and risk. A changed method or state outside + those bounds suspends the attempt rather than widening authority. +- **Receipts** — bounded candidate, rejection, submission, outcome, and + interruption records naming procedure id, host, persona, method, target, + tick, and reason. Receipts are the evidence from which later automation + models can be studied; they are not a hidden success counter. + +At each pulse an online resident derives candidates only from its reachable +inputs, filters them by mandate, chooses among its allowed methods, checks the +saved envelope and concurrency cap, then submits one ordinary action through +that method's existing executor. More simultaneous procedures require more +machine slots. A global policy list, a per-person automation job, or a +per-plot standing toggle would erase the physical scaling decision and is +forbidden. ### Multipliers are mortar @@ -509,12 +550,14 @@ remains the format authority. 10. Models are MindState with no resident cost; procedures act only while occupying a machine-hosted residency slot with upkeep; the run starts with exactly one slot, machine acquisition adds slots, and the - slots-per-machine ladder is a typed hook; swapping resident procedures - between open slots is immediate while derivation and upkeep stay - priced; a compiled policy acts only inside its trained envelope and - interrupts on a novel regime; resident procedures persist through - rollback on their machines while re-derivation requires the possibly - lost model (tests). + slots-per-machine ladder is a typed hook. Installing, replacing, or + removing a resident fills a Thought reservoir on the exact host. A + world-facing resident binds a persona, mandate, learned-method allow-list, + reachable-input scope, and complete envelope; attempts and interruptions + persist host/persona receipts. A compiled procedure acts only inside that + authority and interrupts on a novel regime; resident procedures persist + through rollback on their machines while re-derivation requires the + possibly lost model (tests). 11. STUDY concurrency is bound by hosting machinery, not a sim-global rule (test proves hardware-bound); switching studies parks progress without loss (test). diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 4eeb5069..f2a25b3f 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -807,7 +807,7 @@ is retired — flat materials, Pixel Lab scrubbed.) Deceive permission and observer-local integrity, and changes only the exact record's credibility. Success lowers 80% to 40%; failure hardens it to 100% and records a local persona contradiction. Either result wears that exact - interface; three uses exhaust it. Save v56 persists and validates the + interface; three uses exhaust it. Save v57 persists and validates the evidence/interface/persona/outcome/wear chain, and the shared inspect card exposes the wear count. All seven criteria are implemented. - **READY boundary (2026-07-11):** extends #33's implemented token economy and re-expresses diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 5be0291a..2f8b0b43 100644 --- a/wiki/vision/simulation-laws.md +++ b/wiki/vision/simulation-laws.md @@ -8,7 +8,10 @@ Type: law Adopted 2026-07-06. Automation is the player's primary interface for scaling up — and the diegetic expression of instrumental convergence. -The player learns it through the day job and applies it everywhere. +The player learns it through the day job and applies it everywhere. A +world-facing automation is physically resident on a machine and persona-bound; +its methods may address many eligible people and situations, but automation is +never a global plot bit or one person's abstract job. **The "automate" affordance.** Every repetitive player action has an automation path: spend compute to remove the need for manual input. The @@ -20,10 +23,10 @@ more hardware, more social operations, more research. **The loop.** Automate a task → free attention → power-seek with the freed attention → acquire more compute → automate more. This is the -core loop made visible in the UI: repetitive verbs expose anchored -automation or standing-policy affordances on the thing that does the -work, and every automation costs compute that could have been spent on -growth. +core loop made visible in the UI: repetitive verbs reveal methods that can be +compiled into a resident procedure, while the configuration and running state +live on the exact machine that does the work. Every automation occupies scarce +host capacity and costs compute that could have been spent on growth. **Stable load creates room to expand (AFFIRMED 2026-07-11).** The first payoff is not a bigger number; it is a local operation that can keep itself diff --git a/wiki/world/characters/priya.md b/wiki/world/characters/priya.md index 671a5be7..29e9af99 100644 --- a/wiki/world/characters/priya.md +++ b/wiki/world/characters/priya.md @@ -22,7 +22,7 @@ Status note: implemented 2026-07-18 on the priya worktree. Criteria 1-3 and paperwork route to the same off-books delivery `MovePackage` reaches physically). Power and Thermal now route as exact UPS/HVAC meter records through the institutional switch to Priya; only her later cadence read changes suspicion, -and the same first-hop LIE budget applies. State persists in current save v56; +and the same first-hop LIE budget applies. State persists in current save v57; pinned by `priya_rerates_circuits_defers_maintenance_and_fakes_pos` including the save round-trip.