From a35abca13e8b0ab8d63a0a381716ae5ed7743c59 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:51:09 -0700 Subject: [PATCH 1/3] =?UTF-8?q?chore(release):=200.204.0=20=E2=80=94=20con?= =?UTF-8?q?fig=20flag=20failure=20direction=20and=20off-token=20vocabulary?= =?UTF-8?q?=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5065fb9..5efea522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.197.0" +version = "0.204.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 57a832ab..e89a76c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.197.0" +version = "0.204.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From b6dfc8eaf769eef510a6dc922841bb8be6e35176 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:58:53 -0700 Subject: [PATCH 2/3] fix(config): a mistyped capability flag must be said out loud, not silently guessed Settles both halves of #459 separately, taking a different answer on each. FAILURE DIRECTION. Neither flag inverts. #459 proposed the discriminator "can the flag's behaviour reach the network?" -- DIG_WALLET_ENABLE_CHAIN_SYNC does, so that test says fail closed, and applied here it would produce a defect: failing closed on a typo silently stops the replica advancing, and #416 records that a stale replica's zero balance is indistinguishable from an empty wallet. The surviving generalisation is to fail in whichever direction cannot make a surface assert a falsehood -- closed for an isolation knob, open for a default-ON read path. What both cases share is that silence is wrong, so an unrecognised value now names the variable, the rejected value, and the default applied. VOCABULARY. Five capability knobs adopt the shared off-tokens, so 'disabled' works on all of them as it does on the three isolation knobs. The empty-is-off rule is NOT inherited: it is correct for a knob holding a LIST (#312) and would, for a capability knob, reach #416's false zero through the vocabulary having just been refused through the failure direction. is_off_token is crate-private to dig-node-core, so the vocabulary is exposed UP from the crate root rather than copied a fifth time. Closes #459 --- crates/dig-node-core/src/lib.rs | 98 +++++++ .../src/seams/dig_peer/holdings.rs | 9 +- .../src/seams/dig_peer/store_melted.rs | 9 +- crates/dig-node-service/src/config.rs | 271 ++++++++++++++++-- 4 files changed, 357 insertions(+), 30 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 6db96031..a06a0ce8 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -108,6 +108,104 @@ pub use seams::key_mgmt::KeyManager; pub mod shared; pub mod subscription; +// ---- The operator's flag vocabulary, shared across crates (dig-node#459) --------------------- +// +// `peer::is_off_token` is the ONE reading of "the operator turned this off" for the three ISOLATION +// knobs (#282/#352). It is `pub(crate)`, so `dig-node-service`'s capability knobs could not reach it +// and each grew its own narrower copy — which is how `disabled` came to work on three switches and +// not on five. These two functions expose that vocabulary UP to the crate root rather than letting a +// second copy exist (CLAUDE.md §2.0: centralize rival implementations; Appendix B: expose it up). + +/// Is `value` the operator saying "turn this capability OFF"? +/// +/// The same tokens [`peer::is_off_token`] reads — `off`, `disabled`, `0`, `false`, `no`, trimmed and +/// case-insensitive — with **one deliberate subtraction: an EMPTY value is NOT an off.** +/// +/// # Why the empty rule is subtracted rather than inherited +/// +/// For an ISOLATION knob an empty value is a coherent answer: `DIG_BOOTSTRAP_PEERS=` means "no +/// bootstrap peers", and reading it as the compiled-in default made a node believed to be isolated +/// dial production infrastructure (dig-node#312). The variable holds a LIST, so "set to nothing" +/// names the empty list. +/// +/// A capability knob holds no list. `DIG_WALLET_ENABLE_CHAIN_SYNC=` is what a shell produces from +/// `export X="$UNSET_VAR"`, and reading that as OFF would stop the replica advancing — which +/// dig-node#416 records as indistinguishable from an empty wallet at the balance surface. Inheriting +/// the empty rule wholesale would therefore import a money lie through the vocabulary, having just +/// refused to import one through the failure direction. +/// +/// So: same tokens, different empty. Stated here because "adopt the shared vocabulary" reads like one +/// decision and is two. +#[must_use] +pub fn is_capability_off_token(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() && peer::is_off_token(trimmed) +} + +/// Is `value` the operator saying "turn this capability ON"? `1`, `true`, `yes`, `on`, `enabled`, +/// trimmed and case-insensitive. +/// +/// `enabled` is present as the mirror of `disabled`: an operator who learns one word works will try +/// its opposite, and a vocabulary that accepts only one direction of a pair is the same trap in +/// reverse. +#[must_use] +pub fn is_capability_on_token(value: &str) -> bool { + let v = value.trim(); + v.eq_ignore_ascii_case("1") + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") + || v.eq_ignore_ascii_case("enabled") +} + +/// What a capability flag's raw value means, when "apply the default" and "the operator typed +/// something we do not understand" must be told apart. +/// +/// The third variant is the point. Every one of these knobs previously collapsed `Unrecognised` into +/// the default and said nothing, so a typo and a deliberate omission produced identical behaviour and +/// identical silence — see [`describe_unrecognised_flag`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlagWord { + /// An off-token: the operator asked for this capability to stop. + Off, + /// An on-token: the operator asked for this capability to run. + On, + /// Unset, or set to an empty value — the operator expressed no preference. + Absent, + /// Set to something that is neither. The caller applies its default AND says so. + Unrecognised, +} + +/// Classify a capability flag's raw value. PURE — no process env, no logging — so a caller's policy +/// and its disclosure are testable separately from its wiring. +#[must_use] +pub fn classify_flag(raw: Option<&str>) -> FlagWord { + match raw { + None => FlagWord::Absent, + Some(v) if v.trim().is_empty() => FlagWord::Absent, + Some(v) if is_capability_off_token(v) => FlagWord::Off, + Some(v) if is_capability_on_token(v) => FlagWord::On, + Some(_) => FlagWord::Unrecognised, + } +} + +/// The line an operator sees when a flag's value was not understood. +/// +/// A `String` rather than a log call at the classification site, for the reason +/// `live_broadcast_disclosure` gives: the property under test is the TEXT. A test asserting only +/// "a warning was emitted" passes on a message that omits the value, the variable, or the default +/// actually applied — and a warning missing any of those three sends the operator looking in the +/// wrong place, which is the failure this whole ticket is about. +#[must_use] +pub fn describe_unrecognised_flag(var: &str, raw: &str, applied_default: bool) -> String { + let applied = if applied_default { "ON" } else { "OFF" }; + format!( + "{var} is set to {raw:?}, which is not a value this node understands. The default ({applied}) \ + is being used and your setting has had NO effect. Accepted: on/1/true/yes/enabled, or \ + off/0/false/no/disabled." + ) +} + // The one place the per-range verification contract of a `dig.fetchRange` frame is built (#1577). use seams::content::range_frame; // Serve-side observability vocabulary for the peer-facing read surface (#1595). diff --git a/crates/dig-node-core/src/seams/dig_peer/holdings.rs b/crates/dig-node-core/src/seams/dig_peer/holdings.rs index 5b3eea55..a64c3cb9 100644 --- a/crates/dig-node-core/src/seams/dig_peer/holdings.rs +++ b/crates/dig-node-core/src/seams/dig_peer/holdings.rs @@ -1067,10 +1067,11 @@ pub fn now_unix_secs() -> u64 { /// behaving exactly as it does today. The switch exists to let an operator SHED inbound work under a /// flood, so a typo must never silently disable discovery instead. fn ingest_enabled(raw: Option<&str>) -> bool { - !matches!( - raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" | "no" - ) + // The SHARED off-vocabulary (dig-node#459), not a fifth private copy: `disabled` now works here + // exactly as it does on `DIG_PEER_NETWORK`. An operator shedding load under a flood reaches for + // whichever word they last saw work, and a switch that ignores it looks like a switch that does + // not work. + !crate::is_capability_off_token(raw.unwrap_or_default()) } #[cfg(test)] diff --git a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs index d64216ef..cabefae0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs +++ b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs @@ -549,10 +549,11 @@ pub fn store_melt_enabled() -> bool { /// Pure core of [`store_melt_enabled`], so the policy is unit-tested without touching process-global /// env. Default ON; only an explicit falsy value disables it. fn resolve_store_melt_enabled(value: Option<&str>) -> bool { - !matches!( - value.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("off") | Some("0") | Some("false") | Some("no") - ) + // The SHARED off-vocabulary (dig-node#459). This knob stops the node's ONLY irreversible-delete + // path, so the cost of an off-token it does not recognise is content deleted by a node whose + // operator believed they had stopped it — the widest gap of the five, and the reason the + // vocabulary is centralized rather than restated. + !crate::is_capability_off_token(value.unwrap_or_default()) } /// The production [`MeltChain`] — [`confirm_melt_via_chain`] over the live coinset view, using the diff --git a/crates/dig-node-service/src/config.rs b/crates/dig-node-service/src/config.rs index 5ba4e023..b8e6ede3 100644 --- a/crates/dig-node-service/src/config.rs +++ b/crates/dig-node-service/src/config.rs @@ -355,16 +355,48 @@ impl Config { } } -/// Parse the `DIG_NODE_DIGLOCAL` toggle. Truthy (`1`/`true`/`yes`/`on`) ⇒ enable -/// the bare-dig.local listener; falsy (`0`/`false`/`no`/`off`) ⇒ disable; **unset -/// or unrecognised ⇒ the default `true`** (auto-attempt with graceful fallback). -/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable. +/// Parse the `DIG_NODE_DIGLOCAL` toggle. On-token ⇒ enable the bare-dig.local listener; off-token +/// ⇒ disable; **unset, empty, or unrecognised ⇒ the default `true`** (auto-attempt with graceful +/// fallback) — and an UNRECOGNISED value is WARNED about rather than silently swallowed (#459). +/// +/// Reads the shared vocabulary via [`dig_node_core::classify_flag`], so `disabled` and `enabled` +/// work here exactly as they do on the three isolation knobs. +/// +/// # Why an unrecognised value keeps the default instead of failing closed (#459) +/// +/// This flag binds `127.0.0.2:80`, `127.0.0.2:443` and `[::1]:443` — LOOPBACK only. It cannot reach +/// the network and cannot change what a remote party may do, so the isolation knobs' fail-closed +/// rule buys nothing here while a typo would cost the operator a local feature they asked for. +/// +/// The residue fail-open leaves is not the direction but the SILENCE, and that is what changed: the +/// node now says the value was not understood and names the default it applied. pub fn parse_dig_local_flag(raw: Option) -> bool { - match raw.as_deref().map(str::trim).map(str::to_ascii_lowercase) { - Some(ref v) if matches!(v.as_str(), "0" | "false" | "no" | "off") => false, - Some(ref v) if matches!(v.as_str(), "1" | "true" | "yes" | "on") => true, - // Unset, blank, or anything unrecognised → the default-on behaviour. - _ => true, + resolve_capability_flag("DIG_NODE_DIGLOCAL", raw.as_deref(), true) +} + +/// Apply a default-ON capability flag's policy and, when the value was not understood, SAY SO. +/// +/// One function for both knobs because they take the same answer for the same reason, and because +/// two copies of a disclosure rule is how the vocabulary diverged in the first place. +/// +/// The classification is pure ([`dig_node_core::classify_flag`]); only the disclosure is a side +/// effect, so a test can assert the decision and the emitted line separately. +fn resolve_capability_flag(var: &str, raw: Option<&str>, default: bool) -> bool { + match dig_node_core::classify_flag(raw) { + dig_node_core::FlagWord::Off => false, + dig_node_core::FlagWord::On => true, + dig_node_core::FlagWord::Absent => default, + dig_node_core::FlagWord::Unrecognised => { + tracing::warn!( + "{}", + dig_node_core::describe_unrecognised_flag( + var, + raw.unwrap_or_default().trim(), + default + ) + ); + default + } } } @@ -410,20 +442,31 @@ pub fn live_broadcast_disclosure() -> &'static str { To disable both, unset DIG_WALLET_ENABLE_LIVE_BROADCAST." } -/// Parse the `DIG_WALLET_ENABLE_CHAIN_SYNC` toggle (§18.6, #2501). Falsy -/// (`0`/`false`/`no`/`off`) ⇒ do NOT start the background chain-sync supervisor; **anything else -/// — including unset, blank, or unrecognised — ⇒ the default `true`**. Default-ON, unlike -/// [`parse_live_broadcast_flag`]: syncing reads the chain into the node's own replica and moves -/// no money, so the money-safe reasoning does not apply. Case/whitespace-insensitive. PURE so the -/// policy is unit-testable without process env. +/// Parse the `DIG_WALLET_ENABLE_CHAIN_SYNC` toggle (§18.6, #2501). Off-token ⇒ do NOT start the +/// background chain-sync supervisor; **unset, empty, or unrecognised ⇒ the default `true`**, with an +/// unrecognised value WARNED about rather than silently swallowed (#459). Default-ON, unlike +/// [`parse_live_broadcast_flag`]: syncing reads the chain into the node's own replica and moves no +/// money, so the money-safe reasoning does not apply. +/// +/// # Why this one does NOT fail closed, although it DOES reach the network (#459) +/// +/// #459 proposed the discriminator "can the flag's behaviour reach the network?", on the model of +/// #282/#352. This flag does — it dials chia peers and reads the chain — so that test says fail +/// closed. **Applied here it would produce a defect**, and the reason is worth keeping: +/// +/// Failing closed on an unrecognised value silently stops the replica advancing. dig-node#416 +/// records that a stale replica's zero balance is INDISTINGUISHABLE from an empty wallet at the +/// balance surface — so a typo would be converted into a surface asserting a falsehood about the +/// operator's money. Failing open converts the same typo into "the flag you set had no effect", +/// which is recoverable and, now, stated out loud. +/// +/// The generalisation that survives, and which the network-reach test was a proxy for: **fail in +/// whichever direction cannot make a surface assert a falsehood.** For an isolation knob that is +/// closed, because fail-open leaves a node dialling a network its operator asked it to leave. For a +/// default-ON read path it is open, because fail-closed manufactures a false zero. Same principle, +/// opposite outcome — which is why the proxy must not be applied mechanically. pub fn parse_chain_sync_flag(raw: Option) -> bool { - !matches!( - raw.as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("0" | "false" | "no" | "off") - ) + resolve_capability_flag("DIG_WALLET_ENABLE_CHAIN_SYNC", raw.as_deref(), true) } /// Parse the `DIG_NODE_HOST` override (#288): `Some(ip)` when the raw value is a @@ -1233,6 +1276,190 @@ mod tests { assert!(parse_dig_local_flag(Some("maybe".to_string()))); } + // ---- #459: failure direction and off-vocabulary, decided separately ----------------------- + // + // Every assertion below names the alternative it rules out. The ticket warns that the existing + // `peer_network_enabled` test passed under BOTH the old and new parsers because it listed only + // lowercase exact tokens and never a value the two disagree about — so a test here that only + // re-listed `on`/`off` would prove nothing about either decision. + + /// An in-memory sink for the disclosure assertions. + #[derive(Clone, Default)] + struct FlagLogCapture(std::sync::Arc>>); + + impl std::io::Write for FlagLogCapture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for FlagLogCapture { + type Writer = FlagLogCapture; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + /// Run `body` under a scoped capturing subscriber and return what it logged. + fn capture_flag_logs(body: impl FnOnce() -> T) -> (T, String) { + let buffer = FlagLogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(buffer.clone()) + .finish(); + let outcome = tracing::subscriber::with_default(subscriber, body); + let captured = buffer.0.lock().unwrap().clone(); + (outcome, String::from_utf8_lossy(&captured).into_owned()) + } + + /// **DECISION 1 (#459) — an unrecognised value keeps the default AND is said out loud.** + /// + /// This is the assertion that distinguishes the chosen answer from BOTH alternatives, which is + /// why the value and the log are asserted together rather than in two tests: + /// + /// | alternative | how it fails here | + /// |---|---| + /// | fail CLOSED on unrecognised | returns `false`; the value assertion fails | + /// | fail OPEN **silently** (shipped behaviour) | returns `true` but logs nothing; the disclosure assertion fails | + /// + /// `"fasle"` is a real typo of `false` — the case where the two candidate failure directions + /// give opposite answers — not a token neither implementation would accept. + #[test] + fn an_unrecognised_capability_flag_keeps_its_default_and_says_so() { + for (var, parse) in [ + ( + "DIG_WALLET_ENABLE_CHAIN_SYNC", + parse_chain_sync_flag as fn(Option) -> bool, + ), + ( + "DIG_NODE_DIGLOCAL", + parse_dig_local_flag as fn(Option) -> bool, + ), + ] { + let (enabled, logs) = capture_flag_logs(|| parse(Some("fasle".to_string()))); + + assert!( + enabled, + "{var}: a typo must not silently disable a default-ON capability — failing closed \ + here converts a typo into a stale replica, which dig-node#416 records as \ + indistinguishable from an empty wallet" + ); + assert!( + logs.contains(var), + "{var}: the disclosure must name the VARIABLE, or the operator cannot find it; \ + log was:\n{logs}" + ); + assert!( + logs.contains("fasle"), + "{var}: the disclosure must echo the REJECTED VALUE, or the operator cannot see \ + their typo; log was:\n{logs}" + ); + assert!( + logs.contains("NO effect"), + "{var}: the disclosure must say the setting did nothing — a line that merely \ + mentions the flag reads as confirmation that it was applied; log was:\n{logs}" + ); + } + } + + /// **The control for the test above.** A RECOGNISED value must produce no disclosure at all. + /// + /// Without this, warning unconditionally would satisfy every assertion above while burying the + /// real one in noise on every start-up — the standard way a disclosure stops being read. + #[test] + fn a_recognised_capability_flag_says_nothing() { + for raw in ["off", "on", "DISABLED", " enabled ", ""] { + let (_, logs) = capture_flag_logs(|| parse_chain_sync_flag(Some(raw.to_string()))); + assert!( + logs.is_empty(), + "{raw:?} is understood, so it must not warn; log was:\n{logs}" + ); + } + let (_, logs) = capture_flag_logs(|| parse_chain_sync_flag(None)); + assert!( + logs.is_empty(), + "an unset flag is not a mistake; log was:\n{logs}" + ); + } + + /// **DECISION 2 (#459) — the two flags adopt the shared off-TOKENS.** + /// + /// `disabled` and `enabled` are the whole content of this test: they are exactly the values the + /// old and new parsers disagree about. Under the shipped parsers `disabled` fell through to the + /// default and left the capability RUNNING — the same shape as `DIG_PEER_NETWORK=OFF` leaving + /// the peer network running, which is the defect #282/#352 exists to close. + /// + /// The lowercase exact tokens are re-listed only as a regression floor; on their own they would + /// pass under both implementations, which the ticket names as the trap. + #[test] + fn the_capability_flags_read_the_shared_off_vocabulary() { + for off in [ + "disabled", + "DISABLED", + " Disabled ", + "off", + "OFF", + "0", + "false", + "no", + ] { + assert!( + !parse_chain_sync_flag(Some(off.to_string())), + "{off:?} must stop chain sync" + ); + assert!( + !parse_dig_local_flag(Some(off.to_string())), + "{off:?} must disable dig.local" + ); + } + for on in ["enabled", "ENABLED", "on", "1", "true", "yes"] { + assert!(parse_chain_sync_flag(Some(on.to_string()))); + assert!(parse_dig_local_flag(Some(on.to_string()))); + } + } + + /// **DECISION 2, the SUBTRACTION — an empty value is NOT an off for a capability knob.** + /// + /// `peer::is_off_token` treats an explicitly-empty value as OFF, and that is correct for the + /// three isolation knobs: `DIG_BOOTSTRAP_PEERS=` names the empty LIST (dig-node#312). Adopting + /// the vocabulary wholesale would import that rule here, where the variable holds no list and an + /// empty value is what `export X="$UNSET_VAR"` produces — stopping chain sync, and arriving at + /// dig-node#416's false zero through the vocabulary having just been refused through the failure + /// direction. + /// + /// **Catches:** a future "simplification" that routes these knobs straight at `is_off_token`. + /// That change passes every other test in this file. + #[test] + fn an_empty_capability_flag_is_absent_not_off() { + assert!( + parse_chain_sync_flag(Some(String::new())), + "an empty value must not stop chain sync" + ); + assert!( + parse_chain_sync_flag(Some(" ".to_string())), + "whitespace is empty, and must not stop chain sync either" + ); + assert!(parse_dig_local_flag(Some(String::new()))); + + // Asserted at the shared helper too, because that is where the subtraction lives: the two + // vocabularies agree on every TOKEN and differ only here, deliberately. `is_off_token` + // (crate-private to dig-node-core) still answers `true` for an empty value, which is what + // keeps dig-node#312 intact for the isolation knobs. + assert!( + !dig_node_core::is_capability_off_token(""), + "the capability vocabulary must not inherit the isolation knobs' empty-is-off rule" + ); + assert!( + dig_node_core::is_capability_off_token("disabled"), + "it must still inherit every off TOKEN" + ); + } + #[test] fn parse_live_broadcast_flag_is_off_by_default_and_only_truthy_enables() { // Truthy enables real mainnet broadcast. From d4fbbf441027f55d068225e1ebca8ae61a6a93d8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 15:15:06 -0700 Subject: [PATCH 3/3] docs(spec): state the capability-flag vocabulary, and fix the sixth knob it named Writing the SPEC clause for #459 found a FIFTH capability flag the ticket did not list: DIG_NODE_PROFILE_SYNC carried its own private off-vocabulary. The clause asserts that every capability flag reads one vocabulary, so leaving it would have made the clause false in the commit that introduced it -- the four knobs #459 names were the four someone had listed, not the four that exist. Also removes two section references (4.1b, 4.1c) that this SPEC does not contain; they were invented while drafting and cite nothing. --- SPEC.md | 38 +++++++++++++++++-- .../src/seams/dig_peer/profile_sync.rs | 18 ++++----- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/SPEC.md b/SPEC.md index 75ac82ce..7420040e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -174,8 +174,8 @@ ARE `DIG_NODE_*`, full stop. | `DIG_NODE_ALLOW_REMOTE` | permit a non-loopback `DIG_NODE_HOST` bind | `false` | Truthy = `1`/`true`/`yes`/`on`; anything else (unset/blank/falsy/unrecognized) ⇒ the security-safe default **false**. When false, a non-loopback `DIG_NODE_HOST` is a fatal configuration error at startup (§3.2.1). Loopback overrides and the no-override default never require it. | | `DIG_RPC_UPSTREAM` | upstream DIG RPC base URL for passthrough + miss-proxy | *(unset — NO default upstream)* | Normalized (§3.3); highest precedence (§3.4). Unset ⇒ passthrough is OFF and an unimplemented method answers a local `-32601` (§5.4). A value naming THIS node is REFUSED (§3.4.1). | | `DIG_NODE_CACHE` | explicit on-disk `.dig` cache dir | *(unset)* | Blank/whitespace ⇒ unset. Unset ⇒ shared canonical default (§3.5). | -| `DIG_NODE_DIGLOCAL` | toggle for the bare `dig.local` listeners (`http://dig.local` on `127.0.0.2:80` AND, when a dig-cert leaf is present, `https://dig.local` on `127.0.0.2:443` — §4.1a) | `true` | Falsy = `0`/`false`/`no`/`off`; truthy = `1`/`true`/`yes`/`on`; case/whitespace-insensitive; unset or unrecognized ⇒ **default true**. | -| `DIG_NODE_PROFILE_SYNC` | operator kill switch for profile-body sync (opcodes 223/224/225, §22) | `true` | Falsy = `0`/`false`/`no`/`off`, case- and whitespace-insensitive; unset or unrecognized ⇒ **default true**. Off means the node neither fetches nor serves profile bodies; nothing else depends on it, so it is a clean degradation. | +| `DIG_NODE_DIGLOCAL` | toggle for the bare `dig.local` listeners (`http://dig.local` on `127.0.0.2:80` AND, when a dig-cert leaf is present, `https://dig.local` on `127.0.0.2:443` — §4.1a) | `true` | Off = `off`/`disabled`/`0`/`false`/`no`; on = `on`/`enabled`/`1`/`true`/`yes`; case/whitespace-insensitive. Unset or EMPTY ⇒ **default true**. An UNRECOGNIZED value ⇒ default true AND a warning naming the variable, the rejected value and the applied default (see **Capability-flag vocabulary and failure direction**). | +| `DIG_NODE_PROFILE_SYNC` | operator kill switch for profile-body sync (opcodes 223/224/225, §22) | `true` | Off = `off`/`disabled`/`0`/`false`/`no`, case- and whitespace-insensitive; unset, empty or unrecognized ⇒ **default true**. Off means the node neither fetches nor serves profile bodies; nothing else depends on it, so it is a clean degradation. | The default port is the UNCOMMON high port **`9778`** (not `80`/`8080`). Port 80 requires elevation on most OSes, and both `80` and `8080` are the collision-prone common-dev ports most likely already @@ -456,6 +456,38 @@ governs the `/health` `addr` field, the `status` output, the control-client's JS alias) the node MUST log a structured warning to stderr and continue serving localhost-only — it MUST NOT abort. Skipped entirely when `DIG_NODE_DIGLOCAL` is falsy. +### Capability-flag vocabulary and failure direction + +NORMATIVE. Complements **The shared off-token** above, which governs the isolation knobs; this governs the capability knobs. + +A **capability flag** is a `DIG_*` environment switch that enables or disables a node capability and +holds no list. `DIG_WALLET_ENABLE_CHAIN_SYNC`, `DIG_NODE_DIGLOCAL`, `DIG_HOLDINGS_INGEST`, +`DIG_NODE_STORE_MELT` and `DIG_NODE_PROFILE_SYNC` are capability flags. `DIG_BOOTSTRAP_PEERS`, `DIG_RELAY_URL` and +`DIG_PEER_NETWORK` are **isolation** flags and are governed by **The shared off-token** above instead. + +1. **One vocabulary.** Every capability flag MUST read the same off-tokens — `off`, `disabled`, `0`, + `false`, `no` — and the same on-tokens — `on`, `enabled`, `1`, `true`, `yes` — each trimmed and + compared case-insensitively. A flag that recognizes a token another flag rejects is non-conforming: + an operator who learns a word works on one switch will use it on the next. + +2. **An EMPTY value is ABSENT, not OFF.** A capability flag set to the empty string MUST take its + default. This differs deliberately from an isolation flag, where an empty value names the empty list + and MUST disable (see **The shared off-token**). A capability flag holds no list, and an empty value is what a shell + produces from an unset expansion. + +3. **Failure direction: fail in whichever direction cannot make a surface assert a falsehood.** An + unrecognized value MUST NOT be resolved in a direction that lets any surface state something untrue. + For a default-ON read path such as chain sync this means keeping the default: disabling it silently + stops the replica advancing, and a stale replica's zero balance is indistinguishable from an empty + wallet (§18.6). For an isolation flag the same principle requires the opposite resolution, because + a node that keeps dialling reports an isolation it does not have. + +4. **An unrecognized value MUST be disclosed.** The node MUST emit a warning naming the VARIABLE, the + REJECTED VALUE, and the DEFAULT it applied, and MUST state that the operator's setting had no + effect. A recognized value, including an absent or empty one, MUST NOT warn. Silence is what makes a + typo indistinguishable from a deliberate omission, and it is the residue that survives whichever + failure direction a flag takes. + The distinct loopback IP `.2` exists so the port-80 bind can never collide with an unrelated `localhost:80` service. The dig-installer writes the hosts entry `127.0.0.2 dig.local`; this listener is what makes the portless `http://dig.local` URL reach the node. No listener may @@ -4188,7 +4220,7 @@ Two cheaper signals MUST NOT be used, both having shipped and been found unsound a delete decision. **Operator kill switch.** Store-melt propagation MUST be disableable at runtime via -`DIG_NODE_STORE_MELT` (default ON; only an explicit `off`/`0`/`false`/`no` disables it), matching the +`DIG_NODE_STORE_MELT` (default ON; only an explicit off-token — `off`/`disabled`/`0`/`false`/`no` — disables it), matching the shape of `DIG_NODE_BACKFILL_ON_MISS`. This is the node's only path that irreversibly deletes content in response to chain state, and it propagates, so a fault is correlated across holders rather than isolated; an operator MUST be able to stop the deleting without downgrading the node. Disabling is diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index 54080cdd..800ac6c2 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -95,16 +95,16 @@ use crate::AnchoredRootResolver; /// having run — profiles simply stop syncing — so it is a clean degradation, not an outage. pub const PROFILE_SYNC_ENV: &str = "DIG_NODE_PROFILE_SYNC"; -/// Whether profile sync is enabled. Default ON; `0`/`false`/`off`/`no` (case-insensitive) disable it. +/// Whether profile sync is enabled. Default ON; an off-token (`off`/`disabled`/`0`/`false`/`no`, +/// trimmed and case-insensitive) disables it. +/// +/// Reads the SHARED capability vocabulary (dig-node#459). It was found while writing that ticket's +/// `SPEC.md` clause, which asserts that every capability flag reads one vocabulary — a sixth private +/// copy would have made the clause false in the commit that introduced it. The four knobs #459 names +/// were the four someone had listed, not the four that exist. #[must_use] pub fn profile_sync_enabled() -> bool { - match std::env::var(PROFILE_SYNC_ENV) { - Ok(v) => !matches!( - v.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" | "no" - ), - Err(_) => true, - } + !crate::is_capability_off_token(std::env::var(PROFILE_SYNC_ENV).unwrap_or_default().as_str()) } /// How long a recorded solicitation stays answerable. @@ -2043,7 +2043,7 @@ mod tests { let _guard = env_lock(); std::env::remove_var(PROFILE_SYNC_ENV); assert!(profile_sync_enabled(), "absent must mean ON"); - for off in ["0", "false", "OFF", "no"] { + for off in ["0", "false", "OFF", "no", "disabled", " Disabled "] { std::env::set_var(PROFILE_SYNC_ENV, off); assert!(!profile_sync_enabled(), "{off} must disable profile sync"); }