Task
Serve capsules by streaming, never by reading the whole capsule into RAM. User directive, 2026-08-22:
"dig-node should be streaming data out of capsules not reading the whole capsule into ram to serve it.
This is antithetical to what the capsule was supposed to support and achieve."
The capsule format was designed for seeking and ranged verification. Any path that materialises a whole
capsule to answer a request is working against the format, and it converts a ~200-byte request into a
~135 MiB allocation.
Measured — and the good news is most of the serve path ALREADY streams
Do not rewrite what is already right:
dig.getContent / dig.getCapsule seek and return one WINDOW at a time (lib.rs:1347). The
content serve path is already windowed.
dig.fetchRange exists with a per-range verification contract (seams::content::range_frame,
lib.rs:105-106, #1577).
- Peers range-pull rather than whole-pull (
lib.rs:2727).
So the format's streaming affordances exist and are used. The defect is the paths that bypass them.
The paths to fix
lib.rs:1842 — std::fs::read(&path) of the whole module to extract the data-section blob and decode
manifests. It is careful about lifetime (it drops the module buffer as soon as the blob exists, and drops
the blob before rendering) but it still peaks at whole-capsule resident. The data section's offset and
length are known from the module header — seek to it instead of reading the file to find it.
lib.rs:1311 and lib.rs:2115 — two more std::fs::read(&path) of a module. Determine what each
needs and whether a seek serves it.
dig.getMetadata renders a WHOLE data section (lib.rs:1347) and is on the anonymous public-read
allowlist. Its own doc concedes a hostile capsule could turn a ~200-byte request into a ~100 MB response
with 3-4 in-RAM copies, and today it is bounded by refusing oversized sections with METADATA_TOO_LARGE.
That is a cap, not streaming. The doc argues the section is "a whole JSON object, not a byte stream, so
it cannot be windowed" — assess whether that is genuinely true or an artefact of parsing eagerly; a
streaming JSON reader would change the answer. If it really cannot be windowed, say so and leave the cap.
Enumerate every read of a capsule/module file rather than fixing the three named — git grep for
fs::read, read_to_end, and any Vec<u8> sized from a module length. The named ones are what one grep
found, not a complete set.
What "done" looks like
- No serve path holds a whole capsule resident. State the new peak-resident figure per path and how you
measured it, not that it "should be lower".
- Verification is preserved. Content is trusted because it verifies against the merkle root; a streaming
read must verify incrementally, not defer verification until a whole buffer exists — which would reintroduce
the allocation it removes. Cite how each streamed path verifies.
- A test that asserts peak resident memory stays bounded while serving a capsule far larger than the
bound. Prove it red against the current fs::read implementation.
Fix the infra doc, which is miscited and load-bearing
modules/services/rpc.dig.net/infra/variables.tf:75-84 justifies a 2 GiB instance floor with:
"dig-node reads a whole capsule into RAM to serve it (std::fs::read, dig-node-core lib.rs:1004)"
lib.rs:1004 is not a capsule read. It is the staging-directory recursion, which already enforces a byte
budget before reading (max_total_bytes, stat-then-abort). So the sizing rationale cites the wrong line,
and it also overstates the serve path, which windows.
Correct the citation to whichever read actually justifies the floor after this work, or lower the floor if the
answer is that nothing does. An infra sizing comment that cites the wrong line is how a host gets sized for
a defect that was already fixed — or stays undersized for the one that was not.
Supersedes
This replaces the original framing of this ticket, which proposed a memory-derived semaphore with
backpressure. That bounded the symptom — concurrency × 135 MiB — and was the right shape only if the
135 MiB were irreducible. It is not. Stream first. If a bound is still wanted afterwards as
defence-in-depth, it is a much smaller and less costly one, and it can be a follow-up rather than a
precondition.
Why this matters beyond one host
node-rpc.dig.net is both the public bootstrap peer and the CloudFront origin, so peer-port memory pressure
5xxs the browser read tier (dig_ecosystem#923). But every self-hosted node has this shape — that is why
the fix belongs here and not in Terraform.
Sequencing
Do not fold into dig-node PR#245 (client-side bootstrap seed) or PR#295's successors. Own branch. dig-node
has live lanes on crates/dig-wallet/src/sage/* — this is dig-node-core, so no overlap, but use your own
worktree.
Parent
https://github.com/DIG-Network/dig_ecosystem/issues/923
Task
Serve capsules by streaming, never by reading the whole capsule into RAM. User directive, 2026-08-22:
The capsule format was designed for seeking and ranged verification. Any path that materialises a whole
capsule to answer a request is working against the format, and it converts a ~200-byte request into a
~135 MiB allocation.
Measured — and the good news is most of the serve path ALREADY streams
Do not rewrite what is already right:
dig.getContent/dig.getCapsuleseek and return oneWINDOWat a time (lib.rs:1347). Thecontent serve path is already windowed.
dig.fetchRangeexists with a per-range verification contract (seams::content::range_frame,lib.rs:105-106, #1577).lib.rs:2727).So the format's streaming affordances exist and are used. The defect is the paths that bypass them.
The paths to fix
lib.rs:1842—std::fs::read(&path)of the whole module to extract the data-section blob and decodemanifests. It is careful about lifetime (it drops the module buffer as soon as the blob exists, and drops
the blob before rendering) but it still peaks at whole-capsule resident. The data section's offset and
length are known from the module header — seek to it instead of reading the file to find it.
lib.rs:1311andlib.rs:2115— two morestd::fs::read(&path)of a module. Determine what eachneeds and whether a seek serves it.
dig.getMetadatarenders a WHOLE data section (lib.rs:1347) and is on the anonymous public-readallowlist. Its own doc concedes a hostile capsule could turn a ~200-byte request into a ~100 MB response
with 3-4 in-RAM copies, and today it is bounded by refusing oversized sections with
METADATA_TOO_LARGE.That is a cap, not streaming. The doc argues the section is "a whole JSON object, not a byte stream, so
it cannot be windowed" — assess whether that is genuinely true or an artefact of parsing eagerly; a
streaming JSON reader would change the answer. If it really cannot be windowed, say so and leave the cap.
Enumerate every read of a capsule/module file rather than fixing the three named —
git grepforfs::read,read_to_end, and anyVec<u8>sized from a module length. The named ones are what one grepfound, not a complete set.
What "done" looks like
measured it, not that it "should be lower".
read must verify incrementally, not defer verification until a whole buffer exists — which would reintroduce
the allocation it removes. Cite how each streamed path verifies.
bound. Prove it red against the current
fs::readimplementation.Fix the infra doc, which is miscited and load-bearing
modules/services/rpc.dig.net/infra/variables.tf:75-84justifies a 2 GiB instance floor with:lib.rs:1004is not a capsule read. It is the staging-directory recursion, which already enforces a bytebudget before reading (
max_total_bytes, stat-then-abort). So the sizing rationale cites the wrong line,and it also overstates the serve path, which windows.
Correct the citation to whichever read actually justifies the floor after this work, or lower the floor if the
answer is that nothing does. An infra sizing comment that cites the wrong line is how a host gets sized for
a defect that was already fixed — or stays undersized for the one that was not.
Supersedes
This replaces the original framing of this ticket, which proposed a memory-derived semaphore with
backpressure. That bounded the symptom — concurrency × 135 MiB — and was the right shape only if the
135 MiB were irreducible. It is not. Stream first. If a bound is still wanted afterwards as
defence-in-depth, it is a much smaller and less costly one, and it can be a follow-up rather than a
precondition.
Why this matters beyond one host
node-rpc.dig.netis both the public bootstrap peer and the CloudFront origin, so peer-port memory pressure5xxs the browser read tier (dig_ecosystem#923). But every self-hosted node has this shape — that is why
the fix belongs here and not in Terraform.
Sequencing
Do not fold into dig-node PR#245 (client-side bootstrap seed) or PR#295's successors. Own branch. dig-node
has live lanes on
crates/dig-wallet/src/sage/*— this isdig-node-core, so no overlap, but use your ownworktree.
Parent
https://github.com/DIG-Network/dig_ecosystem/issues/923