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 |
| 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
+1 -1
View File
@@ -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/
+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,
states: ClientStates,
vault: Option<VaultBundle>,
draft_unsupported_reservations: BTreeSet<IpAddr>,
historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>,
@@ -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<Self, AppError> {
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::<Vec<_>>();
occupied.extend(document_host_addresses(&self.document));
let reserved = self
.states
.reserved_addresses()
.iter()
.copied()
.chain(self.draft_unsupported_reservations.iter().copied())
.collect::<Vec<_>>();
let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
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::<Vec<_>>();
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");
+226 -39
View File
@@ -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<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;
/// 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<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.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStates {
draft: Snapshot,
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: ApplyStatus,
reserved_addresses: BTreeSet<IpAddr>,
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<Snapshot>,
@@ -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<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
/// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations(
pub(crate) fn restore_persisted_reservations(
&mut self,
reservations: impl IntoIterator<Item = IpAddr>,
) -> 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::<BTreeSet<_>>();
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<Snapshot>,
) -> 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::<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]
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!(