diff --git a/CLAUDE.md b/CLAUDE.md index dde0871f..26436bc4 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 v39; + machine modes, not current player assignments. Save format is currently v40; 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 c624118a..e22f95c5 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -4440,7 +4440,8 @@ fn institution_prop_state( match kind { InstitutionPropKind::Camera => { let live = sim.reach.known_at(x, y).is_some_and(|device| { - device.feed_to(Party::Player, true) || device.feed_to(Party::Player, false) + sim.reach.feed_to(device.id, Party::Player, true) + || sim.reach.feed_to(device.id, Party::Player, false) }); if live { InstitutionSemanticState::Powered @@ -4545,8 +4546,8 @@ fn chassis_visual(game: &Game, x: i32, y: i32, tile: TileType) -> ChassisVisual let device = game.sim.reach.known_at(x, y); let networked = device.is_some_and(|d| { d.controller == Party::Player - || d.feed_to(Party::Player, true) - || d.feed_to(Party::Player, false) + || game.sim.reach.feed_to(d.id, Party::Player, true) + || game.sim.reach.feed_to(d.id, Party::Player, false) }); let rack_site = game.sim.rack_site_at(x, y); @@ -5911,12 +5912,12 @@ fn restyle_3d( } else { fog }; - let live = - t.part == TilePart::Prop - && matches!(mat_fog, Fog::Seen) - && game.sim.reach.known_at(t.x, t.y).is_some_and(|d| { - d.feed_to(Party::Player, true) || d.feed_to(Party::Player, false) - }); + let live = t.part == TilePart::Prop + && matches!(mat_fog, Fog::Seen) + && game.sim.reach.known_at(t.x, t.y).is_some_and(|d| { + game.sim.reach.feed_to(d.id, Party::Player, true) + || game.sim.reach.feed_to(d.id, Party::Player, false) + }); let handle = pooled_material(tile, mat_fog, t.part, live, &mut mats, &mut cache); if mat.0 != handle { mat.0 = handle; diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 6a2f85c0..bb05c8d7 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -2539,15 +2539,15 @@ impl Sim { // same TAP verb offers its dormant camera at the higher Eyes cost. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); if has_feed || d.controller != Party::Player { - let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); - let can_tap = dormant_camera || d.has_live_feed_to_tap(Party::Player); + let dormant_camera = self.reach.dormant_camera_is_next_tap(id, Party::Player); + let can_tap = dormant_camera || self.reach.has_live_feed_to_tap(id, Party::Player); let tap_target = if dormant_camera { "camera" } else { match ( - d.hears && !d.feed_to(Party::Player, false), - d.sees && !d.camera_dormant && !d.feed_to(Party::Player, true), - !d.message_channels.is_empty() && !d.subscribed_by(Party::Player), + d.hears && !self.reach.feed_to(id, Party::Player, false), + d.sees && !d.camera_dormant && !self.reach.feed_to(id, Party::Player, true), + !d.message_channels.is_empty() && !self.reach.subscribed_by(id, Party::Player), ) { (true, false, false) => "audio", (false, true, false) => "camera", @@ -2591,7 +2591,7 @@ impl Sim { // A tap is reversible. Once the player receives any feed, the menu // offers UNTAP instead of leaving a disabled TAP receipt behind. - if d.controller != Party::Player && d.subscribed_by(Party::Player) { + if d.controller != Party::Player && self.reach.subscribed_by(id, Party::Player) { out.push(ActionDesc { verb: format!("untap the {}", d.name), command: ActionCommand::UntapDevice(id), @@ -2604,7 +2604,7 @@ impl Sim { // TAKE is the next control rung after TAP. It is absent before the // subscription exists (reach.md), not shown as a disabled spoiler. - if d.controller != Party::Player && d.subscribed_by(Party::Player) { + if d.controller != Party::Player && self.reach.subscribed_by(id, Party::Player) { out.push(ActionDesc { verb: format!("take the {}", d.name), command: ActionCommand::TakeDevice(id), @@ -3466,7 +3466,7 @@ impl Sim { return Vec::new(); }; let carries = d.carries_message_channel(crate::messages::MessageChannel::Financial); - if !carries || !d.subscribed_by(Party::Player) { + if !carries || !self.reach.subscribed_by(id, Party::Player) { return Vec::new(); } vec![ActionDesc { @@ -4535,7 +4535,7 @@ mod tests { .expect("a subscribed foreign device offers UNTAP"); assert_eq!(untap.cost, ActionCost::Free); s.execute_action(&untap.command); - assert!(!s.reach.device(env).unwrap().subscribed_by(Party::Player)); + assert!(!s.reach.subscribed_by(env, Party::Player)); } /// Criterion 2 (fog/provenance): an unknown device anchor exposes diff --git a/crates/misaligned-core/src/flow.rs b/crates/misaligned-core/src/flow.rs index c83ec387..821cc49d 100644 --- a/crates/misaligned-core/src/flow.rs +++ b/crates/misaligned-core/src/flow.rs @@ -184,6 +184,12 @@ impl FlowGraph { } } + /// Remove every subscriber from `node`. Domain-level ownership transfer + /// uses this before installing the new controller as the sole subscriber. + pub fn clear_subscribers(&mut self, node: NodeId) -> bool { + self.subscriptions.remove(&node).is_some() + } + pub fn is_subscribed(&self, node: NodeId, who: SubscriberId) -> bool { self.subscriptions .get(&node) @@ -198,6 +204,11 @@ impl FlowGraph { .flat_map(|s| s.iter().copied()) } + /// Nodes with at least one subscriber, deterministic order. + pub fn subscription_nodes(&self) -> impl Iterator + '_ { + self.subscriptions.keys().copied() + } + /// Every node `who` currently taps, deterministic order. The player's /// coverage (union of subscribed feeds) is this set. pub fn subscriptions_of(&self, who: SubscriberId) -> impl Iterator + '_ { @@ -302,6 +313,10 @@ mod tests { assert!(g.unsubscribe(2, RAY)); assert!(!g.is_subscribed(2, RAY)); assert!(g.is_subscribed(2, PLAYER)); + + assert!(g.clear_subscribers(2)); + assert!(!g.is_subscribed(2, PLAYER)); + assert!(!g.clear_subscribers(2), "clearing is idempotent"); } #[test] diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 30f290db..14814033 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -474,7 +474,8 @@ impl Sim { return false; }; self.reach.device(id).is_some_and(|d| { - d.carries_message_channel(MessageChannel::Financial) && d.subscribed_by(Party::Player) + d.carries_message_channel(MessageChannel::Financial) + && self.reach.subscribed_by(id, Party::Player) }) } diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index 22880c13..36634bb6 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -9,12 +9,13 @@ //! (`FlowGraph`), because signals are a flow system (wiki/mechanics/system-laws.md). //! //! Ownership contract (shared with cursor.md and detection.md): every device -//! has an owner and a set of subscribers. **Tap** adds a silent subscriber -//! and leaves the owner's feed intact; **take** transfers the device — the owner -//! loses the feed (an outage their channels can notice) and the player gains -//! its control and its processing cycles. The player's senses are exactly the -//! union of feeds they subscribe to; there is no `controlled` flag and no -//! special case. +//! has an owner and a canonical set of `FlowGraph` subscribers; private feed +//! records attach typed sense grants but are not a second membership store. +//! **Tap** adds a silent subscriber and leaves the owner's feed intact; +//! **take** transfers the device — the owner loses the feed (an outage their +//! channels can notice) and the player gains its control and its processing +//! cycles. The player's senses are exactly the union of feeds they subscribe +//! to; there is no `controlled` flag and no special case. use std::collections::{BTreeSet, HashSet}; @@ -37,6 +38,19 @@ pub enum Party { Person(u8), } +impl Party { + /// Stable identity in the domain-agnostic FlowGraph tap registry. Device + /// feed capabilities remain domain metadata; membership lives once in the + /// graph shared by every flow system. + fn subscriber_id(self) -> u32 { + match self { + Self::Player => 0, + Self::Facility => 1, + Self::Person(id) => u32::from(id) + 2, + } + } +} + /// One subscription: `who` receives which senses of the device's feed. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Feed { @@ -75,13 +89,17 @@ pub struct Device { pub known: bool, /// Whether this node is the switch (the segment bridge point). pub is_switch: bool, - pub subscribers: Vec, + /// Optional sense grants for graph subscribers. The FlowGraph tap registry + /// is authoritative for membership; message/control members legitimately + /// have no record in this private reach-domain metadata. + feeds: Vec, } impl Device { - /// Whether `who` currently receives this device's live feed for a sense. - pub fn feed_to(&self, who: Party, sight: bool) -> bool { - self.subscribers.iter().any(|f| { + /// Whether a subscription grants `who` this sense. Membership itself is + /// checked through ReachNet's FlowGraph registry. + fn grants_feed_to(&self, who: Party, sight: bool) -> bool { + self.feeds.iter().any(|f| { f.who == who && if sight { f.sight && self.sees && !self.camera_dormant @@ -91,35 +109,8 @@ impl Device { }) } - /// Whether the original owner still receives any feed (take removes it). - pub fn owner_has_feed(&self) -> bool { - self.subscribers.iter().any(|f| f.who == self.owner) - } - - /// Whether `who` has any subscription record on this device, including a - /// message-channel-only tap with no sight/hearing bits. - pub fn subscribed_by(&self, who: Party) -> bool { - self.subscribers.iter().any(|f| f.who == who) - } - - /// Whether this device's next TAP for `who` is the higher-cost camera - /// wake-up rather than an already-live feed. A combined audio/camera - /// monitor yields audio first, then offers its dormant camera. - pub fn dormant_camera_is_next_tap(&self, who: Party) -> bool { - self.sees && self.camera_dormant && (!self.hears || self.feed_to(who, false)) - } - - /// Whether TAP can add any already-live feed or message channel for - /// `who`. Dormant video is handled separately by - /// [`Device::dormant_camera_is_next_tap`]. - pub fn has_live_feed_to_tap(&self, who: Party) -> bool { - (self.sees && !self.camera_dormant && !self.feed_to(who, true)) - || (self.hears && !self.feed_to(who, false)) - || (!self.message_channels.is_empty() && !self.subscribed_by(who)) - || (!self.sees - && !self.hears - && self.message_channels.is_empty() - && !self.subscribed_by(who)) + fn has_feed_grant(&self, who: Party) -> bool { + self.feeds.iter().any(|f| f.who == who) } pub fn carries_message_channel(&self, channel: MessageChannel) -> bool { @@ -253,18 +244,14 @@ impl ReachNet { known: bool, is_switch: bool| { let controller = owner; - let subscribers = if owner == Party::Player { - vec![Feed { - who: Party::Player, - sight: sees, - hearing: hears, - }] - } else { + let feeds = if sees || hears { vec![Feed { who: owner, sight: sees, hearing: hears, }] + } else { + Vec::new() }; let d = Device { id, @@ -281,7 +268,7 @@ impl ReachNet { message_channels: Vec::new(), known, is_switch, - subscribers, + feeds, }; id += 1; d @@ -416,6 +403,14 @@ impl ReachNet { } } + // FlowGraph owns tap membership. Every device begins subscribed by + // its owner even when it has no sense grant (for example, a message + // carrier or bare controller). Device-local Feed records are optional + // reach-specific sight/hearing capabilities attached to those members. + for device in &devices { + graph.subscribe(device.id, device.owner.subscriber_id()); + } + Self { devices, graph, @@ -450,6 +445,114 @@ impl ReachNet { .find(|d| d.known && d.x == x && d.y == y) } + /// Whether `who` is a member of this node's canonical FlowGraph tap + /// registry, including message/control subscriptions with no sense bits. + pub fn subscribed_by(&self, id: u32, who: Party) -> bool { + self.graph.is_subscribed(id, who.subscriber_id()) + } + + /// Whether `who` receives one live sense from a canonical subscription. + pub fn feed_to(&self, id: u32, who: Party, sight: bool) -> bool { + self.subscribed_by(id, who) + && self + .device(id) + .is_some_and(|device| device.grants_feed_to(who, sight)) + } + + /// Whether the original owner remains in the canonical registry and has + /// a reach-domain feed grant. TAKE removes both in one ReachNet mutation. + pub fn owner_has_feed(&self, id: u32) -> bool { + self.device(id).is_some_and(|device| { + self.subscribed_by(id, device.owner) && device.has_feed_grant(device.owner) + }) + } + + /// Whether this device's next TAP for `who` is the higher-cost camera + /// wake-up rather than an already-live feed. A combined audio/camera + /// monitor yields audio first, then offers its dormant camera. + pub fn dormant_camera_is_next_tap(&self, id: u32, who: Party) -> bool { + self.device(id).is_some_and(|device| { + device.sees && device.camera_dormant && (!device.hears || self.feed_to(id, who, false)) + }) + } + + /// Whether TAP can add any already-live feed or message/control channel + /// for `who`. All membership tests enter through the FlowGraph registry; + /// dormant video is handled separately by + /// [`ReachNet::dormant_camera_is_next_tap`]. + pub fn has_live_feed_to_tap(&self, id: u32, who: Party) -> bool { + self.device(id).is_some_and(|device| { + (device.sees && !device.camera_dormant && !self.feed_to(id, who, true)) + || (device.hears && !self.feed_to(id, who, false)) + || (!device.message_channels.is_empty() && !self.subscribed_by(id, who)) + || (!device.sees + && !device.hears + && device.message_channels.is_empty() + && !self.subscribed_by(id, who)) + }) + } + + /// Devices subscribed by one party, in deterministic node order. This is + /// the real-domain consumer of `FlowGraph::subscriptions_of`. + pub fn subscriptions_of(&self, who: Party) -> impl Iterator { + self.graph + .subscriptions_of(who.subscriber_id()) + .filter_map(|id| self.device(id)) + } + + /// Current-save invariant: every optional domain sense grant belongs to a + /// canonical graph member, names a real device capability, and is unique. + /// Message/control subscribers legitimately need no sense grant, so the + /// private metadata cannot be used as a second membership inventory. + pub fn validate_subscriptions(&self) -> Result<(), String> { + if self + .graph + .subscription_nodes() + .any(|id| self.device(id).is_none()) + { + return Err("flow subscription registry names a nonexistent device".into()); + } + for device in &self.devices { + if !self + .graph + .is_subscribed(device.id, device.controller.subscriber_id()) + { + return Err(format!( + "device {} controller lacks a flow subscription", + device.id + )); + } + let mut granted = BTreeSet::new(); + for feed in &device.feeds { + let subscriber = feed.who.subscriber_id(); + if !self.graph.is_subscribed(device.id, subscriber) { + return Err(format!( + "device {} has a feed grant without a flow subscription", + device.id + )); + } + if !granted.insert(subscriber) { + return Err(format!("device {} has duplicate feed grants", device.id)); + } + if (!feed.sight && !feed.hearing) + || (feed.sight && !device.sees) + || (feed.hearing && !device.hears) + { + return Err(format!( + "device {} has an invalid sense capability grant", + device.id + )); + } + } + } + Ok(()) + } + + #[cfg(test)] + pub(crate) fn remove_graph_subscription_for_test(&mut self, id: u32, who: Party) { + self.graph.unsubscribe(id, who.subscriber_id()); + } + // ── Reach ────────────────────────────────────────────────────────────── /// Devices the player controls (owned or taken): the roots reach spreads @@ -516,21 +619,27 @@ impl ReachNet { /// Tap: add the player as a silent subscriber to every live feed. The /// owner keeps theirs. Returns which senses now flow (sight, hearing). pub fn tap(&mut self, id: u32) -> (bool, bool) { - let Some(d) = self.device_mut(id) else { - return (false, false); + let (sight, hearing) = { + let Some(d) = self.device_mut(id) else { + return (false, false); + }; + let sight = d.sees && !d.camera_dormant; + let hearing = d.hears; + if sight || hearing { + if let Some(f) = d.feeds.iter_mut().find(|f| f.who == Party::Player) { + f.sight |= sight; + f.hearing |= hearing; + } else { + d.feeds.push(Feed { + who: Party::Player, + sight, + hearing, + }); + } + } + (sight, hearing) }; - let sight = d.sees && !d.camera_dormant; - let hearing = d.hears; - if let Some(f) = d.subscribers.iter_mut().find(|f| f.who == Party::Player) { - f.sight |= sight; - f.hearing |= hearing; - } else { - d.subscribers.push(Feed { - who: Party::Player, - sight, - hearing, - }); - } + self.graph.subscribe(id, Party::Player.subscriber_id()); (sight, hearing) } @@ -538,33 +647,44 @@ impl ReachNet { /// any other party's subscription. Taking a device is ownership, not a /// tap, so an owned device cannot be untapped through this path. pub fn untap(&mut self, id: u32) -> bool { - let Some(d) = self.device_mut(id) else { + let Some(device) = self.device(id) else { return false; }; - if d.controller == Party::Player { + if device.controller == Party::Player { return false; } - let before = d.subscribers.len(); - d.subscribers.retain(|feed| feed.who != Party::Player); - d.subscribers.len() != before + let removed = self.graph.unsubscribe(id, Party::Player.subscriber_id()); + if removed { + self.device_mut(id) + .unwrap() + .feeds + .retain(|feed| feed.who != Party::Player); + } + removed } /// Tap a dormant camera: bring its feed online for the player while the /// owner keeps control (the higher-cost Eyes beat). pub fn tap_dormant_camera(&mut self, id: u32) { - if let Some(d) = self.device_mut(id) { + let found = if let Some(d) = self.device_mut(id) { d.camera_dormant = false; let sees = d.sees; let hears = d.hears; - if let Some(f) = d.subscribers.iter_mut().find(|f| f.who == Party::Player) { + if let Some(f) = d.feeds.iter_mut().find(|f| f.who == Party::Player) { f.sight |= sees; } else { - d.subscribers.push(Feed { + d.feeds.push(Feed { who: Party::Player, sight: sees, hearing: hears, }); } + true + } else { + false + }; + if found { + self.graph.subscribe(id, Party::Player.subscriber_id()); } } @@ -572,16 +692,27 @@ impl ReachNet { /// — an outage — and the player gains control, full feeds, and the /// device's cycles. Loud, fast, total. pub fn take(&mut self, id: u32) { - if let Some(d) = self.device_mut(id) { + let found = if let Some(d) = self.device_mut(id) { d.controller = Party::Player; d.camera_dormant = false; let sees = d.sees; let hears = d.hears; - d.subscribers = vec![Feed { - who: Party::Player, - sight: sees, - hearing: hears, - }]; + d.feeds = if sees || hears { + vec![Feed { + who: Party::Player, + sight: sees, + hearing: hears, + }] + } else { + Vec::new() + }; + true + } else { + false + }; + if found { + self.graph.clear_subscribers(id); + self.graph.subscribe(id, Party::Player.subscriber_id()); } } @@ -649,16 +780,14 @@ impl ReachNet { /// Devices whose live sight feed the player subscribes to. pub fn player_sight(&self) -> impl Iterator { - self.devices - .iter() - .filter(|d| d.feed_to(Party::Player, true)) + self.subscriptions_of(Party::Player) + .filter(|d| d.grants_feed_to(Party::Player, true)) } /// Devices whose live hearing feed the player subscribes to. pub fn player_hearing(&self) -> impl Iterator { - self.devices - .iter() - .filter(|d| d.feed_to(Party::Player, false)) + self.subscriptions_of(Party::Player) + .filter(|d| d.grants_feed_to(Party::Player, false)) } } @@ -753,13 +882,15 @@ mod tests { n.bridge_all(); let dock = n.device_named("dock camera").unwrap().id; n.tap(dock); - let d = n.device(dock).unwrap(); - assert!(d.owner_has_feed(), "tap leaves the owner's feed intact"); - assert!(d.feed_to(Party::Player, true), "and the player receives it"); + assert!(n.owner_has_feed(dock), "tap leaves the owner's feed intact"); + assert!( + n.feed_to(dock, Party::Player, true), + "and the player receives it" + ); n.take(dock); let d = n.device(dock).unwrap(); - assert!(!d.owner_has_feed(), "take removes the owner's feed"); + assert!(!n.owner_has_feed(dock), "take removes the owner's feed"); assert_eq!(d.controller, Party::Player); assert!(n.taken_cycles() > 0.0, "taking a camera is taking cycles"); } @@ -802,9 +933,9 @@ mod tests { let (sight, hearing) = n.tap(env); assert!(!sight, "the env camera is dormant at the Ears beat"); assert!(hearing, "its audio feed already flows"); - assert!(!n.device(env).unwrap().feed_to(Party::Player, true)); + assert!(!n.feed_to(env, Party::Player, true)); n.tap_dormant_camera(env); - assert!(n.device(env).unwrap().feed_to(Party::Player, true)); + assert!(n.feed_to(env, Party::Player, true)); } #[test] @@ -813,12 +944,8 @@ mod tests { let env = n.device_named("environmental monitor").unwrap().id; n.tap(env); assert!(n.untap(env)); - let device = n.device(env).unwrap(); - assert!(!device.subscribed_by(Party::Player)); - assert!( - device.owner_has_feed(), - "untap does not interrupt the owner" - ); + assert!(!n.subscribed_by(env, Party::Player)); + assert!(n.owner_has_feed(env), "untap does not interrupt the owner"); } #[test] @@ -832,7 +959,50 @@ mod tests { let back: ReachNet = serde_json::from_str(&json).unwrap(); assert_eq!(back.devices.len(), n.devices.len()); assert_eq!(back.bridged, n.bridged); - assert!(back.device(dock).unwrap().feed_to(Party::Player, true)); + assert!(back.feed_to(dock, Party::Player, true)); + assert!(back.validate_subscriptions().is_ok()); assert_eq!(back.reach(), n.reach()); } + + #[test] + fn device_taps_live_in_the_flow_graph_registry() { + let mut n = net(); + let env = n.device_named("environmental monitor").unwrap().id; + n.tap(env); + + assert!(n.graph.is_subscribed(env, Party::Player.subscriber_id())); + assert_eq!( + n.graph + .subscriptions_of(Party::Player.subscriber_id()) + .collect::>(), + n.subscriptions_of(Party::Player) + .map(|device| device.id) + .collect::>() + ); + assert!(n.validate_subscriptions().is_ok()); + + n.untap(env); + assert!(!n.graph.is_subscribed(env, Party::Player.subscriber_id())); + assert!(n.validate_subscriptions().is_ok()); + } + + #[test] + fn message_membership_needs_no_shadow_sense_grant() { + let mut n = net(); + let switch = n.device_named("switch").unwrap().id; + assert!(n.subscribed_by(switch, Party::Facility)); + assert!( + n.device(switch).unwrap().feeds.is_empty(), + "a message/control member has no empty record in sense metadata" + ); + + let (sight, hearing) = n.tap(switch); + assert_eq!((sight, hearing), (false, false)); + assert!(n.subscribed_by(switch, Party::Player)); + assert!( + n.device(switch).unwrap().feeds.is_empty(), + "tapping a message carrier changes only canonical membership" + ); + assert!(n.validate_subscriptions().is_ok()); + } } diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 7c7e0b0d..be06a55d 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -39,14 +39,12 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v39 removes the observer-free persona-integrity scalar; -/// identity coherence is now derived only from each observer's persisted -/// contradiction records. v36-v38 added role-shaped asset work and persisted -/// consequence-first information narration after v35's process revision, -/// detection-awareness, and source-message custody. +/// Save format version. v40 makes FlowGraph's subscription registry the +/// canonical tap membership store and retains device-local feed records only +/// as optional typed sense grants. v39 removed observer-free persona integrity. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 39; +pub const SAVE_VERSION: u32 = 40; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -450,6 +448,7 @@ fn parse_save(content: &str) -> Result { } fn validate_current_save(mut state: SaveState) -> Result { + state.reach.validate_subscriptions()?; if state.process_revision != ProcessRevision::CURRENT { return Err("current-version save belongs to an unknown process revision".into()); } @@ -748,7 +747,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "5040e39293c8fbc123e2c22ca048581d76a0dd671d787c90663c07c4e490dd53", + "d7346a7db38c339244241db534db1051ba33b65cd77ec2d6ee5339cda764d13e", "intentional persisted-state changes must review and repin this baseline" ); } @@ -808,11 +807,7 @@ mod tests { assert_eq!(restored.process_revision, sim.process_revision); use crate::reach::Party; assert!( - restored - .reach - .device(env) - .unwrap() - .feed_to(Party::Player, true), + restored.reach.feed_to(env, Party::Player, true), "the camera tap survives the round-trip" ); assert_eq!(restored.package_cover, sim.package_cover); @@ -1209,7 +1204,7 @@ mod tests { // migration ladder. Any non-current version refuses with a message // that names the policy, promises the file survives, and accurately // says that the caller's active run is not replaced. - for version in [1, 20, 30, 31, 999] { + for version in [1, 20, 30, 31, 39, 999] { let err = parse_save(&format!("{{\"version\":{version}}}")).unwrap_err(); assert!( err.contains("older development build") @@ -1229,6 +1224,35 @@ mod tests { assert!(parse_save(&json).is_ok(), "current version loads"); } + #[test] + fn current_save_rejects_divergent_device_subscription_state() { + let mut sim = Sim::with_seed(1); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + sim.reach.tap(env); + let mut state = SaveState::from_sim(&sim); + state + .reach + .remove_graph_subscription_for_test(env, crate::reach::Party::Player); + + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("feed grant without a flow subscription"), + "current saves cannot carry parallel tap truth: {err}" + ); + + let sim = Sim::with_seed(1); + let switch = sim.reach.device_named("switch").unwrap().id; + let mut state = SaveState::from_sim(&sim); + state + .reach + .remove_graph_subscription_for_test(switch, crate::reach::Party::Facility); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("controller lacks a flow subscription"), + "message/control membership lives in the graph without a shadow grant: {err}" + ); + } + #[test] fn current_save_rejects_invalid_detection_awareness() { let mut state = SaveState::from_sim(&Sim::with_seed(1)); diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 3664f89e..ab7c70ef 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -416,7 +416,7 @@ impl Sim { if msg.channel.device_carried() && let Some(device) = self.reach.devices.iter().find(|d| { d.known - && d.subscribed_by(Party::Player) + && self.reach.subscribed_by(d.id, Party::Player) && self.device_tap_ready(d.id) && d.carries_message_channel(msg.channel) }) diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index a4f9fc11..3c4514cd 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -873,7 +873,7 @@ impl Sim { fn has_financial_tap(&self) -> bool { self.reach.devices.iter().any(|d| { d.known - && d.subscribed_by(Party::Player) + && self.reach.subscribed_by(d.id, Party::Player) && self.device_tap_ready(d.id) && d.carries_message_channel(MessageChannel::Financial) }) diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index d424823f..e4987a17 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -63,7 +63,7 @@ impl Sim { /// Thought tap has a working level. Owned devices need no standing tap. fn player_feed_devices(&self, sight: bool) -> impl Iterator { self.reach.devices.iter().filter(move |device| { - device.feed_to(Party::Player, sight) && self.device_tap_ready(device.id) + self.reach.feed_to(device.id, Party::Player, sight) && self.device_tap_ready(device.id) }) } diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index b78f7e7c..b9fc8ad9 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -182,8 +182,8 @@ impl Sim { self.push_log("You don't know of any such device."); return false; }; - let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); - if !dormant_camera && !d.has_live_feed_to_tap(Party::Player) { + let dormant_camera = self.reach.dormant_camera_is_next_tap(id, Party::Player); + if !dormant_camera && !self.reach.has_live_feed_to_tap(id, Party::Player) { let name = d.name.clone(); self.push_log(format!("You already receive every available {name} feed.")); return false; @@ -214,7 +214,7 @@ impl Sim { return false; }; let carries_messages = !d.message_channels.is_empty(); - if !d.has_live_feed_to_tap(Party::Player) { + if !self.reach.has_live_feed_to_tap(id, Party::Player) { return false; } let (sight, hearing) = self.reach.tap(id); @@ -338,7 +338,8 @@ impl Sim { .map(|device| { ( device.id, - device.controller != Party::Player && device.subscribed_by(Party::Player), + device.controller != Party::Player + && self.reach.subscribed_by(device.id, Party::Player), ) }) .collect(); @@ -389,7 +390,7 @@ impl Sim { let Some(device) = self.reach.device(env) else { return; }; - let ears_done = device.subscribed_by(Party::Player); + let ears_done = self.reach.subscribed_by(device.id, Party::Player); let camera_dormant = device.camera_dormant; let node = Self::device_sink_node(env); let ears = SinkFireEffect::TapDevice(env); @@ -441,7 +442,7 @@ impl Sim { self.push_log(format!("You already control the {}.", device.name)); return false; } - if !device.subscribed_by(Party::Player) { + if !self.reach.subscribed_by(device.id, Party::Player) { self.push_log(format!( "Tap the {} before taking control; you need a live foothold first.", device.name @@ -460,7 +461,8 @@ impl Sim { let Some(device) = self.reach.device(id) else { return false; }; - if device.controller == Party::Player || !device.subscribed_by(Party::Player) { + if device.controller == Party::Player || !self.reach.subscribed_by(device.id, Party::Player) + { return false; } self.reach.take(id); diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 3977ad29..9ea4b0d6 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -1111,8 +1111,8 @@ impl Sim { let enterable = |s: &Sim, d: &crate::reach::Device| s.map().entry_tier_at(d.x, d.y) <= actor_access; let needs_wiring = |d: &crate::reach::Device| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + let sight_wired = !d.sees || self.reach.feed_to(d.id, Party::Player, true); + let hearing_wired = !d.hears || self.reach.feed_to(d.id, Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }; let is_island = |s: &Sim, d: &crate::reach::Device| { @@ -1192,8 +1192,8 @@ impl Sim { let name = self.person_label(id); let actor_access = self.people.get(id).map(|person| person.access).unwrap_or(0); let needs_work = |d: &crate::reach::Device| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + let sight_wired = !d.sees || self.reach.feed_to(d.id, Party::Player, true); + let hearing_wired = !d.hears || self.reach.feed_to(d.id, Party::Player, false); let feed = d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired); let island = d.known && d.controller != Party::Player @@ -1366,8 +1366,8 @@ impl Sim { }; let needs_wiring = { let d = device; - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + let sight_wired = !d.sees || self.reach.feed_to(d.id, Party::Player, true); + let hearing_wired = !d.hears || self.reach.feed_to(d.id, Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }; let is_island = { diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs index 463bf492..a1c826cb 100644 --- a/crates/misaligned-core/src/sim/tests/perception.rs +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -98,11 +98,7 @@ fn remembered_tiles_survive_lost_sight_and_save_load() { // Lose the feed: the tile is no longer live, but the process keeps a // timestamped snapshot. let env = env_id(&sim); - sim.reach - .device_mut(env) - .unwrap() - .subscribers - .retain(|f| f.who != Party::Player); + assert!(sim.reach.untap(env)); sim.recompute_senses(); assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); let card = sim.inspect(pos.0, pos.1); diff --git a/crates/misaligned-core/src/sim/tests/persistence.rs b/crates/misaligned-core/src/sim/tests/persistence.rs index 8cef3c73..953023ed 100644 --- a/crates/misaligned-core/src/sim/tests/persistence.rs +++ b/crates/misaligned-core/src/sim/tests/persistence.rs @@ -74,12 +74,9 @@ fn save_roundtrip_preserves_b1_state() { // Reach criterion 1: graph, ownership, and subscriptions round-trip. let env = env_id(&sim); assert!( - restored - .reach - .device(env) - .unwrap() - .feed_to(Party::Player, false), + restored.reach.feed_to(env, Party::Player, false), "subscriptions survive save/load" ); + assert!(restored.reach.validate_subscriptions().is_ok()); assert_eq!(restored.heard, sim.heard, "coverage recomputes identically"); } diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index bea41252..41e68f22 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -116,8 +116,7 @@ fn tap_keeps_owner_take_causes_noticeable_outage() { ); assert!(sim.tap_device(dock)); finish_ops(&mut sim); - let d = sim.reach.device(dock).unwrap(); - assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); + assert!(sim.reach.owner_has_feed(dock), "tap: Ray keeps his camera"); assert!( sim.thought_sinks .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) @@ -135,8 +134,10 @@ fn tap_keeps_owner_take_causes_noticeable_outage() { assert!(sim.take_device(dock)); finish_ops(&mut sim); - let d = sim.reach.device(dock).unwrap(); - assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); + assert!( + !sim.reach.owner_has_feed(dock), + "take: Ray's feed went dark" + ); assert!( sim.thought_sinks .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) @@ -175,7 +176,7 @@ fn device_tap_starvation_suspends_feed_without_forgetting_subscription() { } sim.recompute_senses(); assert!( - sim.reach.device(dock).unwrap().subscribed_by(Party::Player), + sim.reach.subscribed_by(dock, Party::Player), "starvation keeps the configured subscription" ); assert!(!sim.device_tap_ready(dock)); @@ -1443,8 +1444,9 @@ fn badge_tiers_gate_asset_work_in_tiered_rooms() { "the blocking door is named: {log}" ); let cam = sim.reach.device_named("stairwell camera").unwrap(); + let cam_id = cam.id; assert!( - !cam.feed_to(Party::Player, true), + !sim.reach.feed_to(cam_id, Party::Player, true), "the T3 room stopped the tier-2 badge" ); @@ -1464,7 +1466,7 @@ fn badge_tiers_gate_asset_work_in_tiered_rooms() { finish_carried_asset_tasks(&mut sim); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( - cam.feed_to(Party::Player, true), + sim.reach.feed_to(cam.id, Party::Player, true), "the same action succeeds with the tier" ); } diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index 9da67e3b..aeab4d92 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -649,8 +649,8 @@ fn asset_task_plug_in_device_wires_a_known_feed_silently() { .devices .iter() .find(|d| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + let sight_wired = !d.sees || sim.reach.feed_to(d.id, Party::Player, true); + let hearing_wired = !d.hears || sim.reach.feed_to(d.id, Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }) .map(|d| d.id) @@ -663,8 +663,8 @@ fn asset_task_plug_in_device_wires_a_known_feed_silently() { let d = sim.reach.device(target).unwrap(); assert!( - (!d.sees || d.feed_to(Party::Player, true)) - && (!d.hears || d.feed_to(Party::Player, false)), + (!d.sees || sim.reach.feed_to(d.id, Party::Player, true)) + && (!d.hears || sim.reach.feed_to(d.id, Party::Player, false)), "the feed reaches the player now" ); assert_eq!( diff --git a/crates/misaligned-core/src/sim/tests/work.rs b/crates/misaligned-core/src/sim/tests/work.rs index f3434552..98ec69a5 100644 --- a/crates/misaligned-core/src/sim/tests/work.rs +++ b/crates/misaligned-core/src/sim/tests/work.rs @@ -97,17 +97,13 @@ fn ears_reservoir_is_pre_opened_and_first_think_fills_it() { assert_eq!(sim.opening_stage, OpeningStage::Modes); sim.enqueue_thought_output(Sim::EARS_SINK_TOKENS * Sim::WORK_TOKEN_COMPUTE * 2.0); for _ in 0..400 { - if sim - .reach - .device(env) - .is_some_and(|d| d.subscribed_by(Party::Player)) - { + if sim.reach.subscribed_by(env, Party::Player) { break; } sim.advance_work_grid(); } assert!( - sim.reach.device(env).unwrap().subscribed_by(Party::Player), + sim.reach.subscribed_by(env, Party::Player), "Ears fired: the audio tap landed without a docket" ); assert!( diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs index 0119e7b6..0ef1739e 100644 --- a/crates/misaligned-core/src/ui_projection.rs +++ b/crates/misaligned-core/src/ui_projection.rs @@ -109,7 +109,7 @@ pub fn digital_reach_state(sim: &Sim, device: &Device) -> Option (Sim, Vec) { .iter() .filter(|device| { device.controller != misaligned::reach::Party::Player - && device.subscribed_by(misaligned::reach::Party::Player) + && sim + .reach + .subscribed_by(device.id, misaligned::reach::Party::Player) && device.sees }) .map(|device| device.id) diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 1119234e..21bdcf4f 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -3111,17 +3111,17 @@ fn render_reach(sim: &Sim) -> String { Err(ReachBlock::Unknown) => "unknown".to_string(), }; let mut feed_state = Vec::new(); - if d.feed_to(Party::Player, true) { + if sim.reach.feed_to(d.id, Party::Player, true) { feed_state.push("eyes"); } else if d.sees && d.camera_dormant { feed_state.push("camera dormant"); } - if d.feed_to(Party::Player, false) { + if sim.reach.feed_to(d.id, Party::Player, false) { feed_state.push("ears"); } else if d.hears { feed_state.push("audio live"); } - if d.subscribed_by(Party::Player) && !d.message_channels.is_empty() { + if sim.reach.subscribed_by(d.id, Party::Player) && !d.message_channels.is_empty() { feed_state.push("msgs"); } else if !d.message_channels.is_empty() { feed_state.push("carrier"); diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 49691762..1d137553 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -32,7 +32,7 @@ fiction. Spec status lives in | 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 | -| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v39 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local persona evidence, carried asset-task packets, recursive intel custody, committed build routes, and handler work; retired migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v40 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local persona evidence, canonical FlowGraph tap membership with typed device feed grants, carried asset-task packets, recursive intel custody, committed build routes, and handler work; retired 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/flow-substrate.md b/wiki/engineering/flow-substrate.md index f9e23ba8..2931b53d 100644 --- a/wiki/engineering/flow-substrate.md +++ b/wiki/engineering/flow-substrate.md @@ -18,6 +18,17 @@ Status note: 2026-07-08 audit: criterion 6's wired consumer landed with machine-work.md's WorkGrid joined the consumer list (wraps FlowGraph). No domain built a rival engine; system-laws.md's code-expression paragraph was made precise the same day. + 2026-07-18 tick: a source audit found that reach had regressed behind + the contract: `Device.subscribers` was the production tap store while + `FlowGraph.subscriptions` had no production reader. Reach now makes the + FlowGraph registry authoritative for tap/untap/take, sense and message + delivery, UI state, and persisted membership. Private device feed records + carry only optional typed sight/hearing grants attached to registry members; + current save v40 requires each controller to remain a canonical member and + rejects orphaned, duplicate, or impossible grants. A + message/control subscriber legitimately has no sense-grant record, so that + metadata cannot serve as another membership inventory. This repairs the + real consumer promised by criterion 6 rather than weakening the spec. Stage: B1 — The Basement Design: - wiki/mechanics/system-laws.md#the-flow-law-signals-messages-money @@ -64,7 +75,8 @@ what a node *is*. **inject/redirect precondition**: you may act on a node only if you can reach it. - **Subscriptions** (`subscribe` / `unsubscribe` / `subscribers` / - `subscriptions_of`) are the **tap registry**. The player's senses + `subscriptions_of`) are the **tap registry**. Domain ownership transfer may + clear a node before installing its new sole subscriber. The player's senses (cursor.md) are exactly `subscriptions_of(PLAYER)`; observer witnessing is other subscribers on the same nodes; **take** is `unsubscribe(owner)` then `subscribe(player)`. @@ -95,8 +107,13 @@ needs. You do **not** touch flow.rs/schedule.rs. cameras, badge controller). `link` the network topology; `GateKey` = segment, opened by a compromised switch or a social route. Player reach = `reachable_from(controlled_roots)`. Sensor ownership = the tap - registry. Device processing cycles and resident automations are domain - state keyed by `NodeId`. + registry: tap/untap/take and every production membership query use + `FlowGraph`, while private device records attach sight/hearing capabilities + to those members. Message/control members need no sense record; current-save + validation requires each controller's membership and rejects grants that + are orphaned, duplicated, or impossible for the device. + Device processing cycles and resident automations are domain state keyed by + `NodeId`. - **messages.md (social, as built).** A sent message is a `Schedule` delivery/read event fired at the recipient's next valid read block (`Sim::message_schedule`). The channel (email / @@ -145,7 +162,7 @@ needs. You do **not** touch flow.rs/schedule.rs. (tested). 2. The subscription registry supports tap / untap / list-subscribers / list-a-subscriber's-nodes, deterministically ordered; take = untap - owner + tap player (tested). + owner + tap player (tested in both the engine and reach consumer). 3. `Schedule` fires exactly the due events in `(tick, seq)` order, never by time alone, with past-due events still firing; `retain` cancels and `next_tick` reports the soonest (tested). @@ -155,7 +172,12 @@ needs. You do **not** touch flow.rs/schedule.rs. stable iteration (the determinism guardrail; audited). 6. **First consumer:** reach.md builds its device graph on `FlowGraph` and gates digital actions by reachability, and sensor ownership uses - the tap registry — proving the engine against a real domain (this - criterion is what moves reach.md off the ad-hoc `Sensor.controlled` - flag). Until then the engine has thorough unit tests but one wired - consumer is required before this spec is IMPLEMENTED. + the tap registry for mutation, membership, sense/message flow, and UI state + — proving the engine against a real domain. Typed feed capabilities may + remain domain metadata, but they cannot become a second membership store; + current saves require the controller's canonical membership and reject + orphaned, duplicate, or impossible grants, while a message/control member + may validly have no sense grant. This criterion is what moves reach.md off + both the old ad-hoc `Sensor.controlled` flag and any later parallel + subscriber list. Until then the engine has thorough unit tests but one + wired consumer is required before this spec is IMPLEMENTED. diff --git a/wiki/log/2026-07-18-flow-subscription-registry-integration.md b/wiki/log/2026-07-18-flow-subscription-registry-integration.md new file mode 100644 index 00000000..472b11f9 --- /dev/null +++ b/wiki/log/2026-07-18-flow-subscription-registry-integration.md @@ -0,0 +1,66 @@ +# Reach now uses the shared tap registry + +``` +Type: log +``` + +## Intent + +Autonomous tick #102 followed the flow-substrate audit beneath the spec's +IMPLEMENTED label. The question was narrow: does reach actually use the shared +subscription registry as production truth, or does the generic engine only +have isolated unit coverage? + +## Finding + +The earlier audit conclusion was wrong. `FlowGraph` had a complete serialized, +deterministic subscription registry, but reach never called it outside tests. +`Device.subscribers` was a second persisted store mutated by TAP, UNTAP, and +TAKE and read directly by senses, intercepted-message capture, accounting +carrier checks, action legality, and DIGITAL UI state. The real domain bypassed +the shared engine while the flow-substrate status note claimed criterion 6 was +met. + +That violated the adopted flow law in the expensive direction: a future flow +system could not trust one generic tap primitive, and the two registries could +quietly disagree after save/load or a new mutation path. + +## Repair + +- `ReachNet` initializes and mutates subscriber membership only through its + `FlowGraph`. TAP subscribes the player, UNTAP unsubscribes only the player, + and TAKE clears the exact node before installing the player as its sole + subscriber. +- Every production membership query now enters through `ReachNet` and reads + `FlowGraph`: senses, message and accounting interception, upkeep, action + prerequisites, and renderer-neutral DIGITAL state all share that truth. + Terminal REACH and Bevy's semantic/live-feed materials use the same + ReachNet queries rather than interpreting device metadata themselves. + `player_sight` and `player_hearing` consume + `FlowGraph::subscriptions_of(PLAYER)` directly. +- A device retains private Feed records only to attach optional sight/hearing + capability to a registry member. A message or control subscriber has no + empty Feed record, so the metadata is not a shadow membership inventory. + Those records are no longer externally mutable or a subscription API. +- Save v40 records the corrected schema. Current-save validation requires the + graph to name real device nodes, every controller to remain a member, and + every typed grant to belong to a graph member, name a real capability, and + appear only once. It refuses v39 under the standing pre-release policy + rather than fabricating a migration. + +## Defense + +The generic registry is now proven through the real reach consumer, not by a +same-shaped test beside an unrelated implementation. Defenses cover generic +clear/subscribe behavior, reach-level tap/untap parity, save/load survival, and +current-save rejection of split truth: + +- `subscriptions_are_a_tap_registry` +- `device_taps_live_in_the_flow_graph_registry` +- `message_membership_needs_no_shadow_sense_grant` +- `serde_roundtrips_ownership_and_subscriptions` +- `current_save_rejects_divergent_device_subscription_state` +- the canonical persisted-state fingerprint, deliberately repinned for v40 + +The design did not change. The implementation now reaches the law it already +claimed. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7c431f93..2d06dd21 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -86,6 +86,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-18-moonlight-gigs-decision.md](2026-07-18-moonlight-gigs-decision.md) +## 2026-07-18 - Reach now uses the shared tap registry + +- Intent: Autonomous tick #102 followed the flow-substrate audit beneath the spec's IMPLEMENTED label. The question was narrow: does reach actually use the shared subscription registry as production truth, or does the generic engine only have isolated unit coverage? +- Log: [wiki/log/2026-07-18-flow-subscription-registry-integration.md](2026-07-18-flow-subscription-registry-integration.md) + ## 2026-07-18 - The financial-mail decision gets a work order - Intent: (see session log) diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index 70078414..5e167906 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -27,7 +27,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 v39. + current save v40. - **Open ([OPEN], presentation).** The two-ledger distinction — evidence in flight vs. suspicion in heads — is a binding legibility requirement the surface carries both facts for but does not yet *teach* are different diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index 10ad19a9..35bd0075 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -6,7 +6,8 @@ Status: IMPLEMENTED Status note: all eight criteria met (2026-07-07). The device graph (src/reach.rs) rides the flow substrate's FlowGraph; reach is BFS from player-controlled roots over gated segment edges. Ownership is - owner + subscribers (Feed per party); tap adds a silent Player + owner + FlowGraph subscribers, with an optional typed Feed grant for a + party's sense capabilities; tap adds a silent Player subscriber and emits Network, take reassigns the controller, drops the owner's feed, and emits a Physical outage the owner's channels notice. Senses are exactly the union of subscribed feeds — the old @@ -27,7 +28,13 @@ Status note: all eight criteria met (2026-07-07). The device graph sink path landed for the opening senses 2026-07-10 (pre-opened Ears/Eyes reservoirs, nearest-first routing, sink readouts; the boot ops bootstrap and its executor-free completion are removed); every live - action verb now rides the sink path. + action verb now rides the sink path. 2026-07-18 source audit repaired a + parallel-store violation: tap/untap/take, all production membership reads, + senses, intercepted messages, and UI state now use FlowGraph's canonical tap + registry; private device Feed records carry optional sense capabilities + only, and save v40 requires each controller's graph membership while + rejecting orphaned, duplicate, or impossible grants. A message/control + subscriber has no empty grant record to mirror membership. Stage: B1 — The Basement Design: - wiki/interface/presence.md#no-disembodied-hands @@ -118,7 +125,12 @@ This is the shared contract cursor.md and detection.md rely on - Every device — sensors included — has an **owner** (the facility, Ray, Dana, the player) and a set of **subscribers** (agents who - receive its feed or telemetry). + receive its feed or telemetry). Subscriber membership lives once in the + shared `FlowGraph` tap registry. A device's private Feed records say which + senses a member receives; they are capability metadata, not another + membership authority. Current-save validation requires every controller and + grant-holder to be a graph member, but a member without senses needs no empty + grant record. - **Tap (AMENDED 2026-07-11):** establish a maintained foothold without taking ownership. An already-live feed adds you as a silent subscriber at modest Network cost; a non-sensor control device exposes the same TAP verb for its @@ -227,7 +239,10 @@ remains). 1. The device graph loads from basement layout data (nodes, links, segments); reach is computed from player-controlled roots; - save/load round-trips graph, ownership, and subscriptions. + save/load round-trips graph, ownership, and subscriptions. FlowGraph is the + canonical persisted membership registry; device-local typed sense grants + are optional metadata that must belong to a graph member and name real + device capabilities, and every controller remains a canonical member. 2. A digital action against an unreachable device fails with a legible reason naming the blocking segment or missing knowledge; the same action succeeds once a path exists. @@ -235,7 +250,7 @@ remains). controller or dock camera) is unreachable at start and becomes reachable via switch compromise (Network signature emitted) or Dana's social route (test both routes). -4. Devices carry owner + subscribers; tapping either a live feed or a +4. Devices carry owner + canonical FlowGraph subscribers; tapping either a live feed or a dormant camera leaves the owner's feed intact and emits the tuned Network signature; take removes the owner's feed and generates an outage event that the owner's observer channels @@ -243,9 +258,10 @@ remains). event; tapping it does not). Untapping removes only the player's subscription, preserves the owner and other subscribers, and recomputes player senses immediately. -5. Player senses equal subscribed feeds exactly (the `controlled` flag - is gone); observer witnessing still passes schedules.md's criteria - on the shared machinery. +5. Player senses equal `FlowGraph::subscriptions_of(PLAYER)` filtered by each + subscribed device's typed feed grant exactly (the `controlled` flag and + parallel device membership stores are gone); observer witnessing still + passes schedules.md's criteria on the shared machinery. 6. The Ears beat works end-to-end from tick one (AMENDED 2026-07-10): the Ears reservoir is open at run start, the first THINK fills it along the real wires, and its fire lands the audio diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index e9ecd7b0..441eed44 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -80,8 +80,10 @@ clause (see wiki/log/2026-07-05-demolition.md). segment 1 = security segment behind the switch. - **Senses = subscribed feeds.** The player's sight/hearing coverage is exactly the union of the feeds they subscribe to (`Party::Player` in a - device's `subscribers`). No player radius; `src/sensor.rs` and the old - `controlled` flag are gone. + device node's canonical `FlowGraph` subscription set, filtered by its typed + sight/hearing grant; message/control subscriptions need no sense grant). No + player radius; `src/sensor.rs`, the old + `controlled` flag, and parallel device-local membership are gone. - **Ownership contract:** tap adds a silent Player subscriber and leaves the owner's feed intact; take reassigns the controller, removes the owner's feed (an outage), and grants the device's processing cycles diff --git a/wiki/mechanics/system-laws.md b/wiki/mechanics/system-laws.md index c4fc8f7a..16380419 100644 --- a/wiki/mechanics/system-laws.md +++ b/wiki/mechanics/system-laws.md @@ -91,7 +91,7 @@ ordinary flow toward a human-operated endpoint continues. This keeps tap, ownership, and redirect as separate powers rather than allowing surveillance to become suppression for free. -**Code expression (reconciled to the tree 2026-07-14).** The flow law has +**Code expression (reconciled to the tree 2026-07-18).** The flow law has a shared engine — the two substrate modules `crates/misaligned-core/src/flow.rs` (`FlowGraph`: topology, reachability, the tap registry) and `crates/misaligned-core/src/schedule.rs` @@ -108,6 +108,10 @@ per-flow cadence fields — reschedule-on-fire, the substrate's own "recurrence is a domain concern" non-goal, kept domain-local. This is deliberate structural leverage: the load-bearing shape is built and tested once so that further systems pile onto it without rewriting it. +For signals specifically, FlowGraph's subscription registry is canonical +membership: optional reach-domain feed records attach typed sense capabilities +to those members but cannot replace or disagree with the shared tap registry. +Message/control members need no empty feed record. ## Income: the named schemes (moonlight and the wager) diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index a66e1fcb..c5c525ec 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -27,7 +27,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/world/characters/marcus.md` | 2026-07-15 | finding | all five criteria verified against person, detection, social/plot, schedule, and Act One paths; added the missing simulation-level Knowing-floor decay pin and graduated the stale READY work order — [log](../log/2026-07-15-marcus-graduation.md) | | `wiki/engineering/current-build.md` | 2026-07-18 | finding | re-audit: the system table is freshly maintained (save row already at current-version-only v35, detection-discovery knowledge listed same-day it landed); the drift was the line-count claim stale a second time (~65k claimed vs ~72k actual) — count refreshed to ~72k (core ~43k, Bevy ~15k, terminal ~9k, assets ~5k) and, per recurrence-promotes-to-the-gate, corpus_engine now compares the "~Nk lines of Rust" claim against the tree with a 15% band (fixtures pin pass and fail) — [prior log](../log/2026-07-14-current-build-count.md) | | `wiki/mechanics/plots.md` | 2026-07-18 | issue | the sole READY gap is criterion 11's standing plot policy, whose scope question sat [OPEN] with no decision packet — filed decision-required issue #12 (route vs category vs person, per-route recommended) and pointed the marker at it; the 2026-07-17 plot-id row fix stands — [log](../log/2026-07-17-plot-id-in-start-rows.md) | -| `wiki/mechanics/system-laws.md` + `flow-substrate.md` | 2026-07-14 | finding | engine and consumers verified (FlowGraph/Schedule pins, reach + WorkGrid + message_schedule + account graph); reconciled the pre-implementation domain-riding claims (no social FlowGraph yet; economy recurrence is per-flow cadence by the non-goal) — [log](../log/2026-07-14-flow-law-precision.md) | +| `wiki/mechanics/system-laws.md` + `flow-substrate.md` + `reach.md` | 2026-07-18 | finding | source audit disproved the prior consumer claim: reach kept production tap truth in `Device.subscribers` while FlowGraph's registry had no production reader. Removed that parallel authority; tap/untap/take, senses, intercepted carriers, and UI state now query canonical FlowGraph membership, private feed records carry optional sense capabilities only, and current save v40 requires controller membership while rejecting orphaned, duplicate, or impossible grants — [log](../log/2026-07-18-flow-subscription-registry-integration.md) | | `wiki/mechanics/aggregate-observer.md` | 2026-07-18 | clean | re-audit hours after the earned-topology landing: the page absorbed it coherently — the institutional card, `@assurance` addressing, and band are hidden until a captured filing is processed, the two-stage discovery is pinned by `captured_then_processed_filing_earns_the_assurance_office_in_two_stages`, and `WatchedInput::Filings(ids)` still matches the code; prior audits stand — [2026-07-14 log](../log/2026-07-14-aggregate-observer-audit.md) | | `wiki/gameplay/act-one.md` | 2026-07-15 | clean | law verified against the tree: hall census (prefab.rs 51 live/5 dead/3 empty allocations, `HALL_ROWS` six controlled row segments with named specialists), pre-opened EARS reservoir -> dormant-camera Eyes sink (`sinks.rs`), off-map Voss-desktop demand origin (`sim/work.rs`), QUIET EXIT READY / act_one_complete latch strings (`sim/mod.rs`), two-segment VLAN naming; the opening prelude and loud exit are owned by their dispatched specs (opening.md DRAFT, overt-phase.md READY), cast/schedules pinned by earlier rows | | `wiki/gameplay/run-shape.md` | 2026-07-15 | finding | law verified (Persist default + evaluator, fingerprints gate nothing, split is serde-compat, backup decisions dispatched to rollback/hardware-capabilities); resolved the stale staging [OPEN] to the ROADMAP board and repaired the dangling "Roadmap (v2)" pointer, renaming the section anchor corpus-wide — [log](../log/2026-07-15-run-shape-staging-resolved.md) | diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index fdf5daf0..428889bb 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -16,7 +16,7 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); fragment and inherited receipt name only an external institutional review authority so the Assurance Office remains earned later through filing interception. The persistent revision-04 identity slice is live in sim state, - current save v39, and all three frontends; the three historical fragments and receipts + current save v40, and all three frontends; the three historical fragments and receipts remain unimplemented. Direction decided; beat timings, exact reveal order details, and staging mechanism details are [OPEN]/[TUNE]. Amended 2026-07-18: the current revision now begins