Add domain ownership of reservations and apply knowledge

This commit is contained in:
2026-10-29 23:03:13 +02:00
parent 8413b382b1
commit 2766c6254a
5 changed files with 349 additions and 100 deletions
+12 -12
View File
@@ -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 | | 18 | Readable, complete in-app help for implemented workflows | 17 | accepted |
| 19 | Refactoring contract, terminology and behavior map | 18 | accepted | | 19 | Refactoring contract, terminology and behavior map | 18 | accepted |
| 20 | Explicit vocabulary and named operation contexts | 19 | 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 | | 22 | Transactional draft editing and client creation vertical slice | 21 | pending |
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending | | 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | 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 ## 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 ### 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 ### 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 #### Outcome and scope
@@ -3965,9 +3965,9 @@ Encapsulate address retention and verification transitions without changing pers
| Task | Owner | State | Bounded deliverable | | Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M21.1 | implementer | pending | Encapsulated reservation rules and existing allocator integration | | 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 | pending | Explicit current/historical verification transitions and caller migration | | M21.2 | implementer `/root/m21_reservations` | complete | Explicit current/historical verification transitions and caller migration; focused tests passed |
| M21.3 | reviewer; repairer; coordinator | pending | Review invariant matrix, repair and RUST acceptance | | 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. 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. 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 #### Acceptance and validation
- [ ] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them. - [x] 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. - [x] 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-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 ### 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 ## 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. - 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 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. - 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 ## Plan maintenance
+1 -1
View File
@@ -28,7 +28,7 @@ src/
main.rs # Startup, arguments, process exit main.rs # Startup, arguments, process exit
lib.rs # Module declarations and application API lib.rs # Module declarations and application API
config.rs # Application settings and path resolution 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 app.rs # Application operations and orchestration
wireguard.rs # Server configuration module boundary wireguard.rs # Server configuration module boundary
wireguard/ wireguard/
+25
View File
@@ -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.
+85 -48
View File
@@ -81,7 +81,6 @@ pub struct Application {
document: ConfigDocument, document: ConfigDocument,
states: ClientStates, states: ClientStates,
vault: Option<VaultBundle>, vault: Option<VaultBundle>,
draft_unsupported_reservations: BTreeSet<IpAddr>,
historical_apply: HistoricalApplyStatus, historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>, verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>, active_restart_attempt: Option<u64>,
@@ -477,7 +476,11 @@ impl fmt::Debug for Application {
) )
.field( .field(
"pending_reservation_count", "pending_reservation_count",
&self.draft_unsupported_reservations.len(), &self
.states
.address_reservations()
.pending_host_routes()
.count(),
) )
.field("initial_issue_count", &self.initial_issues.len()) .field("initial_issue_count", &self.initial_issues.len())
.finish() .finish()
@@ -511,7 +514,7 @@ impl Application {
saved: SavedBytes, saved: SavedBytes,
mut states: ClientStates, mut states: ClientStates,
) -> Result<Self, AppError> { ) -> Result<Self, AppError> {
states.mark_runtime_unknown(); states.begin_runtime_session();
let document = ConfigDocument::parse(&saved.server_config)?; let document = ConfigDocument::parse(&saved.server_config)?;
let vault = saved let vault = saved
.vault .vault
@@ -531,7 +534,6 @@ impl Application {
document, document,
states, states,
vault, vault,
draft_unsupported_reservations: BTreeSet::new(),
historical_apply, historical_apply,
verified_saved_projection: None, verified_saved_projection: None,
active_restart_attempt: None, active_restart_attempt: None,
@@ -633,13 +635,7 @@ impl Application {
.into_iter() .into_iter()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.document)); occupied.extend(document_host_addresses(&self.document));
let reserved = self let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
.states
.reserved_addresses()
.iter()
.copied()
.chain(self.draft_unsupported_reservations.iter().copied())
.collect::<Vec<_>>();
let availability = AddressAvailability { let availability = AddressAvailability {
server: &server_addresses, server: &server_addresses,
occupied: &occupied, occupied: &occupied,
@@ -1268,36 +1264,24 @@ impl Application {
.peers() .peers()
.iter() .iter()
.any(|peer| peer.identity == Some(identity)); .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() { if saved_peer_exists && self.states.saved().get(&identity).is_none() {
let peer = saved_document let peer = saved_document
.peers() .peers()
.iter() .iter()
.find(|peer| peer.identity == Some(identity)) .find(|peer| peer.identity == Some(identity))
.ok_or(AppError::DeletedPeerAddressUnknown)?; .ok_or(AppError::DeletedPeerAddressUnknown)?;
for network in &peer.allowed_ips { next_states
match network { .stage_exact_host_routes(peer.allowed_ips.iter().copied())
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { .map_err(|_| AppError::DeletedPeerAddressUnknown)?;
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);
}
} }
let edited = self.document.delete_client(&identity)?; let edited = self.document.delete_client(&identity)?;
let removed_record = match self.vault.as_mut() { let removed_record = match self.vault.as_mut() {
Some(vault) => vault.remove_record(identity)?, Some(vault) => vault.remove_record(identity)?,
None => false, None => false,
}; };
self.states.draft_mut().remove(&identity); next_states.draft_mut().remove(&identity);
self.draft_unsupported_reservations self.states = next_states;
.extend(additional_reservations);
self.document = edited; self.document = edited;
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
self.initial_issues.remove(&identity); self.initial_issues.remove(&identity);
@@ -1365,11 +1349,10 @@ impl Application {
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.states.discard_draft(); self.states.discard_draft();
self.draft_unsupported_reservations.clear();
if let Some(bytes) = &self.saved.internal_state { if let Some(bytes) = &self.saved.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?; PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
} else { } else {
self.states.mark_runtime_unknown(); self.states.runtime_knowledge_unknown();
} }
self.historical_apply = historical_apply_from_saved(&self.saved)?; self.historical_apply = historical_apply_from_saved(&self.saved)?;
self.initial_issues self.initial_issues
@@ -1457,7 +1440,12 @@ impl Application {
}; };
self.states.draft() != self.states.saved() self.states.draft() != self.states.saved()
|| self.document.render() != self.saved.server_config || 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 || 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 deadline = RestartDeadline::new(now(), settings.restart_timeout);
let execution = service(&settings.systemd_unit, deadline); let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome; let job = execution.outcome;
@@ -1954,7 +1942,6 @@ impl Application {
self.vault = vault; self.vault = vault;
self.states = states; self.states = states;
self.initial_issues.clear(); self.initial_issues.clear();
self.draft_unsupported_reservations.clear();
self.historical_apply = HistoricalApplyStatus::Unknown; self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None; self.verified_saved_projection = None;
self.active_restart_attempt = None; self.active_restart_attempt = None;
@@ -1980,7 +1967,7 @@ impl Application {
return Err(AppError::SavedGenerationChanged); return Err(AppError::SavedGenerationChanged);
} }
let mut next_states = self.states.clone(); 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 { let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?, Some(bytes) => PersistedState::from_toml(bytes)?,
None => PersistedState::new( None => PersistedState::new(
@@ -2070,17 +2057,6 @@ impl Application {
let mut next_states = self.states.clone(); let mut next_states = self.states.clone();
next_states.commit_draft(); 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::<Vec<_>>();
let persisted = PersistedState::new(reservations, None)?;
persisted.apply_to(&mut next_states)?;
}
let reservations = next_states let reservations = next_states
.reserved_addresses() .reserved_addresses()
.iter() .iter()
@@ -2133,7 +2109,6 @@ impl Application {
self.saved = proposed; self.saved = proposed;
self.states = next_states; self.states = next_states;
self.draft_unsupported_reservations.clear();
self.document = ConfigDocument::parse(&self.saved.server_config)?; self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
promote_matching_issue_map( promote_matching_issue_map(
@@ -3105,7 +3080,7 @@ restart_timeout_seconds = 30
.expect("saved generation verified"); .expect("saved generation verified");
let mut runtime_unknown = app.states().clone(); 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) let reopened = Application::open(app.store.clone(), runtime_unknown)
.expect("application reopens with unknown runtime"); .expect("application reopens with unknown runtime");
let destination = reopened let destination = reopened
@@ -3681,6 +3656,56 @@ restart_timeout_seconds = 30
assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n")); 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] #[test]
fn discard_restores_draft_without_writing_installed_bytes() { fn discard_restores_draft_without_writing_installed_bytes() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -4976,6 +5001,18 @@ restart_timeout_seconds = 30
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); 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] #[test]
fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() { fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
+226 -39
View File
@@ -8,6 +8,70 @@ use std::str::FromStr;
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use ipnet::{Ipv4Net, Ipv6Net}; 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<IpAddr>,
pending_host_routes: BTreeSet<IpAddr>,
}
impl AddressReservations {
pub fn committed(&self) -> &BTreeSet<IpAddr> {
&self.committed
}
pub fn pending_host_routes(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.pending_host_routes.iter().copied()
}
pub fn all(&self) -> impl Iterator<Item = IpAddr> + '_ {
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<Item = ipnet::IpNet>,
) -> 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<Item = IpAddr>) {
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; use thiserror::Error;
/// A WireGuard public key, decoded and validated as exactly 32 bytes. /// A WireGuard public key, decoded and validated as exactly 32 bytes.
@@ -154,18 +218,63 @@ pub enum ApplyStatus {
Verified, 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<Snapshot>,
}
impl ApplyKnowledge {
fn new(current: ApplyStatus, last_verified_snapshot: Option<Snapshot>) -> 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<Snapshot>) {
self.last_verified_snapshot = complete_snapshot;
self.current = ApplyStatus::Verified;
}
}
/// Draft, saved, and last verified applied data remain independent snapshots. /// Draft, saved, and last verified applied data remain independent snapshots.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStates { pub struct ClientStates {
draft: Snapshot, draft: Snapshot,
saved: Snapshot, saved: Snapshot,
applied_snapshot: Option<Snapshot>, apply_knowledge: ApplyKnowledge,
applied: ApplyStatus, reservations: AddressReservations,
reserved_addresses: BTreeSet<IpAddr>,
} }
impl ClientStates { impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation. /// 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( pub fn new(
saved: Snapshot, saved: Snapshot,
applied_snapshot: Option<Snapshot>, applied_snapshot: Option<Snapshot>,
@@ -185,9 +294,11 @@ impl ClientStates {
Ok(Self { Ok(Self {
draft: saved.clone(), draft: saved.clone(),
saved, saved,
applied_snapshot, apply_knowledge: ApplyKnowledge::new(applied, applied_snapshot),
applied, reservations: AddressReservations {
reserved_addresses, committed: reserved_addresses,
pending_host_routes: BTreeSet::new(),
},
}) })
} }
@@ -200,20 +311,31 @@ impl ClientStates {
} }
pub fn applied(&self) -> &ApplyStatus { pub fn applied(&self) -> &ApplyStatus {
&self.applied &self.apply_knowledge.current
} }
pub fn applied_snapshot(&self) -> Option<&Snapshot> { 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<IpAddr> { pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
&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<Item = ipnet::IpNet>,
) -> Result<(), DomainError> {
self.reservations.stage_exact_host_routes(routes)
} }
/// Restore conservative reservations from saved metadata without asserting /// Restore conservative reservations from saved metadata without asserting
/// that the running interface matches the saved snapshot. /// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations( pub(crate) fn restore_persisted_reservations(
&mut self, &mut self,
reservations: impl IntoIterator<Item = IpAddr>, reservations: impl IntoIterator<Item = IpAddr>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
@@ -224,9 +346,8 @@ impl ClientStates {
{ {
return Err(DomainError::ReservationConflictsWithSavedClient); return Err(DomainError::ReservationConflictsWithSavedClient);
} }
self.reserved_addresses = reservations; self.reservations.restore_committed(reservations);
self.applied_snapshot = None; self.apply_knowledge.persisted_state_restored();
self.applied = ApplyStatus::Unknown;
Ok(()) Ok(())
} }
@@ -237,36 +358,38 @@ impl ClientStates {
/// Save the draft in the model only. This performs no persistence or runtime operation. /// Save the draft in the model only. This performs no persistence or runtime operation.
pub fn commit_draft(&mut self) { pub fn commit_draft(&mut self) {
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>(); let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
self.reserved_addresses.extend( self.reservations.committed.extend(
self.saved self.saved
.addresses() .addresses()
.filter(|address| !next_addresses.contains(address)), .filter(|address| !next_addresses.contains(address)),
); );
self.reservations.promote_pending();
self.saved = self.draft.clone(); self.saved = self.draft.clone();
if self.applied == ApplyStatus::Verified if self.apply_knowledge.current == ApplyStatus::Verified
&& self.applied_snapshot.as_ref() != Some(&self.saved) && 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. /// Discard draft edits by returning the draft to the saved snapshot.
pub fn discard_draft(&mut self) { pub fn discard_draft(&mut self) {
self.draft = self.saved.clone(); self.draft = self.saved.clone();
self.reservations.pending_host_routes.clear();
} }
/// Record an applied state only when the verified observation matches saved clients. /// Prepare a verified candidate only when the observation matches saved
pub(crate) fn mark_applied_after_verification( /// clients. Callers publish the candidate after durable state installation.
pub(crate) fn prepare_verified_result(
&mut self, &mut self,
observed: Snapshot, observed: Snapshot,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if observed != self.saved { if observed != self.saved {
self.applied = ApplyStatus::Unknown; self.apply_knowledge.runtime_unknown();
return Err(DomainError::AppliedSnapshotMismatch); return Err(DomainError::AppliedSnapshotMismatch);
} }
self.applied_snapshot = Some(observed); self.apply_knowledge.prepare_verified_result(Some(observed));
self.applied = ApplyStatus::Verified; self.reservations.release_committed();
self.reserved_addresses.clear();
Ok(()) Ok(())
} }
@@ -274,25 +397,28 @@ impl ClientStates {
/// be represented by the narrower management snapshot. `None` deliberately /// be represented by the narrower management snapshot. `None` deliberately
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as /// keeps `applied_snapshot` absent instead of treating a partial snapshot as
/// evidence that every saved peer was applied. /// evidence that every saved peer was applied.
pub(crate) fn mark_technically_applied_after_verification( pub(crate) fn prepare_technically_verified_result(
&mut self, &mut self,
observed_management_snapshot: Option<Snapshot>, observed_management_snapshot: Option<Snapshot>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if let Some(observed) = observed_management_snapshot { 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.apply_knowledge.prepare_verified_result(None);
self.applied = ApplyStatus::Verified; self.reservations.release_committed();
self.reserved_addresses.clear();
Ok(()) Ok(())
} }
pub fn mark_runtime_unavailable(&mut self) { pub fn runtime_knowledge_unavailable(&mut self) {
self.applied = ApplyStatus::Unavailable; self.apply_knowledge.runtime_unavailable();
} }
pub fn mark_runtime_unknown(&mut self) { pub(crate) fn runtime_knowledge_unknown(&mut self) {
self.applied = ApplyStatus::Unknown; 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. /// Addresses in draft, saved, or last verified applied data cannot be reused.
@@ -301,8 +427,13 @@ impl ClientStates {
self.draft self.draft
.addresses() .addresses()
.chain(self.saved.addresses()) .chain(self.saved.addresses())
.chain(self.reserved_addresses.iter().copied()) .chain(self.reservations.all())
.chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses)) .chain(
self.apply_knowledge
.last_verified_snapshot
.iter()
.flat_map(Snapshot::addresses),
)
.collect() .collect()
} }
} }
@@ -333,6 +464,8 @@ pub enum DomainError {
AddressUnavailable, AddressUnavailable,
#[error("verified applied clients do not match the saved snapshot")] #[error("verified applied clients do not match the saved snapshot")]
AppliedSnapshotMismatch, AppliedSnapshotMismatch,
#[error("only exact IPv4/32 or IPv6/128 host routes can be reserved")]
UnsafeReservationRoute,
#[error("persisted reservation conflicts with a saved client address")] #[error("persisted reservation conflicts with a saved client address")]
ReservationConflictsWithSavedClient, ReservationConflictsWithSavedClient,
} }
@@ -602,7 +735,7 @@ mod tests {
assert_eq!(states.saved().clients().count(), 1); assert_eq!(states.saved().clients().count(), 1);
assert_eq!(states.applied(), &ApplyStatus::Unknown); assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&saved)); assert_eq!(states.applied_snapshot(), Some(&saved));
states.mark_runtime_unavailable(); states.runtime_knowledge_unavailable();
assert_eq!(states.applied(), &ApplyStatus::Unavailable); assert_eq!(states.applied(), &ApplyStatus::Unavailable);
assert!(states.applied_snapshot().is_some()); assert!(states.applied_snapshot().is_some());
assert!( 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::<ipnet::IpNet>().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::<Vec<_>>(),
[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::<ipnet::IpNet>().unwrap();
let broad = "10.0.0.0/24".parse::<ipnet::IpNet>().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::<Vec<_>>(),
[IpAddr::V4("10.0.0.9".parse().unwrap())]
);
}
#[test] #[test]
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() { fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
@@ -645,7 +834,7 @@ mod tests {
.unwrap(); .unwrap();
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
assert_eq!( assert_eq!(
states.mark_applied_after_verification(wrong), states.prepare_verified_result(wrong),
Err(DomainError::AppliedSnapshotMismatch) Err(DomainError::AppliedSnapshotMismatch)
); );
assert_eq!(states.applied(), &ApplyStatus::Unknown); assert_eq!(states.applied(), &ApplyStatus::Unknown);
@@ -655,9 +844,7 @@ mod tests {
states.commit_draft(); states.commit_draft();
assert!(!states.reserved_addresses().is_empty()); assert!(!states.reserved_addresses().is_empty());
let saved = states.saved().clone(); let saved = states.saved().clone();
states states.prepare_verified_result(saved.clone()).unwrap();
.mark_applied_after_verification(saved.clone())
.unwrap();
assert_eq!(states.applied(), &ApplyStatus::Verified); assert_eq!(states.applied(), &ApplyStatus::Verified);
assert!(states.reserved_addresses().is_empty()); assert!(states.reserved_addresses().is_empty());
assert!( assert!(