diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 13039974..78b65216 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -3093,6 +3093,9 @@ impl Engine { let st = self.st(); crate::ota::Ctx { node_synced: st.node.synced && st.clock.severity != "block", + // Horizon frontier lane: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S + // (the last LOCK line's age; or, when none was ever seen this run, the engine's own uptime) + finality_paused: st.node.synced && (if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }) > crate::manifest::FINALITY_PAUSE_S, boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None }, // a remote job in progress counts as busy: no update applies under it (src/jobrun.rs) miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting") || self.jobs.active(), diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 8ad020f8..7ff1a94d 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -52,6 +52,9 @@ pub struct Manifest { pub min_supported_version: String, pub notes: String, pub activation_height: Option, + /// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the + /// network's finality is paused (nothing else does); false unless the signed manifest says so + pub urgent: bool, pub deadline_note: String, /// consensus.override: the exact object the engine writes to /override.json for igneumd's /// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest. @@ -157,6 +160,8 @@ pub fn parse(text: &str) -> Result { min_supported_version: s(&v, "min_supported_version"), notes: s(&v, "notes"), activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()), + // the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through) + urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false), deadline_note: s(&consensus, "deadline_note"), override_params: match consensus.get("override") { Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()), @@ -308,8 +313,16 @@ pub struct Moment { pub slot_ok: bool, /// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent. pub network_drop_pct: f64, + /// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint + /// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock + pub finality_paused: bool, + /// the signed manifest's own urgent flag: the one thing that installs while finality is paused + pub manifest_urgent: bool, } +/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes). +pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0; + /// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet). pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0; @@ -320,6 +333,10 @@ pub fn slot_minute(id8: &str) -> u64 { /// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows. pub fn safe_to_apply(m: &Moment) -> Result<(), String> { + // the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag + if m.finality_paused && !m.manifest_urgent { + return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into()); + } if m.urgent { return Ok(()); } @@ -492,8 +509,15 @@ mod tests { #[test] fn safe_moments() { - let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 }; + let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false }; assert!(safe_to_apply(&base).is_ok()); + // the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install; + // paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it + assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality")); + assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err()); + assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err()); assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync"); assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s")); assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err()); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index cfd00841..4e8f3345 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -68,6 +68,8 @@ pub enum Launch { pub struct Ctx { pub node_synced: bool, + /// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S) + pub finality_paused: bool, pub boundary_eta_s: Option, pub miner_busy: bool, pub daa: u64, @@ -539,7 +541,8 @@ impl Updater { } let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false); let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0); - let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct }; + let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false); + let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct, finality_paused: ctx.finality_paused, manifest_urgent }; if !self.auto && !urgent && !self.install_asked { shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into(); return None; diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 0650c1cf..cd0b9377 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -326,6 +326,7 @@ For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` | | `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test | | `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists | +| the finality rule (updater) | Horizon frontier lane: the updater installs nothing while the network's finality is paused (a synced node with no checkpoint lock for `manifest::FINALITY_PAUSE_S` = 15 min; the last LOCK line's age, else the engine's uptime); the update card reads "waiting for finality: ..."; slot, catch-up and patience rules unchanged otherwise; only the signed manifest's own `urgent` flag installs through a pause (a fork-close or unsupported urgency does not); the known-failed case is the test | manifest.rs `Moment.finality_paused`, `manifest_urgent`, `Manifest.urgent`, `safe_to_apply` + test; ota.rs `Ctx`; engine.rs | | `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs | For Miner UI 4's Cards tab (Ember as its second layer), on ember-tune: