Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.208.0"
version = "0.209.0"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
38 changes: 35 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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).
Expand Down
9 changes: 5 additions & 4 deletions crates/dig-node-core/src/seams/dig_peer/holdings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
18 changes: 9 additions & 9 deletions crates/dig-node-core/src/seams/dig_peer/profile_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}
Expand Down
9 changes: 5 additions & 4 deletions crates/dig-node-core/src/seams/dig_peer/store_melted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading