Skip to content

Commit 2f27ed9

Browse files
committed
refactor(state-machine): proof-carrying Connected state + revocation bus skeleton (Phase 0)
Phase 0 of the Proof-Carrying Connected refactor (plan: ~/.claude/plans/iterative-roaming-aho.md). Zero behavior change — this ship only adds the type-level scaffolding so later phases can land incrementally. What ships here: * New `state_machine::verifier` module defining: - `ConnectedProof` struct (crate-private constructors `mint()` and `forced()`). Code outside `state_machine::verifier` cannot mint one. - `EvidenceKinds` bitflags (UI_SNAPSHOT, WINDOW_INVENTORY, EVENT_STREAM, TCP_PROBE, SUPERVISOR_ALIVE, FORCED). - `RevocationSource` enum with per-source `debounce()`, `next_state()`, and `tag()`. JvmDied/ReloginDialog/SessionConflict fire immediately; ErrorDialog/WindowClassMorphed debounce 500ms; LoginFormVisible 1s; DisconnectedLabelStable 2s. - `RevocationTracker` with per-tag first-seen timestamps, `observe()` returning Some once debounce elapses, `clear()` / `clear_all()`. - `VerificationFailure` enum for Phase 2. - 10 unit tests covering tracker debounce semantics. * `State::Connected` → `State::Connected(ConnectedProof)`: - `Display` impl ignores the proof payload, preserving the external JSON contract (`"state":"Connected"`). - `State::from_name("Connected")` now produces a `forced` sentinel proof flagged with `EvidenceKinds::FORCED` for SETSTATE override. - Call-site updates: `matches!(state, State::Connected(_))` replaces `== State::Connected` in every semantic "is this Connected?" check (queries.rs, mod.rs:222/225/260/478, types.rs client_advisory). - The 3 current promotion sites (mod.rs:1157/1456/1593) pass a forced sentinel — these will route through `try_enter_connected()` in Phase 2. * `StateMachine` now owns a `RevocationTracker` field; `clear_all()` is called in `apply_transition()` whenever we leave the Connected family, so stale debounce timers from a prior session don't leak across. Tests: 141 pass (was 131). Clippy clean. No production behavior change yet — zion should still reach and hold Connected exactly as before. Phase 1 wires the revocation tracker into do_connected for source-tagged demotion.
1 parent 65a99ca commit 2f27ed9

5 files changed

Lines changed: 526 additions & 24 deletions

File tree

ibctl/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ toml = "0.8"
1818
futures-core = "0.3"
1919
secrecy = "0.10"
2020
jiff = "0.2"
21+
bitflags = "2"
2122
nix = { version = "0.30", features = ["signal", "process"] }
2223

2324
[lints]

ibctl/src/state_machine/mod.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
mod queries;
1111
mod socat;
1212
mod types;
13+
mod verifier;
1314

1415
// Re-export public API
1516
pub use types::{Channels, State, StateMachine, StateMachineError};
17+
#[allow(unused_imports)] // surfaced in later phases
18+
pub(super) use verifier::{ConnectedProof, EvidenceKinds, RevocationSource, RevocationTracker};
1619

1720
use std::path::Path;
1821
use std::time::Instant;
@@ -216,11 +219,16 @@ impl StateMachine {
216219
self.state_entered_at = Instant::now();
217220
self.consecutive_agent_failures = 0;
218221

219-
if next == State::Connected && self.state != State::Connected {
222+
let next_is_connected = matches!(next, State::Connected(_));
223+
let curr_is_connected = matches!(self.state, State::Connected(_));
224+
if next_is_connected && !curr_is_connected {
220225
self.connected_since = Some(Instant::now());
221226
self.relogin_attempts = 0;
222-
} else if next != State::Connected {
227+
} else if !next_is_connected {
223228
self.connected_since = None;
229+
// Leaving Connected — reset per-source revocation debounce state
230+
// so the next Connected session starts with a clean slate.
231+
self.revocation.clear_all();
224232
}
225233

226234
// Reset 2FA device state when starting a new login or 2FA cycle
@@ -254,7 +262,7 @@ impl StateMachine {
254262
// so window events may carry stale has_login_button=true during
255263
// the authentication animation.
256264
if matches!(next, State::DismissingPopups | State::WaitingFor2fa
257-
| State::WaitingForApiReady | State::ConfiguringApi | State::Connected)
265+
| State::WaitingForApiReady | State::ConfiguringApi | State::Connected(_))
258266
{
259267
self.observation.clear_login_buttons();
260268
}
@@ -475,7 +483,7 @@ impl StateMachine {
475483
State::DismissingPopups => self.do_dismiss_popups().await,
476484
State::WaitingForApiReady => self.do_wait_for_api_ready().await,
477485
State::ConfiguringApi => self.do_configure_api().await,
478-
State::Connected => self.do_connected().await,
486+
State::Connected(_) => self.do_connected().await,
479487
State::ReconnectingSession => self.do_reconnecting_session().await,
480488
State::Restarting => self.do_restart().await,
481489
State::WaitingForIB => self.do_waiting_for_ib().await,
@@ -1146,7 +1154,10 @@ impl StateMachine {
11461154
Ok(()) => {
11471155
log::info!("API configuration complete");
11481156
self.config_retries = 0;
1149-
Ok(State::Connected)
1157+
// Phase 0: use forced sentinel so the enum-shape change is a
1158+
// no-op behavior change. Phase 2 replaces this with a real
1159+
// `try_enter_connected(...)` call backed by the verifier.
1160+
Ok(State::Connected(verifier::ConnectedProof::forced()))
11501161
}
11511162
Err(e) => {
11521163
// Close any menu left open by the failed attempt
@@ -1445,7 +1456,10 @@ impl StateMachine {
14451456
// This sleep is now just a reconciliation tick — events handle the fast path.
14461457

14471458
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1448-
Ok(State::Connected)
1459+
// Phase 0: stay-Connected self-return. Phase 1 will replace this path
1460+
// entirely — the revocation bus drives demotion; otherwise the existing
1461+
// proof is retained without re-verification.
1462+
Ok(State::Connected(verifier::ConnectedProof::forced()))
14491463
}
14501464

14511465
/// Single-step graduated session recovery.
@@ -1582,7 +1596,8 @@ impl StateMachine {
15821596
if confirmed_authenticated {
15831597
log::info!("RE-LOGIN: Gateway authenticated (positive confirmation)");
15841598
self.relogin_attempts = 0;
1585-
return Ok(State::Connected);
1599+
// Phase 0 sentinel — Phase 2 routes through verifier.
1600+
return Ok(State::Connected(verifier::ConnectedProof::forced()));
15861601
}
15871602

15881603
// Inconclusive — stay in ReconnectingSession (retry next tick)
@@ -2452,7 +2467,7 @@ login_dialog_timeout_secs = 0
24522467
};
24532468

24542469
let mut sm = make_test_state_machine(mock);
2455-
sm.state = State::Connected;
2470+
sm.state = State::Connected(verifier::ConnectedProof::forced());
24562471
// Position the liveness timer in the 30-second check window so the
24572472
// periodic check actually fires this iteration.
24582473
sm.state_entered_at = Instant::now() - Duration::from_secs(30);
@@ -2495,9 +2510,10 @@ login_dialog_timeout_secs = 0
24952510
sm.state = State::ConfiguringApi;
24962511

24972512
let result = sm.do_configure_api().await.expect("handler should not error");
2498-
assert_eq!(
2499-
result, State::Connected,
2500-
"no API settings configured ⇒ skip dialog ⇒ Connected (valid no-op path)"
2513+
assert!(
2514+
matches!(result, State::Connected(_)),
2515+
"no API settings configured ⇒ skip dialog ⇒ Connected (valid no-op path), got {:?}",
2516+
result
25012517
);
25022518
}
25032519

@@ -2516,7 +2532,7 @@ login_dialog_timeout_secs = 0
25162532
};
25172533

25182534
let mut sm = make_test_state_machine(mock);
2519-
sm.state = State::Connected;
2535+
sm.state = State::Connected(verifier::ConnectedProof::forced());
25202536
sm.state_entered_at = Instant::now() - Duration::from_secs(30);
25212537
sm.connected_window_class = Some("ibgateway.ay".to_string());
25222538
sm.observation.synced = true;
@@ -2525,9 +2541,11 @@ login_dialog_timeout_secs = 0
25252541
let result = sm.do_connected().await.expect("handler should not error");
25262542

25272543
// Assert: no transition — stays Connected (handler returns next state to loop back).
2528-
assert_eq!(
2529-
result, State::Connected,
2530-
"healthy Connected state must not self-transition just because a liveness tick fired"
2544+
// Uses matches! rather than assert_eq! because proof instances differ by issued_at.
2545+
assert!(
2546+
matches!(result, State::Connected(_)),
2547+
"healthy Connected state must not self-transition just because a liveness tick fired, got {:?}",
2548+
result
25312549
);
25322550
}
25332551
}

ibctl/src/state_machine/queries.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ impl StateMachine {
9090
.unwrap_or(false);
9191
let socat_pid = self.socat_process.as_ref().map(|c| c.id());
9292

93-
let is_connected = self.state == State::Connected;
93+
let is_connected = matches!(self.state, State::Connected(_));
9494
let (should_connect, should_wait, wait_reason, client_id_likely_stale) =
9595
client_advisory(&self.state);
9696

ibctl/src/state_machine/types.rs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use crate::handlers::DialogHandlerRegistry;
1515
use crate::types::Signal;
1616
use crate::supervisor::Supervisor;
1717

18+
use super::verifier::{ConnectedProof, RevocationTracker};
19+
1820
#[derive(Debug, Error)]
1921
pub enum StateMachineError {
2022
#[error("supervisor error: {0}")]
@@ -52,8 +54,14 @@ pub enum State {
5254
WaitingForApiReady,
5355
/// Applying post-login API configuration (master client ID, read-only, etc.)
5456
ConfiguringApi,
55-
/// Fully connected and monitoring for new dialogs
56-
Connected,
57+
/// Fully connected and monitoring for new dialogs.
58+
///
59+
/// The `ConnectedProof` payload is the verifier's evidence that Gateway
60+
/// is actually ready. It can only be constructed via `verifier::mint()`
61+
/// or `verifier::forced()` (for SETSTATE debug override). No handler can
62+
/// accidentally return `Ok(State::Connected(...))` without going through
63+
/// the verifier — see `state_machine::verifier` module docs.
64+
Connected(ConnectedProof),
5765
/// Recovering from connection loss — graduated re-login flow.
5866
/// Waits 30s, clicks Re-login, tracks attempts. If failed, restarts JVM.
5967
ReconnectingSession,
@@ -82,7 +90,11 @@ impl State {
8290
"DismissingPopups" => Some(State::DismissingPopups),
8391
"WaitingForApiReady" => Some(State::WaitingForApiReady),
8492
"ConfiguringApi" => Some(State::ConfiguringApi),
85-
"Connected" => Some(State::Connected),
93+
// SETSTATE Connected produces a synthetic "forced" proof.
94+
// This is a debug-override path — operators should see the
95+
// `evidence=[forced]` marker in logs and STATUS JSON so they can
96+
// tell a forced Connected apart from a verifier-minted one.
97+
"Connected" => Some(State::Connected(ConnectedProof::forced())),
8698
"ReconnectingSession" => Some(State::ReconnectingSession),
8799
"Restarting" => Some(State::Restarting),
88100
"WaitingForIB" => Some(State::WaitingForIB),
@@ -96,6 +108,10 @@ impl std::fmt::Display for State {
96108
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97109
match self {
98110
State::Error(msg) => write!(f, "Error({})", msg),
111+
// Display ignores the proof payload to preserve external JSON
112+
// contract: STATUS still reports `"state":"Connected"`. Proof
113+
// provenance is exposed via a separate `proof` field (Phase 3).
114+
State::Connected(_) => write!(f, "Connected"),
99115
other => write!(f, "{:?}", other),
100116
}
101117
}
@@ -237,6 +253,12 @@ pub struct StateMachine {
237253
pub(super) snapshot_tx: watch::Sender<Arc<QuerySnapshot>>,
238254
/// Monotonic version counter for snapshots.
239255
pub(super) snapshot_version: u64,
256+
/// Source-tagged revocation bus for `Connected` state. Tracks per-source
257+
/// debounce timers for contradictions (login form reappears, "disconnected"
258+
/// label stabilizes, window class morph, etc.). See `verifier::RevocationSource`.
259+
/// Used by `do_connected` to decide when a contradiction has persisted
260+
/// long enough to revoke the proof and transition out.
261+
pub(super) revocation: RevocationTracker,
240262
}
241263

242264
impl StateMachine {
@@ -282,6 +304,7 @@ impl StateMachine {
282304
stats: Stats::default(),
283305
snapshot_tx,
284306
snapshot_version: 0,
307+
revocation: RevocationTracker::new(),
285308
}
286309
}
287310

@@ -311,7 +334,7 @@ pub(crate) fn client_advisory(state: &State) -> (bool, bool, Option<&'static str
311334
State::WaitingFor2fa => (false, true, Some("2fa_pending")),
312335
State::HandlingSessionConflict => (false, true, Some("session_conflict")),
313336
State::DismissingPopups | State::WaitingForApiReady | State::ConfiguringApi => (false, true, Some("configuring")),
314-
State::Connected => (true, false, None),
337+
State::Connected(_) => (true, false, None),
315338
State::ReconnectingSession => (false, true, Some("reconnecting")),
316339
State::Restarting => (false, true, Some("restarting")),
317340
State::WaitingForIB => (false, true, Some("ib_maintenance")),
@@ -337,7 +360,8 @@ mod tests {
337360

338361
#[test]
339362
fn test_connected_should_connect() {
340-
let (should_connect, should_wait, reason, stale) = client_advisory(&State::Connected);
363+
let (should_connect, should_wait, reason, stale) =
364+
client_advisory(&State::Connected(ConnectedProof::forced()));
341365
assert!(should_connect);
342366
assert!(!should_wait);
343367
assert!(reason.is_none());
@@ -406,12 +430,13 @@ mod tests {
406430
State::WaitingForLogin, State::Authenticating,
407431
State::WaitingFor2fa, State::HandlingSessionConflict,
408432
State::DismissingPopups, State::ConfiguringApi,
409-
State::Connected, State::Restarting, State::Shutdown,
433+
State::Connected(ConnectedProof::forced()),
434+
State::Restarting, State::Shutdown,
410435
State::Error("test".into()),
411436
];
412437
for state in &states {
413438
let (sc, sw, _, _) = client_advisory(state);
414-
if matches!(state, State::Connected) {
439+
if matches!(state, State::Connected(_)) {
415440
assert!(sc, "Connected should allow connect");
416441
assert!(!sw, "Connected should not wait");
417442
}
@@ -421,7 +446,11 @@ mod tests {
421446
#[test]
422447
fn test_state_display() {
423448
assert_eq!(State::Init.to_string(), "Init");
424-
assert_eq!(State::Connected.to_string(), "Connected");
449+
assert_eq!(
450+
State::Connected(ConnectedProof::forced()).to_string(),
451+
"Connected",
452+
"Display must ignore the proof payload to preserve JSON contract"
453+
);
425454
assert_eq!(State::WaitingFor2fa.to_string(), "WaitingFor2fa");
426455
assert_eq!(State::Error("boom".into()).to_string(), "Error(boom)");
427456
}

0 commit comments

Comments
 (0)