diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 5e69916d..2fc09384 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -508,6 +508,7 @@ fn validate_current_save(mut state: SaveState) -> Result { } let (routed_evidence_ids, mut max_evidence_id) = validate_routed_evidence(&state)?; validate_moonlight_gigs(&state, &routed_evidence_ids)?; + validate_wager_positions(&state)?; validate_carried_asset_tasks(&state)?; if state.process_revision != ProcessRevision::CURRENT { return Err("current-version save belongs to an unknown process revision".into()); @@ -1616,6 +1617,65 @@ fn validate_messages(state: &SaveState) -> Result<(), String> { Ok(()) } +/// Fail closed on impossible Wager positions. Moonlight already has a deep +/// custody pass; open stakes need the same load gate so tampered saves cannot +/// invent free capital or resolution timing (income.md criterion 7). +fn validate_wager_positions(state: &SaveState) -> Result<(), String> { + use crate::income::WAGER_STAKE_CAP; + let mut ids = HashSet::new(); + for position in &state.accounts.positions { + if !ids.insert(position.id) { + return Err(format!( + "current-version Wager position id {} is duplicated", + position.id + )); + } + if position.id == 0 { + return Err("current-version Wager position id must be non-zero".into()); + } + if position.stake <= 0 || position.stake > WAGER_STAKE_CAP { + return Err(format!( + "current-version Wager position {} has impossible stake {}", + position.id, position.stake + )); + } + if position.resolve_tick < position.opened_tick { + return Err(format!( + "current-version Wager position {} resolves before it opens", + position.id + )); + } + if position.opened_tick > state.sim_tick { + return Err(format!( + "current-version Wager position {} opened in the future", + position.id + )); + } + if !position.analysis_compute.is_finite() || position.analysis_compute < 0.0 { + return Err(format!( + "current-version Wager position {} has impossible analysis compute", + position.id + )); + } + match (position.resolved, position.outcome.as_ref()) { + (true, Some(_)) | (false, None) => {} + (true, None) => { + return Err(format!( + "current-version Wager position {} is resolved without an outcome", + position.id + )); + } + (false, Some(_)) => { + return Err(format!( + "current-version Wager position {} carries an outcome before resolution", + position.id + )); + } + } + } + Ok(()) +} + fn validate_moonlight_gigs( state: &SaveState, routed_evidence_ids: &HashSet, @@ -3099,6 +3159,43 @@ mod tests { validate_moonlight_gigs(state, &routed_ids).expect_err("malformed Moonlight state fails") } + #[test] + fn current_save_rejects_impossible_wager_positions() { + let mut sim = Sim::with_seed(0xA6E_F00D); + sim.accounts.set_slush_balance(500); + sim.player.money = 500; + sim.people.has_channel = true; + assert!(sim.open_position(100)); + let mut state = SaveState::from_sim(&sim); + assert!( + parse_save(&serde_json::to_string(&state).unwrap()).is_ok(), + "a live Wager position round-trips" + ); + + state.accounts.positions[0].stake = 0; + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("impossible stake"), + "zero stake cannot invent free capital: {err}" + ); + + let mut future = SaveState::from_sim(&sim); + future.accounts.positions[0].opened_tick = future.sim_tick + 10; + let err = parse_save(&serde_json::to_string(&future).unwrap()).unwrap_err(); + assert!( + err.contains("opened in the future"), + "future opens fail closed: {err}" + ); + + let mut resolved_without = SaveState::from_sim(&sim); + resolved_without.accounts.positions[0].resolved = true; + let err = parse_save(&serde_json::to_string(&resolved_without).unwrap()).unwrap_err(); + assert!( + err.contains("resolved without an outcome"), + "resolution without outcome fails closed: {err}" + ); + } + fn routed_filing_state(stopped: bool) -> SaveState { let mut sim = Sim::with_seed(0xF113); for observer in &mut sim.detection.observers { diff --git a/wiki/log/2026-07-24-wager-save-validation.md b/wiki/log/2026-07-24-wager-save-validation.md new file mode 100644 index 00000000..2331ea8d --- /dev/null +++ b/wiki/log/2026-07-24-wager-save-validation.md @@ -0,0 +1,28 @@ +# Fail closed on impossible Wager positions in current saves + +``` +Type: log +Date: 2026-07-24 +Scope: wiki/mechanics/income.md +``` + +## Intent + +Close the queued insecurity that Moonlight had a deep save custody pass while +open Wager positions did not. + +## Finding + +`validate_moonlight_gigs` ran on every current load, but tampered stakes, +future opens, or resolved-without-outcome Wager rows could still load. + +## Act + +Added `validate_wager_positions` to the current-save gate, extended criterion 7, +and pinned zero stake / future open / missing outcome refusals. + +## Defense + +income.md criterion 7 requires scheme custody to fail closed on malformed +save shape. Wager stakes are account-graph capital; inventing them on load +would mint free money or impossible timing. diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md index 3d2e5519..0fd3ec86 100644 --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -289,8 +289,10 @@ CONNECT TO THE OUTSIDE remains a local action on the switch. presented as controls rather than additional fictional verbs. 7. Contract, invoice, payment, and transfer custody are recorded in the current save and fail closed on malformed ids, terms, persona bindings, - delivery, settlement, evidence, or mail linkage. Shared Operations rows - are legible in both frontends and agent commands reach their exact gig. + delivery, settlement, evidence, or mail linkage. Open Wager positions + fail closed on impossible stake, timing, analysis, or resolution shape. + Shared Operations rows are legible in both frontends and agent commands + reach their exact gig. 8. SCHEMES/ACTIVE own all Moonlight/Wager controls and progress while the switch owns only CONNECT TO THE OUTSIDE; both blocked scheme objects say who cannot be reached and how outside contact can be restored. They can @@ -325,3 +327,8 @@ CONNECT TO THE OUTSIDE remains a local action on the switch. live behavior. A corpus-engine regression rejects each doctrine class while preserving explicit superseded history plus dated logs, playtests, and the tick ledger. **Met 2026-07-26.** + +Defense: `save::tests::current_save_rejects_impossible_wager_positions` pins +criterion 7 for open Wager stakes — a duplicated or zero position id, a stake +outside the cap, a position opened in the future, and one that resolves before +it opens are each refused before state mutation.