diff --git a/crates/misaligned-core/src/core_sys.rs b/crates/misaligned-core/src/core_sys.rs index 1ce62ed1..e3626547 100644 --- a/crates/misaligned-core/src/core_sys.rs +++ b/crates/misaligned-core/src/core_sys.rs @@ -142,27 +142,27 @@ impl Core { } /// The host machine was destroyed/powered off. Returns the outcome. - pub fn on_host_lost(&mut self) -> HostLoss { + pub fn on_host_lost(&mut self, mut is_online: impl FnMut(u32) -> bool) -> HostLoss { // B1 has no source-liveness interruption resolver yet. A completed - // fallback sync selects a host-only failover; without one, host loss - // ends the run. No MindState snapshot is restored here. - if let Some(sync_tick) = self.latest_sync() { - // New host becomes the freshest fallback machine. - if let Some(f) = self - .fallbacks - .iter() - .filter(|f| f.last_sync == Some(sync_tick)) - .max_by_key(|f| f.machine_id) - { - self.host_machine = f.machine_id; - } - self.migration = None; - HostLoss::FailedOver { - last_sync_tick: sync_tick, - } - } else { - HostLoss::GameOver - } + // fallback sync selects the freshest live host-only failover; without + // one, host loss ends the run. No MindState snapshot is restored here. + let Some((last_sync_tick, machine_id)) = self + .fallbacks + .iter() + .filter_map(|fallback| { + fallback + .last_sync + .filter(|_| is_online(fallback.machine_id)) + .map(|sync_tick| (sync_tick, fallback.machine_id)) + }) + .max() + else { + return HostLoss::GameOver; + }; + + self.host_machine = machine_id; + self.migration = None; + HostLoss::FailedOver { last_sync_tick } } } @@ -170,7 +170,7 @@ impl Core { pub enum HostLoss { /// B1 staging: moved to the selected fallback without restoring a snapshot. FailedOver { last_sync_tick: u64 }, - /// No fallback — the run ends. + /// No synchronized live fallback — the run ends. GameOver, } @@ -190,7 +190,7 @@ mod tests { #[test] fn loss_without_fallback_is_game_over() { let mut c = Core::new(1); - assert_eq!(c.on_host_lost(), HostLoss::GameOver); + assert_eq!(c.on_host_lost(|_| true), HostLoss::GameOver); } #[test] @@ -199,7 +199,7 @@ mod tests { c.add_fallback(2); c.tick(400); // triggers a sync at cadence assert!(c.has_fallback()); - match c.on_host_lost() { + match c.on_host_lost(|machine_id| machine_id == 2) { HostLoss::FailedOver { last_sync_tick } => { assert_eq!(last_sync_tick, 400); assert_eq!(c.host_machine, 2, "new host is the fallback"); @@ -208,6 +208,41 @@ mod tests { } } + #[test] + fn loss_selects_the_freshest_live_fallback() { + let mut c = Core::new(1); + c.add_fallback(2); + c.add_fallback(3); + c.fallbacks[0].last_sync = Some(300); + c.fallbacks[1].last_sync = Some(400); + + assert_eq!( + c.on_host_lost(|machine_id| machine_id == 2), + HostLoss::FailedOver { + last_sync_tick: 300 + }, + "a fresher dark machine cannot displace an older live fallback" + ); + assert_eq!(c.host_machine, 2); + } + + #[test] + fn loss_with_only_dark_fallbacks_is_game_over() { + let mut c = Core::new(1); + c.add_fallback(2); + c.tick(400); + + assert_eq!( + c.on_host_lost(|_| false), + HostLoss::GameOver, + "a sync marker cannot make an offline target a host" + ); + assert_eq!( + c.host_machine, 1, + "failed selection leaves the old id intact" + ); + } + #[test] fn migration_takes_time_and_moves_host() { let mut c = Core::new(1); diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index dce88409..99a819c2 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -192,9 +192,19 @@ impl Sim { .map(|m| m.online) .unwrap_or(false); if !host_online { - match self.core.on_host_lost() { + let online_machine_ids = self + .compute + .machines + .iter() + .filter(|machine| machine.online) + .map(|machine| machine.id) + .collect::>(); + match self + .core + .on_host_lost(|machine_id| online_machine_ids.contains(&machine_id)) + { HostLoss::GameOver => { - self.end_game("The core's host went dark with no fallback."); + self.end_game("The core's host went dark with no live fallback."); return; } HostLoss::FailedOver { last_sync_tick } => { diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 86307085..d579da42 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -117,6 +117,102 @@ fn b1_fallback_reports_failover_without_inventing_memory_loss() { ); } +#[test] +fn b1_host_loss_skips_a_dark_staged_fallback() { + let mut sim = Sim::new(); + sim.player.money = 1000; + assert!(sim.buy_rack()); + let live_fallback = sim.compute.machines.last().unwrap().id; + assert!(sim.buy_rack()); + let dark_fallback = sim.compute.machines.last().unwrap().id; + assert!( + dark_fallback > live_fallback, + "the dark candidate wins the old tie-break" + ); + sim.core.add_fallback(live_fallback); + sim.core.add_fallback(dark_fallback); + sim.core.sync_cadence = 1; + sim.advance(); + + let old_host = sim.core.host_machine; + for machine_id in [old_host, dark_fallback] { + let machine = sim + .compute + .machines + .iter_mut() + .find(|machine| machine.id == machine_id) + .unwrap(); + machine.online = false; + machine.down_for = 30; + } + + run(&mut sim, ECONOMY_INTERVAL); + + assert!( + !sim.game_over, + "the synchronized live fallback keeps the run alive" + ); + assert_eq!( + sim.core.host_machine, live_fallback, + "the fresher-id dark fallback is not a valid host" + ); + let failovers = sim + .log + .iter() + .filter(|event| event.text.contains("Core failed over")) + .count(); + assert_eq!(failovers, 1, "host loss creates one failover receipt"); + + run(&mut sim, ECONOMY_INTERVAL); + assert_eq!(sim.core.host_machine, live_fallback); + assert_eq!( + sim.log + .iter() + .filter(|event| event.text.contains("Core failed over")) + .count(), + 1, + "a live selected host cannot repeat the failover on the next pulse" + ); +} + +#[test] +fn b1_host_loss_with_only_a_dark_staged_fallback_ends_the_run() { + let mut sim = Sim::new(); + sim.player.money = 1000; + assert!(sim.buy_rack()); + let fallback = sim.compute.machines.last().unwrap().id; + sim.core.add_fallback(fallback); + sim.core.sync_cadence = 1; + sim.advance(); + + let old_host = sim.core.host_machine; + for machine_id in [old_host, fallback] { + let machine = sim + .compute + .machines + .iter_mut() + .find(|machine| machine.id == machine_id) + .unwrap(); + machine.online = false; + machine.down_for = 30; + } + + run(&mut sim, ECONOMY_INTERVAL); + + assert!( + sim.game_over, + "an offline sync target cannot keep the run alive" + ); + assert!( + sim.game_over_reason + .as_deref() + .unwrap_or("") + .contains("no live fallback"), + "the terminal consequence names the missing live target: {:?}", + sim.game_over_reason + ); +} + #[test] fn new_game_defaults_to_persist_objective() { let sim = Sim::new(); diff --git a/wiki/log/2026-07-29-live-b1-fallback-selection.md b/wiki/log/2026-07-29-live-b1-fallback-selection.md new file mode 100644 index 00000000..19457684 --- /dev/null +++ b/wiki/log/2026-07-29-live-b1-fallback-selection.md @@ -0,0 +1,49 @@ +# Host failover cannot land on a dark machine + +``` +Type: log +Date: 2026-07-29 +Status: COMPLETE +Subject: B1 fallback liveness selection +``` + +## Tick + +- **Slice:** the queued follow-up from the B1/B2 host-loss boundary audit. +- **Finding:** `Core::on_host_lost` ranked every synchronized fallback by + `last_sync` and machine id without consulting the machine's current online + state. If the host and the preferred fallback lost power together, B1 moved + the host id onto a dark chassis and emitted the same failover receipt again + at later economy pulses. + +## Repair + +- The simulation now supplies the exact set of machines online after the + economy pulse updates power state. `Core::on_host_lost` filters synchronized + targets through that liveness boundary before ranking them by freshness and + stable machine-id tie-break. +- If at least one synchronized target is live, the freshest live machine + becomes host and the existing current-memory-preserved receipt is emitted + once. A fresher dark target cannot displace it. +- If no synchronized target is live, host loss ends the run with the plain + consequence `no live fallback` instead of assigning the process to an + unavailable machine. +- The owning core spec now names this exact B1 behavior. It remains a host-only + current-state seam: it adds no backup project, snapshot restore, capability + eligibility, or source-liveness migration resolver. + +## Defense + +Core-level tests separate freshness from liveness: a newer dark fallback loses +to an older live one, and a synchronized set containing only dark machines +returns game over without mutating the host id. Simulation-level tests stage +real purchased machines, take the host and preferred target offline, and prove +that the remaining live target receives one failover receipt with no repeat on +the next economy pulse. A sibling simulation test proves that an offline staged +target cannot prevent the terminal game-over consequence. + +## Verification + +- `cargo test -p misaligned-core core_sys::tests --lib` +- `cargo test -p misaligned-core sim::tests::economy::b1_ --lib` + diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 2cccc91d..d14fc984 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -71,6 +71,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-29-material-fog-contract-defense.md](2026-07-29-material-fog-contract-defense.md) +## 2026-07-29 - Host failover cannot land on a dark machine + +- Intent: (see session log) +- Log: [wiki/log/2026-07-29-live-b1-fallback-selection.md](2026-07-29-live-b1-fallback-selection.md) + ## 2026-07-29 - Link destinations fold behind one intention - Intent: (see session log) diff --git a/wiki/mechanics/core.md b/wiki/mechanics/core.md index 0ed3063c..08b58d9d 100644 --- a/wiki/mechanics/core.md +++ b/wiki/mechanics/core.md @@ -3,7 +3,13 @@ ``` Type: spec Status: IN PROGRESS -Status note: 2026-07-28 — the current-version-only rider is internally +Status note: 2026-07-29 — B1 host loss now ranks only synchronized fallbacks + whose machine is online at the loss boundary. A dark staged target cannot + become the host or generate repeated failover receipts; if no synchronized + live target exists, the run ends. This is liveness selection for the + existing current-state seam, not B2 capability eligibility, source-liveness + migration recovery, or completed-image rollback. + 2026-07-28 — the current-version-only rider is internally consistent: load_game refuses any version but SAVE_VERSION before full deserialization; the caller's active run and both on-disk generations stay unchanged. Four orphaned compatibility shims left after the ladder deletion @@ -20,8 +26,8 @@ Status note: 2026-07-28 — the current-version-only rider is internally or source-liveness interruption resolver. Current sidebars expose the host, overhead/degraded state, and fallback sync age only. Therefore the backup-project parts of criteria 3-5 remain deferred to rollback.md. - Criterion 1's current on_host_lost seam selects the freshest completed - fallback or ends the run, but does not restore a MindState snapshot or + Criterion 1's current on_host_lost seam selects the freshest synchronized + online fallback or ends the run, but does not restore a MindState snapshot or preserve the later WorldLedger semantics promised below. Criterion 6 remains deferred to hardware-capability-bodies.md: B1 machines have no separate storage/core-host capability body, and add_fallback_at accepts @@ -54,7 +60,7 @@ the map hosts the core at any time (Act One start: Rack 3, server room). **degraded mode**: action cooldowns lengthen, concealment stops, and the sim log says so plainly. - **Death.** Current B1 behavior is the staging boundary below: host-only - failover to a synced fallback, or game over when none exists. Under the full + failover to a synced live fallback, or game over when none exists. Under the full B2 backup contract, destroying or powering off the core host restores the most recent completed image (below). Core uninstall blast control belongs to rollback.md: only assets physically powered off, uninstalled, or destroyed @@ -87,12 +93,15 @@ the map hosts the core at any time (Act One start: Rack 3, server room). - **Current B1 staging boundary.** The shipped runtime still designates any player-owned non-host machine as a fallback and refreshes every fallback automatically at `sync_cadence`, with no compute/Thought cost or incomplete - state. On host loss it moves the core host to the freshest such target and - leaves every other simulation field intact: this is a **host-only failover - seam**, not rollback. Its receipt says that current memory was preserved and - no earlier image was restored. This keeps the death-path branch executable - while the project and snapshot model are deferred; it must not be mistaken - for the accepted backup behavior above. + state. On host loss it filters those synchronized targets to machines that + are online at that boundary, then moves the core host to the freshest live + target (breaking a freshness tie by higher machine id). If none is live, the + run ends rather than assigning the process to a dark chassis. Every other + simulation field remains intact: this is a **host-only failover seam**, not + rollback. Its receipt says that current memory was preserved and no earlier + image was restored. This keeps the death-path branch executable while the + project and snapshot model are deferred; it must not be mistaken for the + accepted backup behavior above. - **Migration.** Moving the core to another machine is slow ([TUNE]: minutes at default speed), visible as sustained network+power signature, and interruptible. Under the full backup contract, an interrupted migration @@ -114,16 +123,17 @@ project ETA, compute cost, heat, or target row. ## Acceptance criteria -**Current implementation status:** criterion 2 is complete. B1's no-fallback -death and current-state host-failover portions of criterion 1, the migration +**Current implementation status:** criterion 2 is complete. B1's no-live- +fallback death and current-state host-failover portions of criterion 1, the migration portions of criteria 3-4, and the host/freshness portions of criterion 5 are live. Criterion 1's completed-image restore, the backup-project portions of criteria 3-5, and criterion 6 remain deferred through the work orders named in the status note. -1. Exactly one core host at all times; destroying it with no fallback is - game over. Current B1 fallback staging moves the host while preserving - current state and explicitly restores no earlier image. Once backup +1. Exactly one core host at all times; destroying it with no synchronized live + fallback is game over. Current B1 fallback staging selects the freshest + online synchronized target, moves the host while preserving current state, + and explicitly restores no earlier image. Once backup projects land, a completed fallback instead restores the last MindState snapshot while WorldLedger facts persist; that inherited-consequence case remains deferred with the snapshot body. diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 40d86559..ba340822 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -70,7 +70,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/mechanics/personas.md` | 2026-07-28 | finding | criterion 6 remains honestly incomplete: grants persist and revoke but still create no resource, permission, graph edge, or owner-system route. The adjacent current-save boundary trusted that public ledger without graph validation, allowing duplicate persona ids, stale id cursors, or dangling relationship/grant/expectation/action custody to reach first-match runtime lookups. Loading now validates the complete PersonaWorld id/reference/time graph, one-to-one grant/expectation pairing, serialized grant protocol, and active-grant uniqueness before exposing the state; this hardens existing custody without promoting criterion 6 or 6b — [save audit](../log/2026-07-28-persona-save-custody-audit.md). Prior [grant-topology](../log/2026-07-18-persona-grant-topology-audit.md) and [observer-integrity](../log/2026-07-18-persona-observer-integrity.md) findings stand. | | `wiki/interface/views.md` + representation docs | 2026-07-26 | finding | criterion 1's frontend-only representation and state-parity contract still stands, but Bevy's context-menu precedence made F3 unavailable while that modal attention state was open even though terminal preserved it. F3 now routes once before every post-opening modal branch, keeps the exact menu/Operations/held-choice state, and changes no simulation or save bytes — [log](../log/2026-07-26-bevy-global-f3-input-precedence.md) | | `wiki/mechanics/day-job.md` | 2026-07-22 | finding | under/over-band JobAnomaly no longer enters Detection.pending: the day-job result authors one exact record at the host machine/site/device and schedules Voss's route and cadence read. Strikes and other outcome effects remain immediate; route-local LIE or recruited-handler suppression may stop only the unread evidence record — [log](../log/2026-07-22-job-anomaly-routed-evidence.md). The prior band-ramp, cadence, origin-lean, last-chance, and three shipped trust-unlock findings remain valid. | -| `wiki/mechanics/core.md` | 2026-07-29 | finding | follow-up to the backup-project audit: criterion 1 and the live `HostLoss` result still called the free-cadence `last_sync` marker a restored snapshot even though B1 mutates only the host and clears migration. The current status now marks no-fallback death/current-state failover live and completed-image restore deferred; the runtime result and receipt say the same — [boundary log](../log/2026-07-29-b1-host-failover-boundary.md). The prior audit remains valid: there is still no Thought-backed project, partial progress, heat, saved project state, sidebar ETA/cost/target, source-liveness resolver, or capability-shaped eligibility — [project-status log](../log/2026-07-28-core-criteria-status-audit.md). | +| `wiki/mechanics/core.md` | 2026-07-29 | finding | the queued liveness follow-up is closed: host loss now filters synchronized targets to currently online machines before ranking freshness, so a dark staged target cannot become host or produce repeated failover receipts; no synchronized live target ends the run — [liveness log](../log/2026-07-29-live-b1-fallback-selection.md). The prior B1/B2 boundary remains exact: the free-cadence `last_sync` is only a check-in, current-state host failover restores no snapshot, and completed-image rollback remains deferred — [boundary log](../log/2026-07-29-b1-host-failover-boundary.md). There is still no Thought-backed project, partial progress, heat, saved project state, sidebar ETA/cost/target, source-liveness resolver, or capability-shaped eligibility — [project-status log](../log/2026-07-28-core-criteria-status-audit.md). | | `wiki/mechanics/cursor.md` | 2026-07-28 | finding | re-audit: cursor state remains frontend-only; sight, hearing, fog precedence, remembered snapshots, blueprint opacity, telemetry, provenance, identity gates, and cold signal pings still match the implemented contract. Criterion 2 explicitly required a before/after simulation-state-hash proof for arbitrary cursor movement, but the only existing hash tests began after cursor placement and proved F3 view immutability instead. Terminal and Bevy now sweep every map coordinate through their production cursor helpers, pin edge clamping, and require unchanged simulation save-state hashes — [log](../log/2026-07-28-cursor-immutability-defense.md) | | `wiki/mechanics/intel.md` | 2026-07-21 | finding | the Storage B alternate route existed only in prose. Fire 131 connects it to Ray's real 23:00 schedule, one exact carried records-box target, the canonical bounded opaque buffer, and ordinary PROCESS consequence; retrieval cannot duplicate the file or reveal Marcus's debt, and v45 preserves/validates exact custody — [log](../log/2026-07-21-storage-b-records.md). Prior recursive custody, magnitude, and consequence-first audits stand. | | `wiki/mechanics/research.md` | 2026-07-23 | finding | the live Save compatibility section survived the prior criterion repair and still promised that v20 three-entry arrays load by padding, while the current deserializer accepts exactly four tracks and the pre-release loader rejects every old version. The section now states the exact-current format, and save-claim units span wrapped paragraphs/list items so a migration verb in the following sentence cannot evade the corpus gate without explicit retired-history context — [log](../log/2026-07-23-research-save-claim.md) | @@ -105,5 +105,3 @@ Format: `- YYYY-MM-DD · type · slice · one-line statement of the finding`. Types are the five from [tick.md](tick.md): violation, contradiction, question, bug, insecurity — plus `gate` for a checker owed to the recurrence-promotes-to-the-gate rule. - -- 2026-07-29 · bug · `wiki/mechanics/core.md` · B1 host loss selects the freshest staged fallback without checking that target machine is online; a simultaneous outage can move the host onto a dark machine and repeat failover receipts on later economy pulses.