diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index e433b125..00966311 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -513,6 +513,7 @@ fn parse_save(content: &str) -> Result { fn validate_current_save(mut state: SaveState) -> Result { state.reach.validate_subscriptions()?; state.detection.validate_topology()?; + validate_persona_custody(&state)?; validate_messages(&state)?; if state .last_facility_meter_levels @@ -952,6 +953,168 @@ fn validate_current_save(mut state: SaveState) -> Result { Ok(state) } +/// PersonaWorld is a graph of durable public custody. Serde can reconstruct its +/// individual rows without proving that their stable ids and cross-references +/// still describe one possible ledger, so current-save loading must do that +/// before any consumer resolves an id with first-match semantics. +fn validate_persona_custody(state: &SaveState) -> Result<(), String> { + let world = &state.persona_world; + let mut persona_ids = HashSet::new(); + for persona in &world.instances { + if persona.id == 0 || !persona_ids.insert(persona.id) { + return Err(format!( + "current-version save has invalid or duplicate persona id {}", + persona.id + )); + } + if persona.created_tick > state.sim_tick + || persona + .claims + .iter() + .any(|claim| claim.asserted_tick > state.sim_tick) + { + return Err(format!( + "current-version persona #{} has future identity history", + persona.id + )); + } + } + let max_persona_id = persona_ids.iter().copied().max().unwrap_or(0); + if world.next_persona_id == 0 || world.next_persona_id <= max_persona_id { + return Err("current-version save has a stale persona id cursor".into()); + } + + let mut relationship_keys = HashSet::new(); + for relationship in &world.relationships { + if !persona_ids.contains(&relationship.persona_id) + || !relationship_keys.insert((relationship.counterparty, relationship.persona_id)) + { + return Err( + "current-version save has a dangling or duplicate persona relationship".into(), + ); + } + if relationship + .claim_beliefs + .iter() + .any(|belief| belief.learned_tick > state.sim_tick || belief.confidence > 100) + { + return Err("current-version save has impossible persona belief custody".into()); + } + } + + let mut expectation_ids = HashSet::new(); + for expectation in &world.expectations { + if expectation.id == 0 + || !expectation_ids.insert(expectation.id) + || !persona_ids.contains(&expectation.persona_id) + { + return Err( + "current-version save has invalid, duplicate, or dangling persona expectation" + .into(), + ); + } + } + let max_expectation_id = expectation_ids.iter().copied().max().unwrap_or(0); + if world.next_expectation_id == 0 || world.next_expectation_id <= max_expectation_id { + return Err("current-version save has a stale persona expectation id cursor".into()); + } + for relationship in &world.relationships { + let mut local_expectations = HashSet::new(); + if relationship.expectation_ids.iter().any(|id| { + !local_expectations.insert(*id) + || !world.expectations.iter().any(|expectation| { + expectation.id == *id && expectation.persona_id == relationship.persona_id + }) + }) { + return Err( + "current-version save has impossible relationship expectation custody".into(), + ); + } + } + + let mut grant_ids = HashSet::new(); + let mut granted_expectations = HashSet::new(); + let mut active_grants = Vec::new(); + for grant in &world.grants { + let matching_persona = world.get(grant.persona_id).is_some_and(|persona| { + persona.grant_kind == grant.kind && persona.grant_resource == grant.resource + }); + let matching_expectation = world.expectations.iter().any(|expectation| { + expectation.id == grant.expectation_id + && expectation.persona_id == grant.persona_id + && expectation.institution == grant.institution + }); + if grant.id == 0 + || !grant_ids.insert(grant.id) + || !persona_ids.contains(&grant.persona_id) + || !granted_expectations.insert(grant.expectation_id) + || !matching_persona + || !matching_expectation + || (grant.active() && active_grants.contains(&(grant.persona_id, grant.kind))) + || grant.granted_tick > state.sim_tick + || grant + .revoked_tick + .is_some_and(|tick| tick < grant.granted_tick || tick > state.sim_tick) + { + return Err("current-version save has impossible persona grant custody".into()); + } + if grant.active() { + active_grants.push((grant.persona_id, grant.kind)); + } + } + if expectation_ids != granted_expectations { + return Err("current-version save has an expectation without one exact grant".into()); + } + let max_grant_id = grant_ids.iter().copied().max().unwrap_or(0); + if world.next_grant_id == 0 || world.next_grant_id <= max_grant_id { + return Err("current-version save has a stale persona grant id cursor".into()); + } + + let mut contradiction_ids = HashSet::new(); + for contradiction in &world.contradictions { + if contradiction.id == 0 + || !contradiction_ids.insert(contradiction.id) + || !persona_ids.contains(&contradiction.persona_id) + || contradiction.discovered_tick > state.sim_tick + || contradiction + .resolved_tick + .is_some_and(|tick| tick < contradiction.discovered_tick || tick > state.sim_tick) + { + return Err("current-version save has impossible persona contradiction custody".into()); + } + } + let max_contradiction_id = contradiction_ids.iter().copied().max().unwrap_or(0); + if world.next_contradiction_id == 0 || world.next_contradiction_id <= max_contradiction_id { + return Err("current-version save has a stale persona contradiction id cursor".into()); + } + + let mut correlation_ids = HashSet::new(); + for correlation in &world.correlations { + if correlation.id == 0 + || !correlation_ids.insert(correlation.id) + || correlation.left_persona == correlation.right_persona + || !persona_ids.contains(&correlation.left_persona) + || !persona_ids.contains(&correlation.right_persona) + || correlation.discovered_tick > state.sim_tick + { + return Err("current-version save has impossible persona correlation custody".into()); + } + } + let max_correlation_id = correlation_ids.iter().copied().max().unwrap_or(0); + if world.next_correlation_id == 0 || world.next_correlation_id <= max_correlation_id { + return Err("current-version save has a stale persona correlation id cursor".into()); + } + + if world + .acts + .iter() + .any(|act| !persona_ids.contains(&act.persona_id) || act.tick > state.sim_tick) + { + return Err("current-version save has a dangling or future persona act".into()); + } + Ok(()) +} + fn elimination_binding_valid( state: &SaveState, map: &crate::map::GameMap, @@ -3547,6 +3710,65 @@ mod tests { ); } + #[test] + fn current_save_rejects_malformed_persona_ledger_custody() { + let mut sim = Sim::new(); + sim.tick = 10; + let persona = sim + .persona_world + .create( + "operations", + "Northline Service Desk", + &[ + ("vendor affiliation", "Northline Systems"), + ("service purpose", "rack maintenance"), + ("work-order sponsor", "Dr. Voss"), + ], + 1, + ) + .expect("fixture creates one exact persona"); + sim.persona_world + .grant(persona, 2) + .expect("fixture creates one paired grant and expectation"); + sim.persona_world + .record_act(persona, "message", "person:0", "message:fixture", 3); + let valid = SaveState::from_sim(&sim); + validate_current_save(valid.clone()).expect("the exact persona ledger validates"); + + let mut duplicate_persona = valid.clone(); + duplicate_persona + .persona_world + .instances + .push(duplicate_persona.persona_world.instances[0].clone()); + assert!( + validate_current_save(duplicate_persona) + .unwrap_err() + .contains("duplicate persona id") + ); + + let mut stale_cursor = valid.clone(); + stale_cursor.persona_world.next_persona_id = persona; + assert_eq!( + validate_current_save(stale_cursor).unwrap_err(), + "current-version save has a stale persona id cursor" + ); + + let mut dangling_expectation = valid.clone(); + dangling_expectation.persona_world.expectations[0].persona_id = persona + 100; + assert!( + validate_current_save(dangling_expectation) + .unwrap_err() + .contains("dangling persona expectation") + ); + + let mut dangling_act = valid; + dangling_act.persona_world.acts[0].persona_id = persona + 100; + assert_eq!( + validate_current_save(dangling_act).unwrap_err(), + "current-version save has a dangling or future persona act" + ); + } + #[test] fn observer_evidence_roundtrips_with_source_and_next_identity() { let mut sim = Sim::new(); diff --git a/wiki/log/2026-07-28-persona-save-custody-audit.md b/wiki/log/2026-07-28-persona-save-custody-audit.md new file mode 100644 index 00000000..5f168d5f --- /dev/null +++ b/wiki/log/2026-07-28-persona-save-custody-audit.md @@ -0,0 +1,61 @@ +# Persona save-custody audit + +``` +Type: log +Date: 2026-07-28 +Fire: #60 +Subject: wiki/mechanics/personas.md +``` + +## Question + +Does the implemented persona grant model satisfy the topology-changing grant +contract, and does current-version save loading preserve the exact public +identity ledger already implemented? + +## Read + +The topology gap remains real and already documented. `PersonaWorld::grant` +creates a persisted grant and expectation, and expiration revokes that record, +but `PersonaWorld::allows_action` still consults only active lifecycle plus the +instance's serialized archetype action registry. No grant adds a machine, +permission, graph edge, account, or owner-system route. Criteria 6 and 6b remain +incomplete. + +The adjacent save boundary had a separate defect. `validate_current_save` +validated routed evidence, Moonlight, wagers, carried work, and many other exact +records, but accepted `PersonaWorld` directly from Serde. Stable ids throughout +relationships, grants, expectations, contradictions, correlations, and acts +were later resolved with first-match lookups. A duplicate persona id, stale +next-id cursor, or dangling grant/expectation/action reference could therefore +enter a current-version run as impossible public custody. + +## Change + +Current-version loading now validates the complete persisted persona graph: + +- persona ids and every ledger id are nonzero, unique, and behind their next-id + cursor; +- every relationship, expectation, grant, contradiction, correlation, and act + references a real persona; +- relationship keys and local expectation references cannot duplicate or point + across personas; +- every expectation belongs to one exact grant, whose kind and resource still + match the serialized instance protocol; +- one persona cannot carry two active grants of the same kind; and +- future or inverted record times, self-correlations, and dangling acts fail + closed before simulation code sees the state. + +The focused regression first builds a valid Operations persona, grant, +expectation, and act through production constructors. It then corrupts duplicate +identity, id-cursor, expectation, and act custody independently and proves each +load is refused. + +This changes no grant effect and makes no completion claim for criteria 6 or 6b. + +## Verification + +- `cargo test -p misaligned-core --lib save::tests::canonical_state_fingerprint_pins_replay_resume_equivalence -- --exact` +- `cargo test -p misaligned-core --lib save::tests::current_save_rejects_malformed_persona_ledger_custody -- --exact` + +The exact landing gate runs after fresh-origin reconciliation. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 47fdc1a3..8c75bb43 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -46,6 +46,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-28-rack-aperture-enclosure.md](2026-07-28-rack-aperture-enclosure.md) +## 2026-07-28 - Persona save-custody audit + +- Intent: (see session log) +- Log: [wiki/log/2026-07-28-persona-save-custody-audit.md](2026-07-28-persona-save-custody-audit.md) + ## 2026-07-28 - Operations action selection keeps its meaning - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-28.md b/wiki/log/decisions/2026-07-28.md index 65156f1b..b0600ff1 100644 --- a/wiki/log/decisions/2026-07-28.md +++ b/wiki/log/decisions/2026-07-28.md @@ -173,5 +173,18 @@ actuator/product division in [building.md](../../mechanics/building.md). Owners: [digital-read.md](../../interface/digital-read.md) and [detection.md](../../mechanics/detection.md). -EOF2 -ls wiki/log/decisions/ | tail -3 \ No newline at end of file + +## Current persona saves validate the complete public custody graph + +Persona grants still do not change world topology, so persona criterion 6 +remains incomplete. That honest design gap does not permit the records already +implemented to load as an incoherent graph. Current-version loading now rejects +duplicate stable persona ids, stale id cursors, dangling relationships, grants, +expectations, contradictions, correlations, and acts, mismatched grant protocol, +and impossible temporal custody before simulation code can resolve an id by +first match. + +This is integrity validation of existing saved truth, not implementation of a +grant resource, permission, graph edge, machine owner, or criterion 6/6b. + +Owner: [personas.md](../../mechanics/personas.md). diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md index 8a432d4b..b64db88c 100644 --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -42,6 +42,13 @@ Status note: The 2026-07-12 foundation replaced the ad hoc social and from the archetype's pre-grant action registry. The work order remains IN PROGRESS until Research, Operations, and Security grants each change actual world topology through their owning systems. + Re-audited 2026-07-28: that topology gap remains exact. The adjacent save + boundary was not: current-version loading trusted duplicate stable persona + ids, dangling grant/expectation/action references, stale id cursors, and + impossible relationship, contradiction, or correlation custody. Loading now + validates the complete persisted PersonaWorld graph before any first-match id + lookup can consume it. This hardens existing records; it does not promote + criterion 6 or 6b. Stage: B2 — The Lab Work order: personas Work priority: 110 @@ -427,7 +434,12 @@ mode consume the same persona, relationship, grant, and correlation projection. income binding, and exact persona-owned action. The historical conversion of the old social identity to Operations and the old Moonlight contractor to Research is retired with the pre-release migration ladder; current load never - reconstructs an observer-free integrity score from that old shape. + reconstructs an observer-free integrity score from that old shape. Loading + rejects duplicate or zero stable ids, stale next-id cursors, future history, + dangling persona references, duplicate relationship keys, grants that do not + match their serialized instance protocol, expectations without exactly one + matching grant, duplicate active grant kinds, and impossible contradiction or + correlation custody before exposing the saved world to simulation code. 10. A fourth archetype and a second instance of an existing archetype can be added as data and pass the same action, relationship, evidence, grant, lifecycle, projection, and save tests without new archetype-specific code. @@ -472,3 +484,8 @@ cadences, grant thresholds, and institution-specific revocation delays. the institutional-client boundary: client-local records break that relation first without contaminating the Assurance Office, then the owning institution's concrete rejection performs the global lifecycle burn. +- `current_save_rejects_malformed_persona_ledger_custody` builds one valid + identity/grant/expectation/action graph through production constructors, then + proves current-version load refuses a duplicate identity, stale id cursor, + dangling expectation, and dangling persona-owned act instead of resolving + malformed custody through first-match lookup. diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index d5a3fe6b..fb8a8246 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -66,7 +66,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/vision/simulation-laws.md` | 2026-07-22 | finding | the device-resident-work clause still assigned JobAnomaly to Dana and described it only as a local emission after the runtime had made it an exact host-machine/device/site record routed to Voss. Corrected the law to separate Priya's pooled Power/Thermal channels from Voss's routed day-job evidence and cadence-owned acquisition — [log](../log/2026-07-22-job-anomaly-routed-evidence.md). Prior placeholder and legibility findings remain closed — [log](../log/2026-07-17-placeholder-registry.md). | | `wiki/process/ROADMAP.md` (work order 27) | 2026-07-18 | finding | re-audit: entry 27's prose is honest (material served as opening default 2026-07-08 → superseded by views.md criterion 1 on 2026-07-11; DIGITAL home, F3 to REAL) and material-render.md is IMPLEMENTED as claimed; the drift was three Bevy code comments still calling material "the default material render/frame" against the runtime's own `material == false` DIGITAL default one screen away — comments trued to DIGITAL-home / REAL-via-F3 language — [prior log](../log/2026-07-13-roadmap-digital-home-reconciliation.md) | | `wiki/interface/context-menu.md` | 2026-07-26 | finding | shared legality, executable-only human rows, local-vs-strategic ownership, menu parity, and exact person-entry paths still verify, but Bevy returned through an open context menu before reaching its ordinary F3 branch. One shared post-opening F3 handler now precedes held choices, context menus, and Operations, preserving the exact modal attention state and simulation bytes; source-shape and behavioral regressions pin the route — [log](../log/2026-07-26-bevy-global-f3-input-precedence.md) | -| `wiki/mechanics/personas.md` | 2026-07-18 | finding | observer-local integrity is now derived from each witness's contradiction records without a global scalar or automatic lifecycle burn — [integrity log](../log/2026-07-18-persona-observer-integrity.md). The same re-audit found criterion 6 still overclaimed: `PersonaGrant` persists, expires, revokes, and leaves institutional evidence, while no owner system consumes it and `allows_action` gates only on the archetype's pre-grant registry. Downgraded the work order to IN PROGRESS until each protocol's grant creates or enables real topology — [grant log](../log/2026-07-18-persona-grant-topology-audit.md) | +| `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-28 | finding | re-audit corrected the older claim that criteria 2-5 all held. Criterion 2 and migration's duration/signature/persistence slice are live, but backup sync is still a free global cadence that stores only `last_sync`: there is no Thought-backed project, partial progress, completion heat, saved project state, sidebar ETA/cost/target, or source-liveness interruption resolver. The status, backup behavior, player-surface boundary, and stale Rust comment now distinguish that B1 seam from the accepted project contract deferred to rollback; capability rejection remains deferred to hardware bodies — [log](../log/2026-07-28-core-criteria-status-audit.md) |