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.189.0"
version = "0.190.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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,15 @@ CORS reflects `chrome-extension://` and the local page origins `http://localhost
/ `http://127.0.0.1` / `http://127.0.0.2` / `http://[::1]` (with or without a `:port`), so the
extension and any page served from a canonical local name can call it.

### `dign network-info` — this node's own posture, from the terminal

`dign network-info` prints the node's peer id, network id, effective L2 genesis, listen address,
reachability, and the addresses it advertises, in the node's own IPv6-first order. It reads the
open `dig.getNetworkInfo` surface, so it needs **no control token and no elevation** — on a `.deb`
install the control token is `0600 root:root`, and asking "what is my node's address" should not
require `sudo` for a read the node already performs for any peer that dials it. A field the node
did not report prints as `unknown` rather than as a plausible default.

## Machine-readable contracts (agent-friendly)

### CLI `--json`
Expand Down Expand Up @@ -332,6 +341,7 @@ typed `ExitCode` enum in `src/cli.rs`:
| 4 | `SERVICE_FAILED` | A service operation failed (register/start/stop/uninstall). |
| 5 | `BIND_FAILED` | `run`: could not bind the loopback address. |
| 6 | `IO_ERROR` | Other I/O error. |
| 12 | `NODE_UNREACHABLE` | The node did not answer; the operation was not measured. |

### JSON-RPC error-code catalogue

Expand Down
58 changes: 55 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2401,12 +2401,29 @@ flood of never-attached `begin`s cannot grow engine state without bound.
`install` · `uninstall` · `start` · `stop` (each accepting `--scope <auto|system|user>`, §9.1) ·
`status` · `pair` (§7.11) · `open` (§8.5) ·
the **control-parity** subcommands `info` · `config` · `cache` · `stores` · `sync` · `updater` ·
`subscriptions` (§8.6) · `peers` (§8.7) · `logs` (§11).
`subscriptions` (§8.6) · `peers` (§8.7) · `network-info` (§8.8) · `logs` (§11).

The `dign` alias binary (§2.1a) exposes this SAME subcommand set with the SAME semantics — `dign
<subcommand>` is equivalent to `dig-node <subcommand>` in every respect except the reported program
name.

### 8.8. `network-info` — this node's own network posture (#303)

`network-info` prints this node's `peer_id`, network id, effective L2 genesis, listen address,
reachability, and its advertised candidate addresses in the node's own advertisement order, which
is IPv6-first (§5.2). The order MUST be passed through untouched: re-sorting would hide a node
whose IPv6 advertisement is missing, which is the fault an operator runs the command to find. An
absent field MUST render as `unknown` and MUST NOT be filled with a plausible default — a
fabricated `direct` or an invented `0.0.0.0` reads exactly like a measurement.

**It reads the OPEN surface and is NOT token-gated, deliberately.** It is the one documented
exception to §8.6's rule that a CLI subcommand presents the master control token: it calls
`dig.getNetworkInfo`, whose body this node already hands any peer that dials it, so a loopback
caller learns nothing a stranger does not. Gating it would buy no confidentiality while costing
real availability — on a `.deb` install the control token is `0600 root:root` (§7.11/#501), so an
ordinary user asking "what is my node's address" would be told to elevate for a read the network
performs for free. This is a property to PRESERVE, not an oversight to tighten later.

### 8.6. Control-parity subcommands (#426)

For EVERY gated `control.*` method the DIG Chrome extension drives (§7), the CLI exposes an
Expand All @@ -2415,7 +2432,9 @@ extension drives it from a browser. Each subcommand is a THIN dispatch — it ca
`control.*` method over the node's loopback endpoint, presenting the MASTER control token
(`X-Dig-Control-Token`, read WITHOUT minting — §7.11/#501); no CLI logic is forked from the control
plane. A mutating CLI control is therefore gated by the identical capability as the WS surface (the
on-disk master token = local-machine control), never an unauthenticated backdoor.
on-disk master token = local-machine control), never an unauthenticated backdoor. The one
documented exception is `network-info` (§8.8), which reads an OPEN, already-public surface and is
token-free by design; it is not a control-parity subcommand and this rule does not reach it.

- `info` → `control.status` — the rich node status (version, uptime, cache, hosted-store +
cached-capsule counts, §21 sync availability). DISTINCT from `status` (§8.3), which is an
Expand Down Expand Up @@ -2620,10 +2639,43 @@ exit `1` (`NOT_SERVING`) so scripts can gate on liveness; the JSON result carrie
| 4 | `SERVICE_FAILED` | A service-manager operation failed. |
| 5 | `BIND_FAILED` | `run`: could not bind the loopback address. |
| 6 | `IO_ERROR` | Other I/O error. |
| 12 | `NODE_UNREACHABLE` | The node did not answer; the operation was not measured. |

I/O-error mapping, in the order `ExitCode::from_io_error` matches — every arm, because a partial
list reads as complete and the omitted arms are exactly the ones a caller gets wrong:
`PermissionDenied` → 3; `AddrInUse`/`AddrNotAvailable` → 5; `InvalidInput` → 2 (a bad argument
surfaced as an I/O error is still a usage error); `ConnectionRefused` → 12; anything else → 6.

I/O-error mapping: `PermissionDenied` → 3; `AddrInUse`/`AddrNotAvailable` → 5; anything else → 6.
Numeric values and symbolic names are a stable contract and MUST NOT be renumbered.

**The occupied numbers span the whole DIG command line, not this CLI alone (MUST).** `dign` and
dig-app's `diga` deliberately share one numbering so a caller sees one surface across both
(`dig-app-core/src/gateway/outcome.rs`), so a code is available only if it is unoccupied
ECOSYSTEM-WIDE. Absence from the table above does NOT make a number free — this repo has already
paid for that reasoning once in the JSON-RPC error space, where `-32015` was taken as "the next
free code" from the owning crate's own list and collided with a released `METADATA_TOO_LARGE`,
forcing a yank. The full occupied set, measured, is:

| Code | `dign` (this CLI) | `diga` (dig-app gateway) |
|---|---|---|
| 0 | `OK` | `OK` |
| 1 | `NOT_SERVING` | — |
| 2 | `USAGE` | `USAGE` |
| 3 | `PERMISSION_DENIED` | — |
| 4 | `SERVICE_FAILED` | — |
| 5 | `BIND_FAILED` | — |
| 6 | `IO_ERROR` | `IO_ERROR` |
| 7 | — | `NOT_CONNECTED` |
| 8 | — | `ENGINE_ERROR` |
| 9 | — | `LOCKED` |
| 10 | — | `NOT_FOUND` |
| 11 | — | `DENIED` |
| 12 | `NODE_UNREACHABLE` | — |

0–11 were therefore taken before this CLI added a code, and 12 is the first free number. A new
code MUST be drawn from 13 upward and MUST re-check BOTH tables first; 126, 127 and 128+n are
reserved by the shell and MUST NOT be used.

---

## 9. OS-service contract
Expand Down
2 changes: 1 addition & 1 deletion USER_JOURNEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ every subcommand (machine output to stdout, prose to stderr).
- **`--json`** on the CLI: success → `{ ok:true, action, service, version, …result }`; failure →
`{ ok:false, error:{ code, exit_code, message, hint } }`.
- **Exit-code table** (documented in the README + `src/cli.rs`): `0 OK`, `1 NOT_SERVING`,
`2 USAGE`, `3 PERMISSION_DENIED`, `4 SERVICE_FAILED`, `5 BIND_FAILED`, `6 IO_ERROR`.
`2 USAGE`, `3 PERMISSION_DENIED`, `4 SERVICE_FAILED`, `5 BIND_FAILED`, `6 IO_ERROR`, `12 NODE_UNREACHABLE`.
- **Stable JSON-RPC error codes** (UPPER_SNAKE in `error.data.code`): `PARSE_ERROR` (-32700),
`INVALID_REQUEST` (-32600), `METHOD_NOT_FOUND` (-32601), `INVALID_PARAMS` (-32602),
`DISPATCH_FAILED` (-32000, shell), `UPSTREAM_ERROR` (-32010, shell), and the control-plane codes
Expand Down
98 changes: 98 additions & 0 deletions crates/dig-node-service/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
//! | 4 | SERVICE_FAILED | A service operation failed (register/start/stop).|
//! | 5 | BIND_FAILED | `run`: could not bind the loopback address. |
//! | 6 | IO_ERROR | Other I/O error. |
//! | 12 | NODE_UNREACHABLE | The node did not answer; nothing was measured. |

use serde_json::{json, Value};

Expand All @@ -43,6 +44,14 @@ pub enum ExitCode {
BindFailed,
/// 6 — any other I/O error.
IoError,
/// 12 — the node did not answer, so the operation was never measured (dig-node#407).
///
/// Deliberately NOT `IO_ERROR`: that code says an I/O operation was attempted and failed,
/// which is a claim about the requested operation. An unreachable node is a failure to
/// MEASURE, not a measured failure, and the two must not share an encoding -- the restart
/// window an update opens is exactly when the difference matters, and a caller that cannot
/// tell them apart learns to ignore both.
NodeUnreachable,
Comment thread
MichaelTaylor3d marked this conversation as resolved.
}

impl ExitCode {
Expand All @@ -56,6 +65,7 @@ impl ExitCode {
ExitCode::ServiceFailed => 4,
ExitCode::BindFailed => 5,
ExitCode::IoError => 6,
ExitCode::NodeUnreachable => 12,
}
}

Expand All @@ -69,6 +79,7 @@ impl ExitCode {
ExitCode::ServiceFailed => "SERVICE_FAILED",
ExitCode::BindFailed => "BIND_FAILED",
ExitCode::IoError => "IO_ERROR",
ExitCode::NodeUnreachable => "NODE_UNREACHABLE",
}
}

Expand All @@ -86,6 +97,9 @@ impl ExitCode {
}
ExitCode::BindFailed => "run: could not bind the loopback address.",
ExitCode::IoError => "Other I/O error.",
ExitCode::NodeUnreachable => {
"The node did not answer; the operation was not measured (it may be restarting)."
}
}
}

Expand All @@ -99,6 +113,9 @@ impl ExitCode {
// A bad argument surfaced as `InvalidInput` (e.g. `dig-node open` rejecting a
// non-DIG/malformed link) is a USAGE error, not a generic I/O failure.
InvalidInput => ExitCode::Usage,
// The node did not answer. Nothing was measured about the requested operation, so
// this must not be reported as an I/O failure OF that operation (#407).
ConnectionRefused => ExitCode::NodeUnreachable,
_ => ExitCode::IoError,
}
}
Expand All @@ -113,6 +130,7 @@ impl ExitCode {
ExitCode::ServiceFailed,
ExitCode::BindFailed,
ExitCode::IoError,
ExitCode::NodeUnreachable,
]
}
}
Expand Down Expand Up @@ -179,6 +197,86 @@ pub fn error_envelope(action: &str, exit: ExitCode, message: &str, hint: Option<
mod tests {
use super::*;

/// #407 -- an unreachable node must not be reported as an I/O failure OF the request.
///
/// The two arms are asserted TOGETHER because the property is a distinction, not an outcome:
/// a "fix" that renamed code 6, or that mapped every failure to the new code, satisfies
/// either arm alone. `Other` is the genuine-I/O-failure control and must stay `IO_ERROR`.
#[test]
fn an_unreachable_node_is_distinguished_from_a_measured_io_failure() {
let unreachable =
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "node not answering");
let measured = std::io::Error::other("the disk gave up mid-write");

assert_eq!(
ExitCode::from_io_error(&unreachable),
ExitCode::NodeUnreachable
);
assert_eq!(ExitCode::from_io_error(&measured), ExitCode::IoError);
assert_ne!(
ExitCode::from_io_error(&unreachable).code(),
ExitCode::from_io_error(&measured).code(),
"a failure to measure and a measured failure must not share an exit code"
);
}

/// **No `dign` code may collide with a `diga` code that means something else.**
///
/// The two command lines deliberately share ONE numbering so a caller sees one surface
/// (`dig-app-core/src/gateway/outcome.rs` says so in its own doc comment), which means a
/// number is free only if it is unoccupied ECOSYSTEM-WIDE. `NODE_UNREACHABLE` was first
/// assigned 7 by reading this file's own table, where 7 genuinely was the next number --
/// and 7 is `NOT_CONNECTED` on the other side. The identical reasoning already cost a yank
/// in the JSON-RPC error space (`-32015` vs a released `METADATA_TOO_LARGE`), so the guard
/// is a test rather than a note.
///
/// The `diga` map is transcribed rather than imported: dig-node MUST NOT take a dependency
/// on dig-app (it is the engine, not a consumer of its own client). That makes this fixture
/// the drift risk, so it names the file it was read from and the SPEC carries the same table.
#[test]
fn no_exit_code_collides_with_the_dig_app_gateway_numbering() {
// Read from modules/apps/dig-app/crates/dig-app-core/src/gateway/outcome.rs.
const DIGA: &[(u8, &str)] = &[
(0, "OK"),
(2, "USAGE"),
(6, "IO_ERROR"),
(7, "NOT_CONNECTED"),
(8, "ENGINE_ERROR"),
(9, "LOCKED"),
(10, "NOT_FOUND"),
(11, "DENIED"),
];

for code in ExitCode::all() {
if let Some((_, diga_name)) = DIGA.iter().find(|(n, _)| *n == code.code()) {
assert_eq!(
code.name(),
*diga_name,
"exit {} is `{}` here and `{}` in the dig-app gateway -- a shared number must carry the SAME meaning on both command lines, or a caller branching on it is reading two different failures as one",
code.code(),
code.name(),
diga_name
);
}
}
}

/// The catalogue is the machine-readable contract (§6.2), and a code missing from it is
/// invisible to every consumer that enumerates rather than guesses.
#[test]
fn the_new_code_is_in_the_catalogue_with_a_stable_name_and_number() {
assert!(ExitCode::all().contains(&ExitCode::NodeUnreachable));
assert_eq!(ExitCode::NodeUnreachable.code(), 12);
assert_eq!(ExitCode::NodeUnreachable.name(), "NODE_UNREACHABLE");
// Every code's number is distinct -- an added arm that reused 6 would read as success
// against the two assertions above if either were relaxed.
let mut codes: Vec<u8> = ExitCode::all().iter().map(|c| c.code()).collect();
codes.sort_unstable();
let before = codes.len();
codes.dedup();
assert_eq!(codes.len(), before, "two exit codes share a number");
}

#[test]
fn exit_codes_are_unique_and_upper_snake() {
let mut codes = std::collections::HashSet::new();
Expand Down
Loading
Loading