diff --git a/PLANS.md b/PLANS.md index 2c22999..be64678 100644 --- a/PLANS.md +++ b/PLANS.md @@ -130,7 +130,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 18 | Readable, complete in-app help for implemented workflows | 17 | accepted | | 19 | Refactoring contract, terminology and behavior map | 18 | accepted | | 20 | Explicit vocabulary and named operation contexts | 19 | accepted | -| 21 | Domain ownership of reservations and apply knowledge | 20 | pending | +| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted | | 22 | Transactional draft editing and client creation vertical slice | 21 | pending | | 23 | Readable saved-state Commit and restore scenarios | 22 | pending | | 24 | Session-owned first delivery and password reissue scenarios | 23 | pending | @@ -3877,7 +3877,7 @@ Accepted for presentation and synthetic fixture scope after independent review, ## Approved domain refactoring programme — milestones 19–27 -Status: M19–M20 accepted; M21–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work. +Status: M19–M20 accepted; M21 in progress; M22–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work. ### Shared execution contract for M19–M27 @@ -3957,7 +3957,7 @@ Evidence/handoff: [M20 evidence](docs/evidence/milestone-20.md) records the clea ### Milestone 21 — Domain ownership of reservations and apply knowledge -Status: pending. Prerequisite: M20 accepted. +Status: accepted (2026-10-29T23:00:09+02:00; independent M21-R1 re-review and integrated Rust gates passed). Prerequisite: M20 accepted (2026-10-29T22:40:28+02:00). The user confirmed this session's coordinator model GPT-6-Sol/medium earlier; dispatches selected GPT-6-Luna/high explicitly. Initial tree clean at `8413b382b178991d2e1e597f1422d147f3454581`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 available. M9 is retired/unaccepted by user direction and is not an M21 prerequisite. #### Outcome and scope @@ -3965,9 +3965,9 @@ Encapsulate address retention and verification transitions without changing pers | Task | Owner | State | Bounded deliverable | | --- | --- | --- | --- | -| M21.1 | implementer | pending | Encapsulated reservation rules and existing allocator integration | -| M21.2 | implementer | pending | Explicit current/historical verification transitions and caller migration | -| M21.3 | reviewer; repairer; coordinator | pending | Review invariant matrix, repair and RUST acceptance | +| M21.1 | implementer `/root/m21_reservations` | complete | Domain-owned committed/pending reservation sets, exact-host policy and allocator/Commit/discard integration; focused tests passed | +| M21.2 | implementer `/root/m21_reservations` | complete | Explicit current/historical verification transitions and caller migration; focused tests passed | +| M21.3 | reviewer `/root/m21_reviewer`; repairer `/root/m21_repair`; coordinator | complete | M21-R1 closed on independent re-review; integrated RUST and diff check passed | 1. Extract `AddressReservations` with private storage and intention-revealing operations to retain removed host addresses, restore conservative persisted reservations and expose read-only iteration/availability. Preserve conflict rejection, draft unsupported-peer reservations, Commit timing, discard behavior, IPv4/IPv6 allocation and broad-route deletion restrictions. Do not enumerate networks or infer free addresses from unavailable runtime data. 2. Keep draft-only pending reservations distinct from committed reservations until publication. Follow M19's transition table; preserve reservations from unprojectable exact host routes, and reject unsafe broad-route deletion as before. Move policy, not filesystem/parsing/crypto, into domain. @@ -3977,11 +3977,11 @@ Encapsulate address retention and verification transitions without changing pers #### Acceptance and validation -- [ ] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them. -- [ ] M21-A2: legacy technical verification without a management snapshot, reopening and current vs historical status retain prior semantics. -- [ ] M21-A3: allocator, reservation, persisted round-trip and app failure tests pass; independent invariant review and RUST pass. +- [x] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them. +- [x] M21-A2: legacy technical verification without a management snapshot, reopening and current vs historical status retain prior semantics. +- [x] M21-A3: allocator, reservation, persisted round-trip and app failure tests pass; independent invariant review and RUST pass. -Focused starting commands: `cargo test --locked domain::`, `cargo test --locked persistence::`, `cargo test --locked app::`; reviewer specifically inspects reservation-release callers, not only tests. Evidence: docs/evidence/milestone-21.md. Outcome: not accepted. Handoff: M22 after acceptance. +M21.1 and M21.2 focused checks passed. Independent reviewer found M21-R1: `commit_with_adapters` reset current apply status on unchanged Commit, changing prior Verified/Unavailable semantics. Repair removed that call and added three behavioral tests. Independent re-review closed M21-R1 with no remaining scoped finding. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (399 passed, 3 ignored) and `cargo build --locked --release` all exited 0; `git diff --check` passed. [M21 evidence](docs/evidence/milestone-21.md) records final dirty source and artifact fingerprints, review and validation limits. Outcome: accepted for host/synthetic scope; stop after M21. Next action only on a separate request is M22.1; no fresh Alpine/VM result is claimed. ### Milestone 22 — Transactional draft editing and client creation vertical slice @@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af ## Current progress and resume point -- M20 is accepted (2026-10-29T22:40:28+02:00): renamed apply knowledge, named prepared contexts, independent M20-R1 closure and final RUST gates passed; [M20 evidence](docs/evidence/milestone-20.md) records source/artifact identity and limits. Stop after M20; M21–M27 remain pending. M19 remains accepted; M9 remains retired/unaccepted and its real-system limitations remain unchanged. +- M21 is accepted for host/synthetic scope: domain reservation and apply-knowledge ownership, M21-R1 repair, independent re-review and required Rust gates passed. [M21 evidence](docs/evidence/milestone-21.md) records final inputs and limits. M22–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started. @@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. -- M18/M19 handoffs remain complete. M20 is accepted; the next implementation action, when separately requested, is M21.1. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. +- M18–M21 handoffs remain complete. Stop after M21; begin M22.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. ## Plan maintenance diff --git a/docs/PROJECT_LAYOUT.md b/docs/PROJECT_LAYOUT.md index 87b20b6..8dfc8c6 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -28,7 +28,7 @@ src/ main.rs # Startup, arguments, process exit lib.rs # Module declarations and application API config.rs # Application settings and path resolution - domain.rs # Clients, identities, address allocation, change states + domain.rs # Clients, identities, address allocation/reservations, change states app.rs # Application operations and orchestration wireguard.rs # Server configuration module boundary wireguard/ diff --git a/docs/evidence/milestone-21.md b/docs/evidence/milestone-21.md new file mode 100644 index 0000000..636f46e --- /dev/null +++ b/docs/evidence/milestone-21.md @@ -0,0 +1,25 @@ +# Milestone 21 evidence — domain reservations and apply knowledge + +Run: 2026-10-29T23:00:09+02:00. Base commit: `8413b382b178991d2e1e597f1422d147f3454581` (clean at start). Final dirty source inputs: `src/domain.rs`, `src/app.rs`, and `docs/PROJECT_LAYOUT.md`; `PLANS.md` and this evidence record are coordinator documentation. `git diff -- src/domain.rs src/app.rs docs/PROJECT_LAYOUT.md | sha256sum` returned `d9056e1fd05db2425a127ac57f906333ae311bdb0f3cce13b9153f39c041c982`. `target/release/wireguard-admin` SHA-256: `60b3ad8b9c1bf2750239d3500b319cf42e5581fe027e5b6e39c8ff4115feb024`. `Cargo.toml` and `Cargo.lock` have no diff. Rust and Cargo: 1.97.1, pinned toolchain. + +## Implementation and invariant evidence + +- M21.1: `AddressReservations` owns private committed and draft-only pending host-address sets. Staging validates the entire exact `/32` or `/128` route list before mutation. The allocator sees both sets. Draft discard drops pending addresses; successful Commit promotes pending and retains addresses removed from the prior manageable saved snapshot. Persisted restoration is crate-scoped and rejects saved-client conflicts. No serialization field or version changed. +- M21.2: private `ApplyKnowledge` owns current runtime status and optional complete last-verified management snapshot. Historical verification remains separate in persisted application history. Session open resets current knowledge to `Unknown`; technical verification may publish `Verified` with no complete management snapshot. The restart scenario prepares a cloned verified candidate, then publishes it only after internal-state installation succeeds. Only that verified transition releases committed reservations. Backup, service, read, mismatch and persistence failure paths retain reservations. +- M21-R1: independent review found unconditional Commit demotion of `Verified` and `Unavailable` on unchanged saved client snapshots. The repair removed the extra application call; the domain Commit transition still demotes `Verified` when the saved management snapshot changes. Three behavioral app tests cover unchanged `Verified`, unchanged `Unavailable`, and changed `Verified`. The same independent reviewer re-reviewed the repair and full affected reservation/apply paths, closed M21-R1, and reported no remaining actionable finding. + +## Validation on integrated source + +| Check | Result | +| --- | --- | +| `cargo fmt --all -- --check` | exit 0 | +| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 | +| `cargo test --locked` | exit 0; 399 passed, 3 ignored (opt-in Alpine drivers), 0 failed, including 2 doctests | +| `cargo build --locked --release` | exit 0 | +| `git diff --check` | exit 0 | + +Focused implementation, repair and review runs covered `domain::`, `persistence::`, `app::`, `--test commit_restore`, and `--test runtime_restart`; the final full test command supersedes those focused source results. The independent reviewer specifically inspected reservation mutation/release callers, pending/committed timing, exact-host policy, unchanged/changed Commit behavior, reopen/current versus historical state, legacy technical matching without a management snapshot, and failed verified publication. + +## Scope and acceptance + +M21-A1: passed by reviewed transition callers and synthetic failure-path tests. M21-A2: passed by legacy/reopen/history tests and the M21-R1 behavioral repair. M21-A3: passed by allocator, reservation, persistence and application tests, independent re-review and all required Rust gates. Evidence is host/synthetic. The opt-in Alpine suites and disposable systemd VM were not run for M21, and no real systemd, live VPN, production or IPv6 integration result is claimed. M9 remains retired/unaccepted. The previously recorded unprojectable-peer restore reservation gap belongs to M23 and is not represented as resolved here. diff --git a/src/app.rs b/src/app.rs index 4744b51..e23d852 100644 --- a/src/app.rs +++ b/src/app.rs @@ -81,7 +81,6 @@ pub struct Application { document: ConfigDocument, states: ClientStates, vault: Option, - draft_unsupported_reservations: BTreeSet, historical_apply: HistoricalApplyStatus, verified_saved_projection: Option, active_restart_attempt: Option, @@ -477,7 +476,11 @@ impl fmt::Debug for Application { ) .field( "pending_reservation_count", - &self.draft_unsupported_reservations.len(), + &self + .states + .address_reservations() + .pending_host_routes() + .count(), ) .field("initial_issue_count", &self.initial_issues.len()) .finish() @@ -511,7 +514,7 @@ impl Application { saved: SavedBytes, mut states: ClientStates, ) -> Result { - states.mark_runtime_unknown(); + states.begin_runtime_session(); let document = ConfigDocument::parse(&saved.server_config)?; let vault = saved .vault @@ -531,7 +534,6 @@ impl Application { document, states, vault, - draft_unsupported_reservations: BTreeSet::new(), historical_apply, verified_saved_projection: None, active_restart_attempt: None, @@ -633,13 +635,7 @@ impl Application { .into_iter() .collect::>(); occupied.extend(document_host_addresses(&self.document)); - let reserved = self - .states - .reserved_addresses() - .iter() - .copied() - .chain(self.draft_unsupported_reservations.iter().copied()) - .collect::>(); + let reserved = self.states.address_reservations().all().collect::>(); let availability = AddressAvailability { server: &server_addresses, occupied: &occupied, @@ -1268,36 +1264,24 @@ impl Application { .peers() .iter() .any(|peer| peer.identity == Some(identity)); - let mut additional_reservations = BTreeSet::new(); + let mut next_states = self.states.clone(); if saved_peer_exists && self.states.saved().get(&identity).is_none() { let peer = saved_document .peers() .iter() .find(|peer| peer.identity == Some(identity)) .ok_or(AppError::DeletedPeerAddressUnknown)?; - for network in &peer.allowed_ips { - match network { - ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { - additional_reservations.insert(IpAddr::V4(network.addr())); - } - ipnet::IpNet::V6(network) if network.prefix_len() == 128 => { - additional_reservations.insert(IpAddr::V6(network.addr())); - } - _ => return Err(AppError::DeletedPeerAddressUnknown), - } - } - if peer.allowed_ips.is_empty() || additional_reservations.is_empty() { - return Err(AppError::DeletedPeerAddressUnknown); - } + next_states + .stage_exact_host_routes(peer.allowed_ips.iter().copied()) + .map_err(|_| AppError::DeletedPeerAddressUnknown)?; } let edited = self.document.delete_client(&identity)?; let removed_record = match self.vault.as_mut() { Some(vault) => vault.remove_record(identity)?, None => false, }; - self.states.draft_mut().remove(&identity); - self.draft_unsupported_reservations - .extend(additional_reservations); + next_states.draft_mut().remove(&identity); + self.states = next_states; self.document = edited; self.invalidate_active_issue_authorizations(); self.initial_issues.remove(&identity); @@ -1365,11 +1349,10 @@ impl Application { .map(VaultBundle::from_toml) .transpose()?; self.states.discard_draft(); - self.draft_unsupported_reservations.clear(); if let Some(bytes) = &self.saved.internal_state { PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?; } else { - self.states.mark_runtime_unknown(); + self.states.runtime_knowledge_unknown(); } self.historical_apply = historical_apply_from_saved(&self.saved)?; self.initial_issues @@ -1457,7 +1440,12 @@ impl Application { }; self.states.draft() != self.states.saved() || self.document.render() != self.saved.server_config - || !self.draft_unsupported_reservations.is_empty() + || self + .states + .address_reservations() + .pending_host_routes() + .next() + .is_some() || vault_differs } @@ -1708,7 +1696,7 @@ impl Application { ); } - self.states.mark_runtime_unknown(); + self.states.runtime_knowledge_unknown(); let deadline = RestartDeadline::new(now(), settings.restart_timeout); let execution = service(&settings.systemd_unit, deadline); let job = execution.outcome; @@ -1954,7 +1942,6 @@ impl Application { self.vault = vault; self.states = states; self.initial_issues.clear(); - self.draft_unsupported_reservations.clear(); self.historical_apply = HistoricalApplyStatus::Unknown; self.verified_saved_projection = None; self.active_restart_attempt = None; @@ -1980,7 +1967,7 @@ impl Application { return Err(AppError::SavedGenerationChanged); } let mut next_states = self.states.clone(); - next_states.mark_technically_applied_after_verification(observed_management_snapshot)?; + next_states.prepare_technically_verified_result(observed_management_snapshot)?; let mut persisted = match &installed.internal_state { Some(bytes) => PersistedState::from_toml(bytes)?, None => PersistedState::new( @@ -2070,17 +2057,6 @@ impl Application { let mut next_states = self.states.clone(); next_states.commit_draft(); - if !self.draft_unsupported_reservations.is_empty() { - let reservations = next_states - .reserved_addresses() - .iter() - .copied() - .chain(self.draft_unsupported_reservations.iter().copied()) - .map(|address| address.to_string()) - .collect::>(); - let persisted = PersistedState::new(reservations, None)?; - persisted.apply_to(&mut next_states)?; - } let reservations = next_states .reserved_addresses() .iter() @@ -2133,7 +2109,6 @@ impl Application { self.saved = proposed; self.states = next_states; - self.draft_unsupported_reservations.clear(); self.document = ConfigDocument::parse(&self.saved.server_config)?; self.invalidate_active_issue_authorizations(); promote_matching_issue_map( @@ -3105,7 +3080,7 @@ restart_timeout_seconds = 30 .expect("saved generation verified"); let mut runtime_unknown = app.states().clone(); - runtime_unknown.mark_runtime_unknown(); + runtime_unknown.runtime_knowledge_unknown(); let reopened = Application::open(app.store.clone(), runtime_unknown) .expect("application reopens with unknown runtime"); let destination = reopened @@ -3681,6 +3656,56 @@ restart_timeout_seconds = 30 assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n")); } + #[test] + fn unchanged_commit_preserves_verified_apply_knowledge() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store, states).expect("application opens"); + let observed = app.states().saved().clone(); + test_mark_verified(&mut app, observed, "2026-10-29T12:00:00Z".to_owned()) + .expect("synthetic verification is published"); + assert_eq!(app.states().applied(), &ApplyStatus::Verified); + + app.commit().expect("unchanged commit completes"); + + assert_eq!(app.states().applied(), &ApplyStatus::Verified); + assert_eq!(app.states().applied_snapshot(), Some(app.states().saved())); + } + + #[test] + fn unchanged_commit_preserves_unavailable_apply_knowledge() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store, states).expect("application opens"); + app.states.runtime_knowledge_unavailable(); + + app.commit().expect("unchanged commit completes"); + + assert_eq!(app.states().applied(), &ApplyStatus::Unavailable); + } + + #[test] + fn changed_saved_snapshot_demotes_verified_apply_knowledge_on_commit() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let mut app = Application::open(store, states).expect("application opens"); + let observed = app.states().saved().clone(); + test_mark_verified( + &mut app, + observed.clone(), + "2026-10-29T12:00:00Z".to_owned(), + ) + .expect("synthetic verification is published"); + app.rename_client(key, "changed saved peer") + .expect("peer change is staged"); + + app.commit().expect("changed commit completes"); + + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); + assert_eq!(app.states().applied_snapshot(), Some(&observed)); + assert_ne!(app.states().saved(), &observed); + } + #[test] fn discard_restores_draft_without_writing_installed_bytes() { let root = tempfile::tempdir().expect("temp directory"); @@ -4976,6 +5001,18 @@ restart_timeout_seconds = 30 assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } + #[test] + fn open_does_not_treat_caller_seeded_verified_state_as_runtime_evidence() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, seeded, _, _) = setup(root.path(), false); + assert_eq!(seeded.applied(), &ApplyStatus::Verified); + let app = Application::open(store, seeded).expect("application opens"); + + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); + assert!(app.states().applied_snapshot().is_some()); + assert!(!app.saved_generation_is_verified_applied(app.saved_bytes(), app.document())); + } + #[test] fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() { let root = tempfile::tempdir().expect("temp directory"); diff --git a/src/domain.rs b/src/domain.rs index 3488ec5..9cbfa30 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -8,6 +8,70 @@ use std::str::FromStr; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use ipnet::{Ipv4Net, Ipv6Net}; + +/// Committed addresses retained until verified application and exact host +/// routes staged by a draft deletion. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AddressReservations { + committed: BTreeSet, + pending_host_routes: BTreeSet, +} + +impl AddressReservations { + pub fn committed(&self) -> &BTreeSet { + &self.committed + } + + pub fn pending_host_routes(&self) -> impl Iterator + '_ { + self.pending_host_routes.iter().copied() + } + + pub fn all(&self) -> impl Iterator + '_ { + self.committed + .iter() + .chain(&self.pending_host_routes) + .copied() + } + + /// Validate the complete route list before staging any pending address. + pub(crate) fn stage_exact_host_routes( + &mut self, + routes: impl IntoIterator, + ) -> Result<(), DomainError> { + let mut addresses = BTreeSet::new(); + let mut route_count = 0; + for route in routes { + route_count += 1; + match route { + ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { + addresses.insert(IpAddr::V4(network.addr())); + } + ipnet::IpNet::V6(network) if network.prefix_len() == 128 => { + addresses.insert(IpAddr::V6(network.addr())); + } + _ => return Err(DomainError::UnsafeReservationRoute), + } + } + if route_count == 0 || addresses.is_empty() { + return Err(DomainError::UnsafeReservationRoute); + } + self.pending_host_routes.extend(addresses); + Ok(()) + } + + fn restore_committed(&mut self, reservations: impl IntoIterator) { + self.committed = reservations.into_iter().collect(); + self.pending_host_routes.clear(); + } + + fn promote_pending(&mut self) { + self.committed.append(&mut self.pending_host_routes); + } + + fn release_committed(&mut self) { + self.committed.clear(); + } +} use thiserror::Error; /// A WireGuard public key, decoded and validated as exactly 32 bytes. @@ -154,18 +218,63 @@ pub enum ApplyStatus { Verified, } +/// Current runtime knowledge and the optional complete management snapshot +/// most recently verified. Historical verification time is persisted +/// separately and never initializes this value on reopen. +#[derive(Clone, Debug, Eq, PartialEq)] +struct ApplyKnowledge { + current: ApplyStatus, + last_verified_snapshot: Option, +} + +impl ApplyKnowledge { + fn new(current: ApplyStatus, last_verified_snapshot: Option) -> Self { + Self { + current, + last_verified_snapshot, + } + } + + fn runtime_unknown(&mut self) { + self.current = ApplyStatus::Unknown; + } + + fn runtime_unavailable(&mut self) { + self.current = ApplyStatus::Unavailable; + } + + fn saved_state_changed(&mut self) { + self.current = ApplyStatus::Unknown; + } + + fn new_session(&mut self) { + self.current = ApplyStatus::Unknown; + } + + fn persisted_state_restored(&mut self) { + self.current = ApplyStatus::Unknown; + self.last_verified_snapshot = None; + } + + fn prepare_verified_result(&mut self, complete_snapshot: Option) { + self.last_verified_snapshot = complete_snapshot; + self.current = ApplyStatus::Verified; + } +} + /// Draft, saved, and last verified applied data remain independent snapshots. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientStates { draft: Snapshot, saved: Snapshot, - applied_snapshot: Option, - applied: ApplyStatus, - reserved_addresses: BTreeSet, + apply_knowledge: ApplyKnowledge, + reservations: AddressReservations, } impl ClientStates { /// Start with a saved snapshot and an explicit initial runtime observation. + /// `Application::open` treats caller-provided state as untrusted runtime + /// evidence and resets current knowledge before exposing a session. pub fn new( saved: Snapshot, applied_snapshot: Option, @@ -185,9 +294,11 @@ impl ClientStates { Ok(Self { draft: saved.clone(), saved, - applied_snapshot, - applied, - reserved_addresses, + apply_knowledge: ApplyKnowledge::new(applied, applied_snapshot), + reservations: AddressReservations { + committed: reserved_addresses, + pending_host_routes: BTreeSet::new(), + }, }) } @@ -200,20 +311,31 @@ impl ClientStates { } pub fn applied(&self) -> &ApplyStatus { - &self.applied + &self.apply_knowledge.current } pub fn applied_snapshot(&self) -> Option<&Snapshot> { - self.applied_snapshot.as_ref() + self.apply_knowledge.last_verified_snapshot.as_ref() } pub fn reserved_addresses(&self) -> &BTreeSet { - &self.reserved_addresses + self.reservations.committed() + } + + pub fn address_reservations(&self) -> &AddressReservations { + &self.reservations + } + + pub(crate) fn stage_exact_host_routes( + &mut self, + routes: impl IntoIterator, + ) -> Result<(), DomainError> { + self.reservations.stage_exact_host_routes(routes) } /// Restore conservative reservations from saved metadata without asserting /// that the running interface matches the saved snapshot. - pub fn restore_persisted_reservations( + pub(crate) fn restore_persisted_reservations( &mut self, reservations: impl IntoIterator, ) -> Result<(), DomainError> { @@ -224,9 +346,8 @@ impl ClientStates { { return Err(DomainError::ReservationConflictsWithSavedClient); } - self.reserved_addresses = reservations; - self.applied_snapshot = None; - self.applied = ApplyStatus::Unknown; + self.reservations.restore_committed(reservations); + self.apply_knowledge.persisted_state_restored(); Ok(()) } @@ -237,36 +358,38 @@ impl ClientStates { /// Save the draft in the model only. This performs no persistence or runtime operation. pub fn commit_draft(&mut self) { let next_addresses = self.draft.addresses().collect::>(); - self.reserved_addresses.extend( + self.reservations.committed.extend( self.saved .addresses() .filter(|address| !next_addresses.contains(address)), ); + self.reservations.promote_pending(); self.saved = self.draft.clone(); - if self.applied == ApplyStatus::Verified - && self.applied_snapshot.as_ref() != Some(&self.saved) + if self.apply_knowledge.current == ApplyStatus::Verified + && self.apply_knowledge.last_verified_snapshot.as_ref() != Some(&self.saved) { - self.applied = ApplyStatus::Unknown; + self.apply_knowledge.saved_state_changed(); } } /// Discard draft edits by returning the draft to the saved snapshot. pub fn discard_draft(&mut self) { self.draft = self.saved.clone(); + self.reservations.pending_host_routes.clear(); } - /// Record an applied state only when the verified observation matches saved clients. - pub(crate) fn mark_applied_after_verification( + /// Prepare a verified candidate only when the observation matches saved + /// clients. Callers publish the candidate after durable state installation. + pub(crate) fn prepare_verified_result( &mut self, observed: Snapshot, ) -> Result<(), DomainError> { if observed != self.saved { - self.applied = ApplyStatus::Unknown; + self.apply_knowledge.runtime_unknown(); return Err(DomainError::AppliedSnapshotMismatch); } - self.applied_snapshot = Some(observed); - self.applied = ApplyStatus::Verified; - self.reserved_addresses.clear(); + self.apply_knowledge.prepare_verified_result(Some(observed)); + self.reservations.release_committed(); Ok(()) } @@ -274,25 +397,28 @@ impl ClientStates { /// be represented by the narrower management snapshot. `None` deliberately /// keeps `applied_snapshot` absent instead of treating a partial snapshot as /// evidence that every saved peer was applied. - pub(crate) fn mark_technically_applied_after_verification( + pub(crate) fn prepare_technically_verified_result( &mut self, observed_management_snapshot: Option, ) -> Result<(), DomainError> { if let Some(observed) = observed_management_snapshot { - return self.mark_applied_after_verification(observed); + return self.prepare_verified_result(observed); } - self.applied_snapshot = None; - self.applied = ApplyStatus::Verified; - self.reserved_addresses.clear(); + self.apply_knowledge.prepare_verified_result(None); + self.reservations.release_committed(); Ok(()) } - pub fn mark_runtime_unavailable(&mut self) { - self.applied = ApplyStatus::Unavailable; + pub fn runtime_knowledge_unavailable(&mut self) { + self.apply_knowledge.runtime_unavailable(); } - pub fn mark_runtime_unknown(&mut self) { - self.applied = ApplyStatus::Unknown; + pub(crate) fn runtime_knowledge_unknown(&mut self) { + self.apply_knowledge.runtime_unknown(); + } + + pub(crate) fn begin_runtime_session(&mut self) { + self.apply_knowledge.new_session(); } /// Addresses in draft, saved, or last verified applied data cannot be reused. @@ -301,8 +427,13 @@ impl ClientStates { self.draft .addresses() .chain(self.saved.addresses()) - .chain(self.reserved_addresses.iter().copied()) - .chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses)) + .chain(self.reservations.all()) + .chain( + self.apply_knowledge + .last_verified_snapshot + .iter() + .flat_map(Snapshot::addresses), + ) .collect() } } @@ -333,6 +464,8 @@ pub enum DomainError { AddressUnavailable, #[error("verified applied clients do not match the saved snapshot")] AppliedSnapshotMismatch, + #[error("only exact IPv4/32 or IPv6/128 host routes can be reserved")] + UnsafeReservationRoute, #[error("persisted reservation conflicts with a saved client address")] ReservationConflictsWithSavedClient, } @@ -602,7 +735,7 @@ mod tests { assert_eq!(states.saved().clients().count(), 1); assert_eq!(states.applied(), &ApplyStatus::Unknown); assert_eq!(states.applied_snapshot(), Some(&saved)); - states.mark_runtime_unavailable(); + states.runtime_knowledge_unavailable(); assert_eq!(states.applied(), &ApplyStatus::Unavailable); assert!(states.applied_snapshot().is_some()); assert!( @@ -634,6 +767,62 @@ mod tests { ); } + #[test] + fn exact_host_routes_stay_pending_until_commit_and_discard_drops_them() { + let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); + let address = IpAddr::V4("10.0.0.9".parse().unwrap()); + let route = "10.0.0.9/32".parse::().unwrap(); + let mut states = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap(); + + states.stage_exact_host_routes([route]).unwrap(); + assert!(states.reserved_addresses().is_empty()); + assert_eq!( + states + .address_reservations() + .pending_host_routes() + .collect::>(), + [address] + ); + assert!(states.addresses_in_use().contains(&address)); + states.discard_draft(); + assert!( + states + .address_reservations() + .pending_host_routes() + .next() + .is_none() + ); + assert!(!states.addresses_in_use().contains(&address)); + + states.stage_exact_host_routes([route]).unwrap(); + states.commit_draft(); + assert!( + states + .address_reservations() + .pending_host_routes() + .next() + .is_none() + ); + assert!(states.reserved_addresses().contains(&address)); + } + + #[test] + fn unsafe_route_cannot_partially_change_pending_reservations() { + let mut reservations = AddressReservations::default(); + let exact = "10.0.0.9/32".parse::().unwrap(); + let broad = "10.0.0.0/24".parse::().unwrap(); + reservations.stage_exact_host_routes([exact]).unwrap(); + + assert_eq!( + reservations.stage_exact_host_routes(["10.0.0.10/32".parse().unwrap(), broad]), + Err(DomainError::UnsafeReservationRoute) + ); + assert_eq!( + reservations.pending_host_routes().collect::>(), + [IpAddr::V4("10.0.0.9".parse().unwrap())] + ); + } + #[test] fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() { let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); @@ -645,7 +834,7 @@ mod tests { .unwrap(); let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); assert_eq!( - states.mark_applied_after_verification(wrong), + states.prepare_verified_result(wrong), Err(DomainError::AppliedSnapshotMismatch) ); assert_eq!(states.applied(), &ApplyStatus::Unknown); @@ -655,9 +844,7 @@ mod tests { states.commit_draft(); assert!(!states.reserved_addresses().is_empty()); let saved = states.saved().clone(); - states - .mark_applied_after_verification(saved.clone()) - .unwrap(); + states.prepare_verified_result(saved.clone()).unwrap(); assert_eq!(states.applied(), &ApplyStatus::Verified); assert!(states.reserved_addresses().is_empty()); assert!(