From 38da9a5ad1bb36ab1c92aab96346a91dd8f595da Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 30 Jul 2026 22:20:30 -0700 Subject: [PATCH 1/2] feat(content): report peer-tier attachment on every serve and on /health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A content read routed during the ~30 s cold-start window, before the P2P content engine attaches, skips Tier 2 entirely and is answered by the public gateway. The read succeeding is correct — refusing content for half a minute is worse — but nothing on the response said the peer tier had never been consulted, so a fresh node (or a test that started promptly) measured the gateway and recorded the result as P2P. The defect is the silence, not the routing. X-Dig-Source cannot carry this: a gateway serve because no peer HELD the resource and a gateway serve because there was no peer tier YET are both 'rpc'. Add a distinct signal that says only what it means: * X-Dig-Peer-Tier: attached | unattached on every served response, captured once at request entry so it describes the routing decision the read faced, applied uniformly across whichever tier served the bytes, and never derived from X-Dig-Source. * GET /health -> peer_tier.attached, so a caller waits for the peer tier deterministically instead of sleeping a guessed interval. The name reports engine attachment and nothing else — not peer count, not reachability, not verification. It deliberately does not repeat the overload that makes X-Dig-Verified ambiguous (#1738). SPEC.md states both surfaces normatively, plus the harness contract in the integration-test launch surface (SS7.8). Bump 0.67.0 -> 0.68.0: additive header and additive /health field, nothing removed or changed. Closes #1763 Co-Authored-By: Claude --- Cargo.lock | 4 +- Cargo.toml | 2 +- SPEC.md | 37 +++++++- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-core/src/download.rs | 15 ++++ crates/dig-node-core/src/lib.rs | 88 ++++++++++++++++++- .../src/seams/content/content_serve.rs | 68 ++++++++++++-- crates/dig-node-service/src/server.rs | 73 +++++++++++---- .../dig-node-service/tests/content_serve.rs | 85 +++++++++++++++++- 9 files changed, 343 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51e45f8..d4a65cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2211,7 +2211,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-trait", "axum", @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.67.0" +version = "0.68.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2367846..b74e565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,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.67.0" +version = "0.68.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index c4fb250..a016ee3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -455,8 +455,8 @@ Allowed methods: `GET`, `POST`, `OPTIONS`. Allowed request headers: `Content-Typ node-first path) can READ them — a cross-origin `fetch` can otherwise read only a short safelist, and a resolver that cannot see `X-Dig-Verified` fails CLOSED and drops to the verified rpc tier. The exposed set is: `X-Dig-Verified`, `X-Dig-Root`, `X-Dig-Inclusion-Proof`, `X-Dig-Chunk-Lens`, -`X-Dig-Source`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`, `X-Dig-Owner-Puzzle-Hash`, -`X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only +`X-Dig-Source`, `X-Dig-Peer-Tier`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`, +`X-Dig-Owner-Puzzle-Hash`, `X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only readability. (Cross-repo contract with dig-urn-resolver — mirrored in `SYSTEM.md`.) **Private Network Access (PNA, #285).** The server MUST advertise `allow_private_network` on the @@ -598,6 +598,23 @@ verified server-side — `false` only when the node-side pin is disabled via `DI `X-Dig-Root: ` (the resolved root served against), and `X-Dig-Source: local|peer|rpc` (the tier that served the MAIN resource). A consumer's DIG Shields / toolbar reads these. +**Peer-tier attachment (every serve, #1763).** Every served response MUST additionally carry +`X-Dig-Peer-Tier: attached|unattached`, reporting whether the P2P content engine was attached at the +moment the read was ROUTED — i.e. whether Tier 2 was consultable at all. The node MUST capture this +once, before any tier runs, and report the same value regardless of which tier ultimately served the +bytes; it MUST NOT be derived from `X-Dig-Source`. + +The two are independent and both are required, because `X-Dig-Source` alone cannot express the +difference between a gateway serve that MISSED on the peer tier and a gateway serve that never had +one. The node serves content from the moment its HTTP surface opens, which is BEFORE the peer network +attaches (§7.8) — availability is deliberately not traded away for readiness — so reads inside that +window skip Tier 2 entirely and are answered by the public RPC. `unattached` is the node stating that; +a caller MUST NOT treat such a read as evidence about peer replication. `unattached` is also the +permanent value on the in-process/FFI path, which brings up no peer network. + +`X-Dig-Peer-Tier` reports engine attachment ONLY: not peer count, not reachability, not whether a +fetch was attempted, and never verification (that is `X-Dig-Verified`). + **Serve-metadata headers (every serve, #486).** Alongside the provenance set, every served resource carries: `X-Dig-Store-Id: <64-hex>` (the storeId serving this resource); `X-Dig-Owner-Puzzle-Hash: <64-hex>` — the store's on-chain OWNER puzzle hash, resolved from the SAME chain read as the @@ -882,11 +899,18 @@ are asserted by classification only). ### 6.1. `GET /health` Returns `{ status: "ok", service, version, commit, mode: "local-node", addr, upstream, cache: -{ dir, cap_bytes, used_bytes, shared }, methods: [names…] }`. The fields `status`, `version`, +{ dir, cap_bytes, used_bytes, shared }, sync: { available }, peer_tier: { attached }, +methods: [names…] }`. The fields `status`, `version`, `mode`, `upstream`, `cache` are the stable probe contract (the v0.2 server's health shape); additions MUST be additive. `cache.shared` reports whether the effective cache dir is the shared canonical one (`true`) or a process-private fallback (`false`), from the read path's resolver. +`peer_tier.attached` (#1763) reports whether the P2P content engine is attached RIGHT NOW — the same +state a served response reports as `X-Dig-Peer-Tier` (§4.6). `status: "ok"` means the node is live and +answering; it does NOT imply a usable peer tier, since the HTTP surface opens before the peer network +attaches (§7.8). A client or harness that needs the peer tier MUST poll `peer_tier.attached` until it +is `true` rather than waiting a fixed interval. + ### 6.2. `GET /version` Returns `{ service, version, commit, protocol }` (§2; `version` is the one canonical version). @@ -1173,6 +1197,13 @@ token-gated test is read from `/control-token` after startup; a test `DIG_NODE_STATE_DIR` to an isolated temp dir (§7.3a) so its token/paired-token state is hermetic regardless of any real machine-wide state dir on the host. +**The peer tier is NOT up when `/health` first answers (#1763).** The HTTP surface opens immediately, +while the peer network attaches seconds later, so a read issued as soon as `/health` responds skips +Tier 2 and is answered by the public RPC. A harness that intends to exercise the P2P path MUST poll +`peer_tier.attached` on `/health` (§6.1) until it is `true` — a fixed sleep is neither sufficient nor +checkable — and MUST confirm `X-Dig-Peer-Tier: attached` on the response it measures (§4.6). A result +gathered from a response carrying `unattached` is a measurement of the gateway, not of peer replication. + ### 7.9. Cache-method families (open `cache.*` vs gated `control.cache.*`) The node exposes cache operations under TWO method families, BY DESIGN — a consumer picks the one diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 3f7ef15..2ab52e4 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -6,7 +6,7 @@ name = "dig-node-core" # compatibility-relevant change even though the crate is consumed in-workspace and is not published to # crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for # what the exported types are (dig_ecosystem#1785c). -version = "0.24.0" +version = "0.25.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index d228563..539e14b 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -1317,6 +1317,21 @@ impl crate::Node { self.p2p_content.get() } + /// Whether the peer (Tier-2) content path is consultable RIGHT NOW (#1763). + /// + /// The peer network attaches ~30 s after the HTTP surface starts answering, so this is + /// [`PeerTier::Unattached`](crate::content_serve::PeerTier::Unattached) for the whole + /// cold-start window — and permanently on the FFI/in-process path, which brings up no peer + /// network. It is reported per read (`X-Dig-Peer-Tier`) and on `GET /health`, which is what + /// lets a caller wait for the peer tier deterministically instead of sleeping a guessed + /// interval and hoping. + pub fn peer_tier(&self) -> crate::content_serve::PeerTier { + match self.p2p_content() { + Some(_) => crate::content_serve::PeerTier::Attached, + None => crate::content_serve::PeerTier::Unattached, + } + } + /// Decide the #165 miss outcome for `content` at redirect depth `depth`: fetch-through when /// configured (falling back to redirect if the fetch fails), else locate + redirect within the /// hop budget, else not-found. NEVER a silent 404 while a provider exists. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 72e7d39..90ee5db 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6209,6 +6209,7 @@ mod tests { root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => { @@ -6219,6 +6220,9 @@ mod tests { "the chain-anchored pin is enforced → verified=true" ); assert_eq!(source, ServeSource::Local); + // No peer network is brought up on this node, so the read skipped Tier 2 (#1763) — + // reported honestly even though the bytes came from disk and never needed a peer. + assert_eq!(peer_tier, crate::content_serve::PeerTier::Unattached); // The injected resolver (`MockResolver::one`) reports no owner (#486) — the header // must be OMITTED, never guessed. assert_eq!(owner_puzzle_hash, None); @@ -6293,6 +6297,76 @@ mod tests { assert_eq!(latest.resources.len(), 2); } + /// **Regression (#1763):** `peer_tier` reports whether the P2P content engine was ATTACHED when + /// a read was routed — a fact about the node, independent of which tier ended up serving. Before + /// this fix nothing carried it, so a read taken inside the ~30 s cold-start window looked exactly + /// like a read taken after attach and any peer-replication conclusion drawn from it was unfounded. + /// + /// **The fixture varies ONE actor.** Both arms drive the IDENTICAL locally-seeded, chain-anchored + /// capsule and both are served from disk (`ServeSource::Local`) — the only difference is whether an + /// engine is attached. That is deliberate: the nearest wrong implementation derives the value from + /// the SERVE SOURCE (`peer_tier = if source == Rpc { Unattached } else { Attached }`), which is + /// indistinguishable from the real thing on a gateway-serve fixture, and which this pair kills from + /// both directions — arm 1 is Local-and-Unattached (that impl says Attached) while arm 2 is + /// Local-and-Attached, so it cannot be satisfied by any constant either. + /// + /// The end-to-end cold-start case — a real gateway serve inside the window, reported over HTTP — + /// is `dig-node-service`'s `cold_start_gateway_serve_reports_the_peer_tier_as_unattached`. + #[test] + fn serve_reports_peer_tier_attachment_independently_of_the_serving_tier() { + use crate::content_serve::{PeerTier, PlaintextOutcome, ServeSource}; + use crate::ContentServer; + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); // enforce the chain-anchored pin (the default) + let rt = pin_test_rt(); + let store = Bytes32([23u8; 32]); + let files = vec![("index.html".to_string(), b"

local

".to_vec())]; + let (root, module) = + compile_fixture_module(store, digstore_core::Visibility::Public, true, &files); + + for attach in [false, true] { + let (node, td) = + test_node_with_resolver(None, MockResolver::one(&store.to_hex(), root)); + seed_cached_module(&node.cache_dir, &store.to_hex(), &root.to_hex(), &module); + if attach { + // An engine with NO providers: the peer tier EXISTS but holds nothing, so the read is + // still served from disk. Attachment, not peer availability, is what is under test. + let (content, _unrelated_root, _pt) = + anchored_sealed_content(Bytes32([24u8; 32]), "index.html", b"elsewhere"); + attach_p2p(&node, vec![], content, MissMode::FetchThrough, &td); + } + + let out = rt.block_on(node.serve_content_plaintext( + &store.to_hex(), + &root.to_hex(), + "index.html", + None, + crate::download::ReadOrigin::Local, + )); + match out { + PlaintextOutcome::Served { + source, peer_tier, .. + } => { + assert_eq!( + source, + ServeSource::Local, + "attach={attach}: both arms serve from disk — the serving tier is the CONTROL" + ); + assert_eq!( + peer_tier, + if attach { + PeerTier::Attached + } else { + PeerTier::Unattached + }, + "attach={attach}: peer_tier must track engine attachment, not the serve source" + ); + } + other => panic!("attach={attach}: expected a local Served, got {other:?}"), + } + } + } + /// **Proves:** the serve-metadata `X-Dig-Owner-Puzzle-Hash` source (#486) — when the chain-anchored /// pin is ENFORCED and the resolver reports the store's on-chain owner, `serve_content_plaintext` /// surfaces it on the `Served` outcome, resolved from the SAME chain read as the root pin (no second @@ -6976,9 +7050,21 @@ mod tests { )); std::env::remove_var("DIG_NODE_PIN"); match out { - PlaintextOutcome::Served { bytes, source, .. } => { + PlaintextOutcome::Served { + bytes, + source, + peer_tier, + .. + } => { assert_eq!(source, ServeSource::Peer, "must serve from the P2P holder"); assert_eq!(bytes, plaintext, "the decrypted P2P bytes match the source"); + // The post-attach half of #1763: a read that genuinely reached a peer reports the + // tier as attached, so it is distinguishable from a cold-start read that could not. + assert_eq!( + peer_tier, + crate::content_serve::PeerTier::Attached, + "a peer-served read was routed with the engine up" + ); } other => panic!("expected a peer Served (no upstream), got {other:?}"), } diff --git a/crates/dig-node-core/src/seams/content/content_serve.rs b/crates/dig-node-core/src/seams/content/content_serve.rs index adc392c..f73e3c5 100644 --- a/crates/dig-node-core/src/seams/content/content_serve.rs +++ b/crates/dig-node-core/src/seams/content/content_serve.rs @@ -59,6 +59,40 @@ impl ServeSource { } } +/// Whether the P2P content engine was attached when a read was routed — surfaced verbatim as +/// `X-Dig-Peer-Tier` (#1763). +/// +/// The engine attaches ~30 s after the HTTP surface opens, so a node answers content reads before +/// there is any peer tier to consult. Such a read is still SERVED (refusing content for half a +/// minute is worse), but it reaches the gateway having skipped Tier 2 entirely. `X-Dig-Source` +/// alone cannot express that: a gateway serve because the peer tier was DOWN and a gateway serve +/// because no peer HELD the resource are both `rpc`. This value is the difference, so a caller — +/// or an acceptance test — can tell whether the peer path was measured or merely absent. +/// +/// It reports engine ATTACHMENT and nothing else: not peer count, not reachability, not whether a +/// fetch was attempted, and emphatically not verification (see `X-Dig-Verified`, whose overloaded +/// name is #1738). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeerTier { + /// The P2P content engine was attached when this read was routed, so Tier 2 was consultable. + /// A read that nonetheless came from the gateway genuinely missed on the peer tier. + Attached, + /// No P2P content engine was attached, so this read SKIPPED Tier 2. Either the node is still + /// inside its cold-start window, or it runs on the FFI/in-process path that has no peer + /// network at all. Any peer-replication conclusion drawn from this read is unfounded. + Unattached, +} + +impl PeerTier { + /// The lowercase `X-Dig-Peer-Tier` header value. + pub fn as_str(self) -> &'static str { + match self { + PeerTier::Attached => "attached", + PeerTier::Unattached => "unattached", + } + } +} + /// The result of a local plaintext content serve. The HTTP layer (`dig-node-service`) maps each /// variant to a response: `Served` → 200 with the plaintext + `X-Dig-*` headers; `NotFound` → the /// SPA-fallback-vs-404 decision (a route serves the store's `index.html`, an asset misses honestly); @@ -74,6 +108,13 @@ pub enum PlaintextOutcome { root_hex: String, verified: bool, source: ServeSource, + /// Whether the P2P content engine was attached when this read was ROUTED — surfaced as + /// `X-Dig-Peer-Tier` (#1763). Independent of `source`: a gateway serve is `Unattached` + /// during cold start but `Attached` once the engine is up and simply missed. Captured + /// ONCE at request entry and applied uniformly across whichever tier served the bytes + /// (see [`with_serve_metadata`]), so it describes the routing decision this read faced + /// rather than a later moment. + peer_tier: PeerTier, /// The store's on-chain OWNER puzzle hash (64-hex) — the future tip recipient, surfaced /// as `X-Dig-Owner-Puzzle-Hash` (#486). `None` when the chain-anchored pin did not run /// (`DIG_NODE_PIN=off`) or the resolver could not supply it — the header is OMITTED @@ -385,6 +426,12 @@ impl ContentServer for Node { .map(|r| r.to_hex()) .unwrap_or_else(|| requested_root_hex.to_string()); let verified = enforced; + // Whether Tier 2 even EXISTS for this read (#1763), captured before any tier runs so the + // answer describes the routing decision this request faced — not whether the engine + // happened to attach while the read was in flight. A read routed with no engine skipped the + // peer tier outright, and the response says so rather than letting a gateway serve pass for + // a measured P2P result. + let peer_tier = self.peer_tier(); // The store generation this resource came from (#486) — a local-only manifest lookup, no // chain call. `None` on a rootless/pin-off request or when the generation is unknowable. let generation = if root_hex.is_empty() { @@ -415,7 +462,7 @@ impl ContentServer for Node { &root_hex, verified, ) { - return with_serve_metadata(served, owner_puzzle_hash, generation); + return with_serve_metadata(served, owner_puzzle_hash, generation, peer_tier); } // else: a decoy / verify or decrypt failure → fall through to peer/RPC. } @@ -441,7 +488,7 @@ impl ContentServer for Node { { // A peer served the resource; warm the whole capsule locally for next time (#290). self.maybe_backfill_capsule(store_hex, &root_hex, origin); - return with_serve_metadata(peer, owner_puzzle_hash, generation); + return with_serve_metadata(peer, owner_puzzle_hash, generation, peer_tier); } } @@ -478,11 +525,14 @@ impl ContentServer for Node { root_hex: root_hex.clone(), verified, source: ServeSource::Rpc, + // All three stamped just below by `with_serve_metadata`. + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, }, owner_puzzle_hash, generation, + peer_tier, ) } // The gateway returned bytes that do not verify against the anchored root — a @@ -622,7 +672,8 @@ impl Node { verified, source: ServeSource::Local, // Stamped by the caller (`serve_content_plaintext`) via `with_serve_metadata` — - // both fields are resolved ONCE per request, not per tier. + // every one of these is resolved ONCE per request, not per tier. + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, } @@ -712,6 +763,7 @@ impl Node { verified, source: ServeSource::Peer, // Stamped by the caller via `with_serve_metadata` (see `decrypt_local`). + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, }) @@ -804,22 +856,26 @@ impl Node { } } -/// Stamp the request-scoped serve-metadata (#486) onto a `Served` outcome — the owner puzzle hash -/// and generation resolved ONCE in [`Node::serve_content_plaintext`], applied uniformly across -/// whichever tier (local/peer/rpc) actually served the bytes. A no-op on any other variant. +/// Stamp the request-scoped serve-metadata onto a `Served` outcome — the owner puzzle hash and +/// generation (#486) plus the peer-tier attachment (#1763), each resolved ONCE in +/// [`Node::serve_content_plaintext`] and applied uniformly across whichever tier (local/peer/rpc) +/// actually served the bytes. A no-op on any other variant. fn with_serve_metadata( mut outcome: PlaintextOutcome, owner_puzzle_hash: Option, generation: Option, + peer_tier: PeerTier, ) -> PlaintextOutcome { if let PlaintextOutcome::Served { owner_puzzle_hash: o, generation: g, + peer_tier: p, .. } = &mut outcome { *o = owner_puzzle_hash; *g = generation; + *p = peer_tier; } outcome } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index c0a7e91..c2d5b4d 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -24,7 +24,7 @@ use axum::{ routing::{get, post}, Json, Router, }; -use dig_node_core::content_serve::{PlaintextOutcome, ServeSource}; +use dig_node_core::content_serve::{PeerTier, PlaintextOutcome, ServeSource}; use dig_node_core::{cache_cap_bytes, cache_used_bytes, handle_rpc, ContentServer, Node}; use dig_wallet::sage::events::{SyncEvent, SyncLifecycle, SyncStatus}; use dig_wallet::sage::rpc::WalletBackend; @@ -203,12 +203,13 @@ pub fn router(state: AppState) -> Router { /// attestation + the Merkle-proof/chunk-length headers on the ciphertext path — without which the /// resolver fails closed and drops to the verified rpc tier. Lowercase (header names are /// case-insensitive; `HeaderName::from_static` requires lowercase). -const EXPOSED_DIG_HEADERS: [&str; 10] = [ +const EXPOSED_DIG_HEADERS: [&str; 11] = [ "x-dig-verified", "x-dig-root", "x-dig-inclusion-proof", "x-dig-chunk-lens", "x-dig-source", + "x-dig-peer-tier", "x-dig-store-id", "x-dig-capsule", "x-dig-resource-key", @@ -488,6 +489,15 @@ fn status_fields(state: &AppState) -> serde_json::Map { // §21 whole-store sync availability (whether a §21.9 identity is loaded) — the // "sync state" a live client wants alongside version/addr (#239). m.insert("sync".into(), json!({ "available": state.sync_available })); + // Peer-tier readiness (#1763). The HTTP surface answers content reads ~30 s BEFORE the peer + // network attaches, so "the node responds" has never implied "the node can reach peers". This + // is the checkable signal for the difference: an acceptance test polls `peer_tier.attached` + // until it is true instead of sleeping a guessed interval, and a read taken before then is + // known to be a gateway measurement rather than assumed to be a P2P one. + m.insert( + "peer_tier".into(), + json!({ "attached": state.node.peer_tier() == PeerTier::Attached }), + ); m } @@ -1280,6 +1290,7 @@ async fn serve_resource( root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => served_response( @@ -1287,10 +1298,13 @@ async fn serve_resource( &sp.resource, bytes, &root_hex, - verified, - source, - owner_puzzle_hash.as_deref(), - generation, + ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash: owner_puzzle_hash.as_deref(), + generation, + }, ), PlaintextOutcome::NotFound { root_hex } => serve_miss(state, &sp, &root_hex, origin).await, PlaintextOutcome::InvalidParams { message } => { @@ -1339,6 +1353,7 @@ async fn serve_miss( root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => served_response( @@ -1346,17 +1361,39 @@ async fn serve_miss( "index.html", bytes, &root_hex, - verified, - source, - owner_puzzle_hash.as_deref(), - generation, + ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash: owner_puzzle_hash.as_deref(), + generation, + }, ), _ => not_found(), } } +/// Everything a served response reports ABOUT its bytes rather than the bytes themselves — grouped +/// so [`served_response`] takes one self-describing argument instead of a positional run of five +/// that a caller can silently transpose. +struct ServeProvenance<'a> { + /// Whether the bytes were verified against the CHAIN-ANCHORED root (`X-Dig-Verified`). + verified: bool, + /// Which tier the bytes came from (`X-Dig-Source`). + source: ServeSource, + /// Whether the peer tier was attached when the read was routed (`X-Dig-Peer-Tier`, #1763) — + /// which is what distinguishes "the gateway answered because no peer held this" from "the + /// gateway answered because there was no peer tier yet". + peer_tier: PeerTier, + /// The store's on-chain owner puzzle hash, when resolvable (`X-Dig-Owner-Puzzle-Hash`). + owner_puzzle_hash: Option<&'a str>, + /// The commit ordinal that last wrote the resource, when known (`X-Dig-Generation`). + generation: Option, +} + /// Build the `200` response for a served resource: the ecosystem content-type + `nosniff`, the -/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source` provenance headers (#292), the serve-metadata HEAD +/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source`/`X-Dig-Peer-Tier` provenance headers (#292, #1763), +/// the serve-metadata HEAD /// (#486: `X-Dig-Store-Id`/`X-Dig-Capsule`/`X-Dig-Resource-Key` always, `X-Dig-Owner-Puzzle-Hash`/ /// `X-Dig-Generation` when resolvable), and — for HTML — the injected store-root /// ``/`` plus the hardened store CSP. @@ -1367,17 +1404,20 @@ async fn serve_miss( /// /// `owner_puzzle_hash`/`generation` are OMITTED (not an empty placeholder) when unknowable — see /// [`PlaintextOutcome::Served`]'s field docs. -#[allow(clippy::too_many_arguments)] fn served_response( sp: &StorePath, resource: &str, bytes: Vec, root_hex: &str, - verified: bool, - source: ServeSource, - owner_puzzle_hash: Option<&str>, - generation: Option, + provenance: ServeProvenance<'_>, ) -> Response { + let ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash, + generation, + } = provenance; let content_type = content_type_for(resource); // The MAIN resource actually served: an empty key (a bare store-root request) resolved to the // default view `index.html` internally, so the header reports that, never a blank string. @@ -1393,6 +1433,7 @@ fn served_response( .header("X-Dig-Verified", if verified { "true" } else { "false" }) .header("X-Dig-Root", root_hex) .header("X-Dig-Source", source.as_str()) + .header("X-Dig-Peer-Tier", peer_tier.as_str()) .header("X-Dig-Store-Id", sp.store_id.as_str()) .header("X-Dig-Capsule", format!("{}:{}", sp.store_id, root_hex)) .header("X-Dig-Resource-Key", resource_key); diff --git a/crates/dig-node-service/tests/content_serve.rs b/crates/dig-node-service/tests/content_serve.rs index d0f0eb7..61d0927 100644 --- a/crates/dig-node-service/tests/content_serve.rs +++ b/crates/dig-node-service/tests/content_serve.rs @@ -100,6 +100,15 @@ async fn mock_upstream_all_miss() -> String { /// seed a module into), and the env-serialization hold. async fn start_server(upstream: &str) -> (SocketAddr, PathBuf, EnvHold) { let hold = env_guard().lock_owned().await; + let (addr, cache) = spawn_node(upstream).await; + (addr, cache, EnvHold(hold)) +} + +/// Bring up ONE service node — the body of [`start_server`] WITHOUT taking the env-serialization +/// lock, so a single test can stand up two nodes (a reader and the gateway it falls back to) inside +/// one hold. Each node captures its own cache dir at construction, so the process-global +/// `DIG_NODE_CACHE` may be repointed between calls. +async fn spawn_node(upstream: &str) -> (SocketAddr, PathBuf) { let unique = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let base = std::env::temp_dir().join(format!( "dig-node-serve-test-{}-{}", @@ -131,7 +140,7 @@ async fn start_server(upstream: &str) -> (SocketAddr, PathBuf, EnvHold) { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, cache, EnvHold(hold)) + (addr, cache) } fn store_and_files() -> (Bytes32, Vec<(String, Vec)>) { @@ -735,3 +744,77 @@ async fn fallback_serve_labels_a_rerooted_read_from_the_connection() { "a non-loopback reader must be Peer on the fallback door too, got {seen:?}" ); } + +/// **Regression (#1763):** during the ~30 s cold-start window BEFORE the peer network attaches, a +/// content read still succeeds — but it is served by the public gateway, having never consulted the +/// peer tier at all. Before this fix the response was indistinguishable from a post-attach gateway +/// serve, so a fresh node (or a test that started promptly) measured the gateway and recorded it as +/// a P2P result. The read MUST now say, in the response itself, that the peer tier was not attached +/// when it was routed. +/// +/// **The fixture is a real cold start, not a mock of one:** `build_state` is the same constructor +/// the daemon uses and it does NOT attach the P2P content engine (only `spawn_peer_network` does), +/// so the reader node below is genuinely inside the window. Its Tier-3 fallback is a SECOND real +/// dig-node serving the capsule over the real `dig.getContent` wire — the gateway's bytes, proof and +/// chunk lengths are produced by the production serve path, not hand-built. +#[tokio::test] +async fn cold_start_gateway_serve_reports_the_peer_tier_as_unattached() { + let _hold = EnvHold(env_guard().lock_owned().await); + let (store, files) = store_and_files(); + let (root, module) = compile_public_module(store, &files); + + // The GATEWAY: a node that holds the capsule and answers `dig.getContent` from its own cache. + // Its own upstream is unroutable — every byte it returns comes from the module seeded below. + let (gw_addr, gw_cache) = spawn_node("http://127.0.0.1:1/").await; + seed_module(&gw_cache, &store.to_hex(), &root.to_hex(), &module); + + // The READER: a freshly-started node with an EMPTY cache and no peer network attached — the + // cold-start window. Local misses, the peer tier is not there, so the read falls to the gateway. + let (addr, _cache) = spawn_node(&format!("http://{gw_addr}")).await; + + let url = format!( + "http://{addr}/s/{}:{}/index.html", + store.to_hex(), + root.to_hex() + ); + let resp = reqwest::Client::new().get(&url).send().await.unwrap(); + assert_eq!( + resp.status(), + 200, + "the read still succeeds — availability is not traded away" + ); + let h = resp.headers(); + assert_eq!( + h.get("x-dig-source").unwrap(), + "rpc", + "the gateway served these bytes" + ); + assert_eq!( + h.get("x-dig-peer-tier") + .map(|v| v.to_str().unwrap()) + .unwrap_or("
"), + "unattached", + "the read must declare that the peer tier was never consulted" + ); +} + +/// **Proves (#1763):** the peer-tier state is ALSO checkable out-of-band, on `GET /health`, so an +/// acceptance test can poll `peer_tier.attached` until the peer network is up instead of sleeping a +/// guessed 30 s and hoping. A node that answers `/health` with `status: ok` is live, but liveness has +/// never implied a usable peer tier — this is the field that separates the two. +#[tokio::test] +async fn health_reports_the_peer_tier_as_unattached_before_the_peer_network_starts() { + let (addr, _cache, _hold) = start_server("http://127.0.0.1:1/").await; + let body: Value = reqwest::get(format!("http://{addr}/health")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["status"], json!("ok"), "the node is live"); + assert_eq!( + body["peer_tier"]["attached"], + json!(false), + "a live node with no peer network must say so rather than leaving it unstated" + ); +} From 090b99bf8e037e3ae255f8d21070655b9a9d8e11 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 31 Jul 2026 01:43:19 -0700 Subject: [PATCH 2/2] test(content): assert the attached direction of the peer-tier signal (#1763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new X-Dig-Peer-Tier / /health peer_tier signal had no test on its `attached`/`true` direction, so mutating either production value kept the whole workspace suite green: - `PeerTier::Attached => "attached"` — the only HTTP coverage exercised the `Unattached` arm, and every dig-node-service integration test builds state via `build_state`, which never attaches an engine, so the `Attached` arm was unreachable from that suite by construction. SPEC.md 4.3/4.6 make this header's value set a cross-repo contract with dig-urn-resolver. - `/health` `peer_tier.attached: true` — SPEC.md 6.1/7.8 tell every harness to poll this until true instead of sleeping, so a field stuck at `false` turns the documented wait into an infinite one with the same observable signature as the legitimate readiness window. - `x-dig-peer-tier` in `EXPOSED_DIG_HEADERS` — dropping it silently removes a browser-side resolver's read access to it. Adds a two-arm assertion on the real response builder, a two-arm assertion on the health field, a node-level `peer_tier()` assertion in the existing core attach/no-attach pair, and the missing CORS-exposure assertion. The one production change is a behaviour-preserving extraction of the health field into `peer_tier_status`, so both directions are assertable without attaching a peer network (attachment is settable only inside dig-node-core) and without widening any production visibility. Co-Authored-By: Claude --- crates/dig-node-core/src/lib.rs | 13 ++++ crates/dig-node-service/src/server.rs | 107 ++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 90ee5db..8d9cbb4 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6336,6 +6336,19 @@ mod tests { attach_p2p(&node, vec![], content, MissMode::FetchThrough, &td); } + // The node-level accessor `/health` reads (#1763) must flip with attachment too — the + // header path and the health path share this one source of truth, so a `peer_tier()` + // pinned to either arm is caught here rather than only at whichever surface has coverage. + assert_eq!( + node.peer_tier(), + if attach { + PeerTier::Attached + } else { + PeerTier::Unattached + }, + "attach={attach}: Node::peer_tier must track engine attachment" + ); + let out = rt.block_on(node.serve_content_plaintext( &store.to_hex(), &root.to_hex(), diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index c2d5b4d..e62a6a5 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -494,13 +494,21 @@ fn status_fields(state: &AppState) -> serde_json::Map { // is the checkable signal for the difference: an acceptance test polls `peer_tier.attached` // until it is true instead of sleeping a guessed interval, and a read taken before then is // known to be a gateway measurement rather than assumed to be a P2P one. - m.insert( - "peer_tier".into(), - json!({ "attached": state.node.peer_tier() == PeerTier::Attached }), - ); + m.insert("peer_tier".into(), peer_tier_status(state.node.peer_tier())); m } +/// The `/health` `peer_tier` object for a given tier — the ONE place the wire spelling of +/// peer-tier readiness is decided, so both directions are assertable without standing up a peer +/// network (attachment itself is only settable inside `dig-node-core`). +/// +/// `attached: true` is the signal SPEC.md §6.1/§7.8 tell every harness to POLL instead of sleeping, +/// so a field that never reports `true` turns the documented wait into an infinite one with the +/// same observable signature as the legitimate cold-start window. +fn peer_tier_status(tier: PeerTier) -> Value { + json!({ "attached": tier == PeerTier::Attached }) +} + /// `GET /health` (and `GET /`) — liveness + mode + cache stats + discovery hooks. /// Shape extends the Node reference server's health body (existing probes keep parsing /// `status`/`version`/`mode`/`upstream`/`cache`) with agent-friendly additions: @@ -1885,12 +1893,92 @@ async fn shutdown_signal() { #[cfg(test)] mod tests { use super::{ - is_allowed_origin, is_app_origin, is_local_origin, read_origin_for, APP_ORIGINS_ENV, - EXPOSED_DIG_HEADERS, + is_allowed_origin, is_app_origin, is_local_origin, peer_tier_status, read_origin_for, + served_response, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, }; + use dig_node_core::content_serve::{PeerTier, ServeSource}; use dig_node_core::download::ReadOrigin; + use serde_json::json; use std::net::{Ipv4Addr, SocketAddr}; + /// **Regression (#1763):** the `X-Dig-Peer-Tier` wire value for BOTH tiers, asserted on the real + /// response builder rather than on the enum alone. + /// + /// SPEC.md §4.3/§4.6 make this header's value set a cross-repo contract with dig-urn-resolver, so + /// the literal is the contract. The only prior HTTP coverage exercised `unattached`, which the + /// `dig-node-service` integration suite cannot escape: every test builds state via `build_state`, + /// which never attaches an engine, so the `Attached` arm was unreachable there BY CONSTRUCTION and + /// spelling it `"unattached"` in core kept the whole workspace green. + /// + /// **The fixture varies ONE actor.** Both arms build the IDENTICAL served response — same store + /// path, same bytes, same `ServeSource::Local` — and differ only in the peer tier, with the + /// `unattached` arm kept as the truthful control. Asserting both spellings also kills the + /// swap/alias mutants (either arm returning the other's literal, or both returning one constant) + /// that a single-direction assertion cannot see. + #[test] + fn served_response_reports_both_peer_tier_wire_values() { + let sp = StorePath { + store_id: "ab".repeat(32), + root: None, + resource: "data.bin".to_string(), + }; + for (tier, expected) in [ + (PeerTier::Attached, "attached"), + (PeerTier::Unattached, "unattached"), + ] { + let resp = served_response( + &sp, + "data.bin", + b"payload".to_vec(), + &"cd".repeat(32), + ServeProvenance { + verified: true, + source: ServeSource::Local, + peer_tier: tier, + owner_puzzle_hash: None, + generation: None, + }, + ); + assert_eq!( + resp.headers() + .get("X-Dig-Peer-Tier") + .map(|v| v.to_str().expect("ascii header")), + Some(expected), + "{tier:?} must serialize as {expected:?} on the wire (#1763)" + ); + // The serving tier is the CONTROL: it is Local in both arms, so a mutant that derives the + // peer tier from the serve source cannot satisfy this pair. + assert_eq!( + resp.headers().get("X-Dig-Source").map(|v| v.to_str().ok()), + Some(Some("local")), + "{tier:?}: the serve source is the control and must not vary" + ); + } + } + + /// **Regression (#1763):** `/health`'s `peer_tier.attached` reports BOTH directions. + /// + /// The `true` direction is the one that matters asymmetrically: SPEC.md §6.1/§7.8 tell every + /// harness to POLL `peer_tier.attached` until it is true instead of sleeping, so a field pinned at + /// `false` turns the documented wait into an infinite one with exactly the observable signature of + /// the legitimate readiness window (`status: "ok"` + `attached: false`). Only the `false` arm had + /// coverage, because `build_state` never attaches an engine and attachment is settable only inside + /// `dig-node-core` — hence the assertion sits on the pure mapping that `status_fields` delegates to, + /// which is reachable on the CI host with no peer network and no widened production visibility. + #[test] + fn health_peer_tier_status_reports_both_directions() { + assert_eq!( + peer_tier_status(PeerTier::Attached), + json!({ "attached": true }), + "an attached engine must report attached: true — the value harnesses poll for" + ); + assert_eq!( + peer_tier_status(PeerTier::Unattached), + json!({ "attached": false }), + "no engine must report attached: false — the truthful control" + ); + } + #[test] fn read_origin_for_classifies_ipv4_mapped_loopback_as_local() { // #1664b: on a `::` dual-stack bind an IPv4 loopback client arrives as the @@ -1977,6 +2065,13 @@ mod tests { "{required} must be exposed via Access-Control-Expose-Headers" ); } + // #1763: the peer-tier readiness header is part of the SAME cross-origin contract — a + // browser-side resolver that cannot READ it cannot tell a cold-start gateway serve from a + // genuine peer miss, and loses that distinction SILENTLY (the header is still sent). + assert!( + EXPOSED_DIG_HEADERS.contains(&"x-dig-peer-tier"), + "x-dig-peer-tier must be exposed via Access-Control-Expose-Headers (#1763)" + ); // Every entry is a valid lowercase HeaderName (from_static panics otherwise) — this asserts // the const stays constructible, the exact shape the CORS layer relies on. for h in EXPOSED_DIG_HEADERS {