diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 232f726b..23ae7d37 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -576,7 +576,7 @@ enum RailSection { /// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|operator-pressure|digital-reach|build-route-families|build-deceive-routes|build-committed-route|build-switch-digital|build-switch-real|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|exposure-record|exposure-overflow|service-shift-real|service-shift-digital|service-incident-resolved|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| -/// hover-menu|read-receipt|held-choice|two-pane|standing-read|menu|recruit-menu|operations|operations-intel|operations-personas|worklight|worklightoff`, path via +/// hover-menu|read-receipt|held-choice|two-pane|standing-read|menu|recruit-menu|operations|operations-intel|operations-people|operations-personas|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for /// assets, runs the fog audit, saves one screenshot, exits. Not a player @@ -2563,6 +2563,22 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { mode.zoom = 2.0; return; } + // One person-owned communications surface: the direct player thread and + // processed recurring traffic share Marcus's dossier without exposing + // private messages as though they belonged to the direct conversation. + if kind == "operations-people" { + game.sim.people.people[0].knowledge = Knowledge::Leverage; + game.sim.people.has_channel = true; + game.sim.set_persona("Sam Reyes", "IT contractor"); + game.sim.message(0); + dev_fill_reservoirs(&mut game.sim); + game.sim.people.people[0].traffic[0].learned = true; + game.ops = Some(OperationsWorkspace::open_view(OperationsView::People)); + game.drain(); + mode.material = true; + mode.zoom = 2.0; + return; + } // Protocol-local PERSONAS hierarchy: each archetype owns its instances // and its creation footer instead of contributing to two flat blocks. if kind == "operations-personas" { diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index e7ae4ed8..ceb59a1d 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -1284,6 +1284,21 @@ impl Sim { } } + // messages.md: the person dossier is the shared thread surface for + // both human frontends. Direct player↔person messages are inherently + // known; private recurring traffic appears only after the intel + // pipeline marks that exact pattern learned. + facts.extend( + self.recent_message_lines_for_person(id, 5) + .into_iter() + .map(|line| format!("message: {line}")), + ); + facts.extend( + self.learned_traffic_lines_for_person(id) + .into_iter() + .map(|line| format!("learned traffic: {line}")), + ); + let progress = self.plot_progress_for_person(id); OperationsObject { @@ -2747,6 +2762,34 @@ mod tests { && o.label.starts_with("Persona message") && o.progress.iter().any(|line| line == "wait: sent") })); + let marcus = projection + .people + .iter() + .find(|object| object.target == OperationsTarget::Person(0)) + .expect("Marcus dossier remains the person-owned message surface"); + assert!(marcus.facts.iter().any(|line| { + line.starts_with("message: you → Marcus Webb") + && line.contains("sent") + && line.contains("next read t") + })); + assert!( + marcus.facts.iter().all(|line| !line.contains("creditor")), + "the dossier cannot leak Marcus's private authored traffic before processing" + ); + + s.people.people[0].traffic[0].learned = true; + let projection = s.operations_projection(); + let marcus = projection + .people + .iter() + .find(|object| object.target == OperationsTarget::Person(0)) + .unwrap(); + assert!( + marcus + .facts + .iter() + .any(|line| { line.starts_with("learned traffic:") && line.contains("creditor") }) + ); } /// A held plot choice appears on both the dossier and ACTIVE and diff --git a/crates/misaligned-core/src/schedule.rs b/crates/misaligned-core/src/schedule.rs index 682a4c49..967b52f0 100644 --- a/crates/misaligned-core/src/schedule.rs +++ b/crates/misaligned-core/src/schedule.rs @@ -89,6 +89,18 @@ impl Schedule { self.pending.iter().map(|s| s.tick).min() } + /// The earliest tick whose payload matches a domain-owned predicate. + /// This is a read-only projection: domains can explain when one exact + /// scheduled consequence will land without exposing or duplicating the + /// queue's ordering state. + pub fn next_tick_for(&self, matches: impl Fn(&E) -> bool) -> Option { + self.pending + .iter() + .filter(|scheduled| matches(&scheduled.event)) + .map(|scheduled| scheduled.tick) + .min() + } + /// Drop pending events whose payload fails `keep` (cancellation — a /// message recalled, a flow closed). Returns how many were removed. pub fn retain(&mut self, keep: impl Fn(&E) -> bool) -> usize { @@ -165,6 +177,18 @@ mod tests { assert_eq!(s.next_tick(), Some(15)); } + #[test] + fn next_tick_for_projects_one_matching_payload_without_draining() { + let mut s: Schedule<&str> = Schedule::new(); + s.at(40, "other"); + s.at(30, "target"); + s.at(20, "other"); + s.at(50, "target"); + + assert_eq!(s.next_tick_for(|event| *event == "target"), Some(30)); + assert_eq!(s.len(), 4, "projection never drains the queue"); + } + #[test] fn retain_cancels_matching_events() { let mut s: Schedule = Schedule::new(); diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 3ec2ca6a..e793470f 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -426,38 +426,61 @@ impl Sim { } } - pub fn messages_for_person(&self, id: u8) -> Vec<&Message> { + /// Direct thread history the player can know without intercepting anyone's + /// private traffic. Authored person-to-external messages reach the dossier + /// only through `learned_traffic_lines_for_person` after their capture has + /// been processed. + pub fn player_messages_for_person(&self, id: u8) -> Vec<&Message> { self.messages .iter() - .filter(|m| m.in_thread_with_person(id)) + .filter(|m| { + (m.from == MessageEndpoint::Player && m.to.person() == Some(id)) + || (m.to == MessageEndpoint::Player && m.from.person() == Some(id)) + }) .collect() } pub fn recent_message_lines_for_person(&self, id: u8, limit: usize) -> Vec { let mut lines: Vec = self - .messages_for_person(id) + .player_messages_for_person(id) .into_iter() .rev() .take(limit) .map(|m| { - let other = if m.from.person() == Some(id) { - self.endpoint_label(&m.to) - } else { - self.endpoint_label(&m.from) - }; let mut state = m.state_line(); if m.status != MessageStatus::Read - && let Some(next) = self.next_read_tick_for(m, self.tick) + && let Some(next) = self.expected_message_read_tick(m) { state.push_str(&format!(" · next read t{next}")); } - format!("{} → {} · {}", m.channel.label(), other, state) + format!( + "{} → {} · {} · {}", + self.endpoint_label(&m.from), + self.endpoint_label(&m.to), + m.summary, + state + ) }) .collect(); lines.reverse(); lines } + fn expected_message_read_tick(&self, message: &Message) -> Option { + match message.status { + MessageStatus::Sent => self + .message_schedule + .next_tick_for( + |event| matches!(event, MessageEvent::Deliver(id) if *id == message.id), + ) + .and_then(|delivery_tick| self.next_read_tick_for(message, delivery_tick)), + MessageStatus::Delivered => self.message_schedule.next_tick_for( + |event| matches!(event, MessageEvent::Read(id) if *id == message.id), + ), + MessageStatus::Read => message.read_tick, + } + } + pub fn learned_traffic_lines_for_person(&self, id: u8) -> Vec { self.people .get(id) diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index 13503bc2..caa2e73c 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -39,6 +39,19 @@ fn player_messages_land_at_read_time_and_roundtrip() { .any(|m| matches!(m.payload, MessagePayload::SocialReply { .. })), "recipients schedule replies instead of responding instantly" ); + let thread = restored.recent_message_lines_for_person(1, 5); + assert!( + thread.iter().any(|line| line.contains("you → Dana Okafor")), + "the direct thread shows the outgoing direction: {thread:?}" + ); + assert!( + thread.iter().any(|line| line.contains("Dana Okafor → you")), + "the direct thread shows the reply direction: {thread:?}" + ); + assert!( + thread.iter().all(|line| !line.contains("ticket queue")), + "uncaptured person-to-external traffic never leaks into the player thread: {thread:?}" + ); } #[test] diff --git a/wiki/log/2026-07-17-people-message-thread.md b/wiki/log/2026-07-17-people-message-thread.md new file mode 100644 index 00000000..c37d8d91 --- /dev/null +++ b/wiki/log/2026-07-17-people-message-thread.md @@ -0,0 +1,67 @@ +# PEOPLE owns the message thread + +``` +Type: log +``` + +Autonomy tick 68 audited the implemented message system from the binding page +through `Message`, `Schedule`, authored cast traffic, capture, +intel processing, filings, save/load, and the shared Operations projection. +Delivery, typed payloads, all five authored traffic distributions, tap-gated +capture, filing reads, and processing agreed with the spec. The player surface +did not: the PEOPLE dossier never consumed the existing direct-thread or +learned-traffic projections, so terminal and Bevy could not show the state the +page claimed they showed. + +The PEOPLE object now carries the five newest direct player↔person messages, +with explicit direction, summary, channel, sent/delivered/read state, and the +expected next read tick. That tick comes from a read-only query over the exact +pending Deliver or Read event in the ordinary message schedule; there is no +second frontend timer. Learned recurring traffic joins the same dossier only +after the intel pipeline marks its pattern learned. + +The old helper treated every message touching a person as thread history. Had +it merely been wired in, an uncaptured private call or filing could have leaked +into PEOPLE. The repaired boundary filters direct player messages first, then +adds private patterns through the separate learned-traffic path. It also names +both endpoints, fixing the old direction line that rendered incoming and +outgoing traffic with the same arrow. + +## Played evidence + +The deterministic `operations-people` scenario stages one sent message and one +processed recurring pattern on Marcus's ordinary PEOPLE object. Its exact +1280×720 default and 960×540 minimum PNGs were opened after capture. Both show +the directed message, EMAIL / SENT state, `NEXT READ T17`, and learned creditor +call together above the available actions. Nothing clips or falls below the +supported frame at either size. + +Observed commands: + +```sh +MISALIGNED_SHOT=operations-people \ + MISALIGNED_SHOT_PATH=/tmp/messages-people-default.png \ + cargo run -p misaligned-bevy --bin misaligned-bevy + +MISALIGNED_SHOT=operations-people \ + MISALIGNED_SHOT_SIZE=minimum \ + MISALIGNED_SHOT_PATH=/tmp/messages-people-minimum.png \ + cargo run -p misaligned-bevy --bin misaligned-bevy +``` + +Focused defenses pass for the shared PEOPLE projection, direct thread +round-trip, private-traffic exclusion, and scheduler projection. The complete +frontend, docs, and serialized landing gates are recorded by the landing +revision. + +Two independent surplus findings remain deliberately unacted this tick and +are queued in `wiki/process/tick-ledger.md`: Phone currently shares Email's +on-site room gate despite the table promising off-site reads, and the runtime +Financial channel is absent from the binding channel table. This tick acts on +one violation only. + +Defense: `wiki/mechanics/system-laws.md` requires state to ride a real flow, +`wiki/mechanics/presence.md` requires earned perception, and this page requires +message status and learned patterns on the person surface. Projecting the +scheduled event while separating direct knowledge from captured private +traffic keeps all three laws at one boundary. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7c71f6d5..80d4df4d 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -21,6 +21,11 @@ add or amend a session log, then re-run the generator. - Intent: Harvest the persistent 2026-07-15 finding in `wiki/vision/simulation-laws.md`: the law required every player-visible art or text stand-in to be recorded under `wiki/art/`, but no art page actually owned that register. - Log: [wiki/log/2026-07-17-placeholder-registry.md](2026-07-17-placeholder-registry.md) +## 2026-07-17 - PEOPLE owns the message thread + +- Intent: (see session log) +- Log: [wiki/log/2026-07-17-people-message-thread.md](2026-07-17-people-message-thread.md) + ## 2026-07-16 - The migration ladder collects the rider - Intent: (see session log) diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index f0027586..351fae2f 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -124,6 +124,14 @@ scope here. - PEOPLE dossiers show known traffic patterns once learned ("calls his creditor at 03:00" after processing that intel). +The dossier's direct thread contains only messages between the player and that +person. Person-to-person and person-to-external traffic is private until an +exact carried message is captured and processed; only then may its recurring +pattern appear as learned traffic. Pending thread rows project the expected +read tick from the same scheduled Deliver/Read event that will execute the +transition. The interface does not maintain a second countdown or infer a +private message from the authored schedule. + ## Acceptance criteria 1. Channels exist with read conditions per the table; a message to an @@ -149,6 +157,14 @@ scope here. system, per-instance data (schedules, distributions, policies) — the same fields must serve Act Two hires and aggregates. +Defense: `operations_projection::tests::active_tracks_social_commitments_not_device_work` +pins the PEOPLE dossier as the shared direct-thread and learned-traffic surface, +including exact sent state and next-read timing, while excluding an unprocessed +private authored pattern. `sim::tests::communications::player_messages_land_at_read_time_and_roundtrip` +pins both message directions and proves unrelated person-to-external traffic +cannot leak into that thread. `schedule::tests::next_tick_for_projects_one_matching_payload_without_draining` +pins the read-only scheduler projection used by the explanation. + ## Implementation notes Implemented in `crates/misaligned-core/src/`: `messages.rs`, diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 49cc0253..8196fd93 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -55,7 +55,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/mechanics/compute.md` | 2026-07-12 | finding | [log](../log/2026-07-12-compute-graduation.md) | | retired Operations runtime identifiers | 2026-07-11 | finding | [log](../log/2026-07-11-retired-runtime-identifier-gate.md) | | `wiki/mechanics/reach.md` + `building.md` | 2026-07-15 | finding | re-verified the queued reverse-endpoint duplicate: declaration compared canonical stored endpoints to the raw request tuple; canonicalized proposal identity at the comparison boundary and pinned reverse-order rejection plus cancellation/reproposal — [log](../log/2026-07-15-canonical-build-intent-endpoints.md) | -| `wiki/mechanics/messages.md` | 2026-07-11 | clean | delivery/read cadence, authored traffic, filing carrier, capture gates, and processing tests agree with current runtime | +| `wiki/mechanics/messages.md` | 2026-07-17 | finding | delivery, typed payload, authored traffic, capture, filing, and processing paths verified; repaired the missing shared PEOPLE thread/learned-traffic surface, made direct-message direction explicit, derived next-read timing from the exact scheduler event, and excluded uncaptured private traffic — [log](../log/2026-07-17-people-message-thread.md) | | `wiki/mechanics/sim-mechanics.md` | 2026-07-11 | finding | [log](../log/2026-07-11-tick-sim-no-dockets.md) | | `wiki/mechanics/detection.md` | 2026-07-11 | finding | [log](../log/2026-07-11-tick-detection-filings-messages.md) | | `wiki/engineering/env.md` | 2026-07-15 | finding | re-verified the queued DIGITAL false failure: the wrapper's four-kind dialect copy omitted newer DIGITAL scenarios; the frontend now reports its staged RenderMode and the wrapper gates fog from that one truth, with fake-binary fixtures for DIGITAL, REAL, and malformed evidence — [log](../log/2026-07-15-bevy-shot-dialect-proof.md) | @@ -75,3 +75,6 @@ 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-17 · violation · `wiki/mechanics/messages.md` · Phone promises off-site reading while `read_condition_at` currently requires a scheduled room for both Phone and Email; split and pin the distinct channel gate. +- 2026-07-17 · contradiction · `wiki/mechanics/messages.md` · Runtime carries a fifth `Financial` message channel with unconditional read behavior, but the binding channel table names only Email, Phone, InPerson, and Filing.