Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aa77667
chore(relay): open the epic-3128 relay batch (#322, #332, #333)
MichaelTaylor3d Aug 23, 2026
081acb0
fix(reshare): erase a discarded partial's checkpoint with its bytes
MichaelTaylor3d Aug 23, 2026
6c5ebb3
feat(relay): a hop acks a running relay instead of holding the ask open
MichaelTaylor3d Aug 23, 2026
1084bfe
feat(relay): wait on a hop's progress, and escalate within one invoca…
MichaelTaylor3d Aug 23, 2026
c9d9316
test(relay): drive the relay wait through the function production calls
MichaelTaylor3d Aug 24, 2026
d811732
fix(relay): restore the relay wait ceiling, and correct two false man…
MichaelTaylor3d Aug 24, 2026
a52615b
build(deps): adopt dig-download 0.19.2 and spec the relay ack contract
MichaelTaylor3d Aug 24, 2026
39706fc
test(relay): fail rather than hang if the relay ceiling is removed
MichaelTaylor3d Aug 24, 2026
763bcc5
style: cargo fmt
MichaelTaylor3d Aug 24, 2026
fa6afaf
test(warm): assert the asked SET, not the retry count, in the pool-un…
MichaelTaylor3d Aug 24, 2026
8cbf8f1
chore: drop the WIP stub and revert gitnexus's edits to CLAUDE.md/AGE…
MichaelTaylor3d Aug 24, 2026
0ec7275
refactor(relay): make the descriptor ask unbypassable, and budget rel…
MichaelTaylor3d Aug 24, 2026
56491f5
test(relay): pin the escalation at the production entry point
MichaelTaylor3d Aug 24, 2026
5a9cdc0
docs(relay): record that a relayed warm competes for local warm slots
MichaelTaylor3d Aug 24, 2026
62b3bd2
docs(spec): verified-complete names the asset upload STATE, not just …
MichaelTaylor3d Aug 24, 2026
07dbbd2
docs(spec): the relay ceiling requires a per-pull budget beside it
MichaelTaylor3d Aug 24, 2026
fea5aa9
test(relay): pin the per-pull budget from both sides
MichaelTaylor3d Aug 24, 2026
04494e0
style: drop an unused test helper
MichaelTaylor3d Aug 24, 2026
44409b1
fix(relay): scope the relay-wait ledger to the PULL, not the daemon
MichaelTaylor3d Aug 24, 2026
5fbc19c
docs(spec): the relay-wait budget's lifetime is the pull's, and a TTL…
MichaelTaylor3d Aug 24, 2026
75b9175
style: confine the no-op pull lifecycle to tests
MichaelTaylor3d Aug 24, 2026
26c11bd
docs(relay): state the privacy boundary as it is, not stronger
MichaelTaylor3d Aug 24, 2026
71f0de4
fix(relay): report the pull boundary from a drop guard, so a panic ca…
MichaelTaylor3d Aug 24, 2026
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
8 changes: 4 additions & 4 deletions 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.145.1"
version = "0.146.0"
Comment thread
MichaelTaylor3d marked this conversation as resolved.

# 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
70 changes: 70 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -3012,6 +3012,7 @@ method runs, and it MUST NOT be conflated with the wallet's own `-32043` egress
| -32010 | `UPSTREAM_ERROR` | shell | The blind-passthrough relay failed (unreachable / non-JSON). |
| -32015 | `METADATA_TOO_LARGE` | node | `dig.getMetadata` refused: the publisher metadata section is too large or too complex to render safely. Refused when the ENCODED section exceeds `METADATA_SECTION_MAX_BYTES` (3 MiB) or its `custom` exceeds `MAX_CUSTOM_ENTRIES`/`MAX_CUSTOM_JSON_DEPTH`/`MAX_CUSTOM_JSON_ELEMENTS` (both checked BEFORE decode, #2160), or the RENDERED body exceeds `METADATA_RESPONSE_MAX_BYTES` (3 MiB, #2145). This section is rendered WHOLE — it cannot be windowed like `dig.getCapsule` — and `custom`/`links` are publisher-controlled, so an oversized/hostile capsule is refused with this bounded error rather than expanded ~16× in memory or blasted into a ~100 MB response (§5.5.1). A normal (kilobyte) metadata section is served unchanged. |
| -32017 | `CONTENT_MISS_INCONCLUSIVE` | peer | No holder was named AND the search could not establish that there is none: a consulted leg timed out, was unreachable, or refused uninformatively (§10.4.5). The OPPOSITE instruction to a plain not-found — a not-found says stop looking, this says the question was not answered and the request MAY be retried. Collapsing the two let ONE slow peer manufacture an authoritative absence and, since a hop relays its answer, propagate it downwards. DEFINED by `dig-rpc-protocol` as `ErrorCode::ContentMissInconclusive` with origin `Peer` — the failure arises in the discovery layer from an unconsultable hop, mirroring `PeerUnreachable`. dig-node adopts the variant and does not assign the number. |
| -32017 | `ContentMissInconclusive` | — | Availability not ESTABLISHED. Also the answer a hop gives while it is still RELAYING a capsule on the requestor's behalf, in which case `error.data.relay_staged_bytes` carries the hop's staged byte count (§21.1). The field is ADDITIVE: a reader that ignores it sees an ordinary inconclusive miss and retries. |
| -32020 | *(reserved: onion `onion_circuit_unavailable`)* | — | Reserved for the onion-routing contract; NOT minted by the control plane. |
| -32021 | *(reserved: onion `privacy_requires_local_node`)* | — | Reserved for the onion-routing contract. |
| -32022 | *(reserved: onion `onion_hops_out_of_range`)* | — | Reserved for the onion-routing contract. |
Expand Down Expand Up @@ -3425,6 +3426,14 @@ boolean, default `false`). It MUST NOT trigger on `push` to `main`.
The guard MUST be falsifiable — a self-test MUST assert that it FAILS an asset list carrying only
the native packages.

**"Verified complete" is a statement about BYTES, not about names.** An asset is complete only when
it is present AND its upload state reports it as fully uploaded (GitHub: `state == "uploaded"`); an
asset still being written reports `state == "starting"`. The row is created when the upload BEGINS,
so every expected asset name can be present while bytes are still in flight, and a verification that
counts names alone promotes a release whose binaries are truncated or absent. A reimplementation that
reads only the name list satisfies the letter of the clause above and reintroduces exactly the race
it exists to prevent, which is why the state is stated here rather than left to the implementation.

11.1a. **Doc-only commits never release** (the version is unchanged → the tag exists → the stable
job is a no-op). The manual-dispatch `workflow_dispatch` on `release.yml` is a build-only "does main
still build?" canary — it never publishes (publish is gated on a tag ref).
Expand Down Expand Up @@ -6238,6 +6247,67 @@ stream is closed, so that its descriptor memo is populated and the re-ask is ans
total a requestor spends on one holder MUST be bounded; an ask that is ANSWERED negatively MUST NOT be
re-asked, since re-asking cannot change a refusal and would delay trying the next holder.

**A hop that is RELAYING MUST acknowledge, not block (MUST, dig-node#333).** A node asked with
`proxy: true` for a capsule it does not hold pulls that capsule from a holder on the requestor's
behalf. That pull is a whole-capsule transfer and takes arbitrarily long — minutes for a 135 MB
capsule on an ordinary link — while the descriptor ask that triggered it is bounded in tens of
seconds. A hop MUST NOT hold the ask open for the length of the transfer: it MUST answer within a
short grace, and if the capsule has not landed by then it MUST answer
`ContentMissInconclusive` (`-32017`) carrying `error.data.relay_staged_bytes`, its own count of the
bytes it has staged so far, and MUST leave the pull running. A capsule that lands inside the grace is
answered with the ordinary descriptor, indistinguishably from a holder's.

The code is the taxonomy's existing inconclusive-miss code and MUST NOT be a new number: a running
relay is exactly the condition that code names — the availability answer is UNKNOWN and a retry is
meaningful — as opposed to `RESOURCE_UNAVAILABLE`, which settles the question. A requestor that does
not understand `relay_staged_bytes` therefore behaves correctly by default: it retries later.

**A requestor MUST bound a relay wait by PROGRESS and by a ceiling (MUST).** On receiving that answer
a requestor MAY wait, re-asking the same hop on an interval. It MUST continue only while the reported
staged count STRICTLY ADVANCES, MUST abandon the hop after a bounded stall window in which it does
not, and MUST abandon it at a hard ceiling however healthy the progress appears.

Both bounds are required and the ceiling is a SECURITY bound. `relay_staged_bytes` is a hop's claim
about itself, so a hostile hop can fabricate a counter that rises forever and would never stall; only
the ceiling makes the worst case finite.

**The ceiling MUST be accompanied by a per-PULL budget.** A ceiling bounds a wait on ONE hop, and a
pull asks many: a puller's worst case is `descriptor attempts × holders × the per-ask bound`, so
raising the per-ask bound from the descriptor ladder to the relay ceiling multiplies by the holder
count. Where the provider set includes merely-CONNECTED peers rather than only announced holders, that
count is the whole connected pool, and hops each fabricating a byte of progress per poll would hold one
pull open for hours while every one of them stayed inside its individual ceiling. A requestor MUST
therefore charge relay waiting against a budget scoped to the CAPSULE being pulled, and MUST NOT grant
each hop a fresh allowance. Shrinking the per-hop ceiling is NOT an acceptable substitute: an honest hop
relaying a large capsule genuinely needs the full ceiling, and that case is what this path exists for.

**The budget's lifetime MUST be the PULL's.** It MUST be released when the pull ends — in success or
failure — so that a later pull of the same capsule starts with the whole of it. A budget that persists
beyond its pull makes a capsule whose first pull spent it permanently ineligible for the relay path,
and, because the exhaustion is reported as a failure against whichever peer was being asked, it
attributes this node's own earlier spend to that peer. A requestor MUST NOT report a budget exhaustion
in a form that names a peer as its cause.

An idle timeout MUST NOT be used in place of the release. Relay time is charged when a wait ENDS, so a
wait in progress is indistinguishable from an idle entry for up to the whole per-hop ceiling: a timeout
shorter than that ceiling can expire a live pull's budget mid-wait and silently restore the
per-hop multiplication, while one at or above it withholds the budget from the next pull for as long as
the condition it was meant to prevent. The pull boundary is therefore reported by the caller that
drives the pull, never inferred. A requestor MUST NOT treat the count as evidence about the
bytes: the capsule that eventually arrives is verified against the chain-anchored root exactly as a
direct holder's would be (§21.2), so a hop that fabricates its way through a wait still cannot produce
content that passes.

**A relay ask is a SECOND-PASS escalation, and both passes MUST fit in one request (MUST,
dig-node#322).** A requestor MUST spend a PLAIN descriptor round on a `(capsule, peer)` pair before it
sets `proxy: true` for that pair — asking every connected peer to fetch a capsule on this node's
behalf before establishing that no reachable holder exists is the amplification the two-phase design
exists to bound. When that plain round is ANSWERED and the answer is no, the requestor MUST escalate
within the SAME invocation rather than deferring to a later one; a user issuing the documented single
command MUST NOT have to issue it twice. A plain round that was never answered at all MUST NOT be
escalated: a peer that could not answer a plain ask will not answer a relay ask, and re-asking it
doubles the invocation's bound for nothing.

### 21.2. The anchor verifier is the ONLY root of trust (MUST)

Every check before the anchor gate compares peer-supplied bytes against peer-supplied hashes. Those
Expand Down
15 changes: 10 additions & 5 deletions crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ name = "dig-node-core"
# dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor;
# this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x
# rule -- recorded here rather than letting the number imply the locator surface held still.
version = "0.54.2"
version = "0.55.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."
Expand Down Expand Up @@ -112,10 +112,15 @@ dig-chainsource-interface = "0.1"
# The two `ChainSourceProvider` traits then fail to unify and `registry.register` stops
# compiling, so a caret `0.5` silently breaks this crate the moment the lock refreshes.
# 0.5.1 is the last version whose `dig-chainsource-interface` agrees with chia-peer's.
# Unpinning (to 0.6, alongside dig-wallet) needs a `chia-peer` release built against
# dig-chainsource-interface 0.2 FIRST (release-first, §4.1) — a separate repo, tracked
# separately. dig-wallet is on chia-query 0.6 meanwhile: it does not exchange `chia_query`
# types across this crate boundary, so the two lines coexist without unifying.
# THE BLOCKER NAMED HERE UNTIL 2026-08-23 WAS WRONG, AND THE WRONG NAME IS WHY NOBODY UNPINNED.
# It said the unpin awaited a `chia-peer` release built against dig-chainsource-interface 0.2.
# No such release was ever needed: `chia-peer` 0.2.0 skipped 0.2 entirely and is built against
# dci ^0.3 + chia-protocol ^0.36.1, and `chia-query` (now 0.15.0) is on the same dci ^0.3 /
# chia 0.36 line. The two `ChainSourceProvider` traits therefore ALREADY unify again.
# What actually blocks the unpin is that they unify at CHIA 0.36, while this crate is still on
# the 0.26 line — so adopting them is the chia-0.36 cascade (dig_ecosystem#3152), not a
# one-line bump. Verify against the index before acting on this comment; a stale blocker is
# worse than none, because it stops people looking.
chia-query = "=0.5.1"
# The ecosystem's canonical address-family / IPv6-first primitive (CLAUDE.md §5.2). The advertise
# path keys + orders candidate addresses via `dig_ip::Family` and aggregates them source-tagged +
Expand Down
13 changes: 11 additions & 2 deletions crates/dig-node-core/src/capsule_warm_locator_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ fn warmer_over(
CapsuleWarmer::new(
content.warm_provider_locator(),
transport,
Arc::new(crate::seams::dig_peer::NoPullState),
Arc::new(dig_download::InMemoryStateStore::new()),
Arc::new(AnchoringChain),
WarmPaths {
Expand Down Expand Up @@ -187,9 +188,17 @@ async fn a_warm_reaches_a_holder_that_only_the_connected_pool_can_name() {

warmer.warm(&hex32(STORE), &hex32(ROOT)).await;

// The assertion is on the SET of peers asked, not on the sequence. Both halves of the property
// survive that: a pull that asked NOBODY leaves the set empty, and a pull that reached past the
// pool puts a second id in it. What the set deliberately does NOT pin is HOW MANY TIMES the
// holder was asked, because that is dig-download's retry budget and not this test's subject --
// 0.19.2 added an across-round descriptor re-ask (dig-download#37), so this holder, which never
// answers, is now asked `MAX_DESCRIPTOR_ATTEMPTS` times. Pinning the count made a legitimate
// downstream retry fix look like a locator regression here.
let asked: std::collections::BTreeSet<String> = transport.asked().into_iter().collect();
assert_eq!(
transport.asked(),
vec![holder],
asked,
std::collections::BTreeSet::from([holder]),
"the connected pool peer is the ONLY candidate the DHT could not name, so a pull that \
asked it proves the warm locator unioned the pool - and a pull that asked nobody is the \
shipped defect"
Expand Down
7 changes: 6 additions & 1 deletion crates/dig-node-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2249,7 +2249,11 @@ impl NodeContent {
));
self.set_capsule_warmer(crate::seams::dig_peer::CapsuleWarmer::new(
self.warm_provider_locator(),
transport,
// The SAME transport, handed over under both of its roles: the thing that talks to
// holders, and the thing holding state whose lifetime is one pull. Two different objects
// here would leave the relay-wait ledger never released.
Arc::clone(&transport) as Arc<dyn dig_download::ModuleTransport>,
transport as Arc<dyn crate::seams::dig_peer::PullLifecycle>,
self.state_store.clone(),
anchor_resolver,
crate::seams::dig_peer::WarmPaths {
Expand Down Expand Up @@ -4783,6 +4787,7 @@ pub(crate) mod tests {
vec![],
8,
)),
Arc::new(crate::seams::dig_peer::NoPullState),
Arc::new(FileStateStore::new(td.path().join("warm-state"))),
Arc::new(HangingResolver),
crate::seams::dig_peer::WarmPaths {
Expand Down
Loading
Loading