Updater (0.3.16, Horizon frontier lane): nothing installs while the network's finality is paused (a synced node with no checkpoint lock for 15 min); the update card reads "waiting for finality"; only the signed manifest's own urgent flag installs through a pause, a fork-close or unsupported urgency does not; slot, catch-up and patience rules unchanged; the known-failed case tested

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 19:47:37 +00:00
parent f9c4ac3701
commit 700312688a
4 changed files with 33 additions and 2 deletions

View file

@ -3093,6 +3093,9 @@ impl Engine {
let st = self.st(); let st = self.st();
crate::ota::Ctx { crate::ota::Ctx {
node_synced: st.node.synced && st.clock.severity != "block", 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 }, 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) // 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(), miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting") || self.jobs.active(),

View file

@ -52,6 +52,9 @@ pub struct Manifest {
pub min_supported_version: String, pub min_supported_version: String,
pub notes: String, pub notes: String,
pub activation_height: Option<u64>, pub activation_height: Option<u64>,
/// 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, pub deadline_note: String,
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's /// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest. /// --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<Manifest, String> {
min_supported_version: s(&v, "min_supported_version"), min_supported_version: s(&v, "min_supported_version"),
notes: s(&v, "notes"), notes: s(&v, "notes"),
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()), 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"), deadline_note: s(&consensus, "deadline_note"),
override_params: match consensus.get("override") { override_params: match consensus.get("override") {
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()), 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, pub slot_ok: bool,
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent. /// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
pub network_drop_pct: f64, 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). /// 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; 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. /// 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> { 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 { if m.urgent {
return Ok(()); return Ok(());
} }
@ -492,8 +509,15 @@ mod tests {
#[test] #[test]
fn safe_moments() { 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()); 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_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(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err()); assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());

View file

@ -68,6 +68,8 @@ pub enum Launch {
pub struct Ctx { pub struct Ctx {
pub node_synced: bool, 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<i64>, pub boundary_eta_s: Option<i64>,
pub miner_busy: bool, pub miner_busy: bool,
pub daa: u64, 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 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 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 { if !self.auto && !urgent && !self.install_asked {
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into(); shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
return None; return None;

View file

@ -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.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.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 | | `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 | | `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: For Miner UI 4's Cards tab (Ember as its second layer), on ember-tune: