docs: write architecture document - #2
Conversation
…inst source Move the architecture doc under docs/ and add the supporting design set so the whole thing is reviewable as one PR: - docs/rfc-pdp-minimum-piece-size.md — PDP gas/economics RFC + Piri policy - docs/aggregation-gate.md — "read first" brief framing the aggregation gate - docs/pdp-cost-calculator.html — interactive cost model
bajtos
left a comment
There was a problem hiding this comment.
This is a well-written, detailed proposal. Love it!
I am afraid I ran out of energy & concentration by the time I got to the middle of docs/architecture.md. I'll continue the review of section 7 and onwards tomorrow.
| shows the **economics** hold (registration linear, proving logarithmic, proof-fee size-neutral, | ||
| **no slashing**, registration gas-gatable). The remaining proof is **operational** — that Piri can | ||
| *register, prove, and delete* sub-256-MiB pieces routinely at PiB scale: | ||
| - **throughput** — ~11M `addPieces` txns per PiB at 8 MiB (the deployed verifier's ~13-piece/tx cap, |
There was a problem hiding this comment.
Wow, that's a lot of transactions!
- Do we have an estimate of how much we will pay in gas fees to onboard PiB of 8 MiB pieces?
- How quickly can we submit these 11M transactions on the chain?
There was a problem hiding this comment.
Good questions, and the answer splits ~cleanly: gas dollars are (probably) not the problem; transaction throughput is.
- Gas cost: registration (addPieces) is ~120,700 EVM gas/piece (size-independent), ~8× in FEVM. For a whole PiB at 8 MiB (as an example) that's on the order of a fraction of a FIL to low tens of dollars at a quiet base fee — negligible against storage revenue. And registration is deferrable: Piri gas-gates it (defer-and-retry, no deadline, no slashing for being late), so we ride cheap-gas windows and never pay spike prices for it. So I'm not worried about the fee. But there is a tradeoff for operators here, since not registering during times of high gas fees means not being paied for data stored.
- Throughput: this is the real wall. And this all depends on how many roots we can add in a single transaction, and how many of those we can send per epoch. The calculator in here attempts to model this.
So "how fast" is a function of how many addPieces transactions we can send, and how many pieces we can stuff in each transaction.
To be upfront: this throughput magnitude is exactly the assumption I'm asking FilOZ to stress-test in the top-level note ("Should Piri do cross-object aggregation at all?"). Good news since I first wrote this: FilOZ already removed the addPieces batch cap in v1.3.0, so the registration wall is softer than the early ~13-pieces/tx draft assumed — but the measured pieces/tx (and whether to shard across multiple data sets) is still the open question that decides whether we can drop aggregation entirely or have to keep a floor.
|
|
||
| Today Piri pools accepted blobs into ~128 MiB pieces (`MinAggregateSize`, up to a 256 MiB ceiling) | ||
| across **unrelated** objects, registers them in one node-wide proof set, and can delete only a | ||
| **whole** piece. So deleting one 8 MiB S3 object means: take down a ~128 MiB aggregate that also holds |
There was a problem hiding this comment.
I am wondering how you chose 8 MiB as the new floor, rather than 1, 2, 4, 16, or 32 MiB?
There was a problem hiding this comment.
Honestly, 8 MiB is a principled starting point, not a derived optimum. It's the AWS SDK default: both boto3 and the aws CLI default multipart_threshold and multipart_chunksize to 8 MiB, so any object large enough to matter arrives already split into ~8 MiB parts by the client. Setting the floor there makes "one client part ≈ one on-chain piece" land for the dominant upload path, while smaller whole objects fall below the floor and get aggregated.
The trade across the range: a smaller floor (1/2/4) puts more objects above the floor (better delete granularity, each its own piece) but multiplies piece count and therefore registration transactions; a larger floor (16/32) means fewer pieces but more sub-floor aggregation and bigger repack units when a member is deleted. 8 sits at the natural client boundary.
That said — I'm now questioning whether we want a floor at all. The cleaner model is one object = its own piece(s) with no cross-object aggregation, which makes "8 vs 1/2/4/16/32" moot. I've written that up as a top-level note ("Should Piri do cross-object aggregation at all?"); short version: 8 MiB only matters if FilOZ tells us we must aggregate, in which case the floor is a function of the measured object-size distribution we don't have yet (the instrumentation/Aurora thread). Absent that constraint, there's no floor to pick.
There was a problem hiding this comment.
Honestly, 8 MiB is a principled starting point, not a derived optimum. It's the AWS SDK default: both
boto3and theawsCLI defaultmultipart_thresholdandmultipart_chunksizeto 8 MiB, so any object large enough to matter arrives already split into ~8 MiB parts by the client. Setting the floor there makes "one client part ≈ one on-chain piece" land for the dominant upload path, while smaller whole objects fall below the floor and get aggregated.
Thank you for the explanation; now the choice makes sense to me!
| reaches zero across **all** spaces (Piri keeps per-`(digest, space)` allocation rows to count on). | ||
| At zero, an own-piece blob takes Regime A and an aggregated blob takes Regime B. | ||
|
|
||
| **Bucket teardown.** All blobs share one node-wide PDP data set, so `deleteDataSet` would tear down |
There was a problem hiding this comment.
Do we have any concerns about the one node-wide PDP data set as we grow the amount of data stored?
If a node is storing 100TiB of data, spread across 1M objects >= 8MiB:
- The data set has 1M roots, does the on-chain data structure support this?
The PDP proofs are probabilistic. I asked Claude to compare one 100TiB-sized proof set vs 100x 1TiB-sized proof sets, and it says there is meaningful difference.
Summary:
The probabilistic guarantee is "you can't cheat much for long without detection," not "every byte is checked every period." But "eventually" scales with how small the missing fraction is — and a single 100 TiB set makes "eventually" very long for small losses.
More details:
- One proof set, 100 TiB, 5 challenges/period: If the provider drops 1% of your data (1 TiB), per period: 1 − 0.99^5 ≈ 4.9% chance of catching it. They'd expect to get away with it for ~20 periods on average. Drop 0.1% (100 GiB) and per-period detection falls to ~0.5%.
- 100 proof sets, 1 TiB each, 5 challenges each = 500 challenges/period total:
Now you have 500 independent challenges across your data every period. If the provider drops 1% spread across everything: 1 − 0.99^500 ≈ 99.3% caught per period. Even 0.1% missing gives 1 − 0.999^500 ≈ 39% per period. - There's a localization problem beyond raw probability. With 1M roots in one set and only 5 challenges, an adversary who deletes one entire root (say one of your 1M pieces, ~100 MiB) is missing 1/1,000,000 of the leaves. Per-period detection ≈ 5/1,000,000 = 0.0005%. That root could be gone for thousands of years of proving periods before a challenge happens to land on it. The data is effectively unprotected at fine granularity.
There was a problem hiding this comment.
Two halves to this, and I want to answer them differently.
On the security/probabilistic half (the 5-challenges-over-1M-roots dilution argument): I'd rather not pull that thread here. This doc treats the PDP/PDPVerifier contract security model as a vetted assumption, and how hard it is to cheat over a proving window is covered by the contract security analysis, not this doc.
On the operational scaling half, though — your instinct is right and it's very much live, so I've pulled it into a top-level note ("Should Piri do cross-object aggregation at all?"). Structurally, I believe ~1M roots is fine: every on-chain hot path is bounded (O(batch) / O(5) / O(log cumulative) / O(removals≤2000)). The real open question you're circling is how many proof sets a node should maintain. Today it's one data set per node for operational simplicity — but each set is a proof the node must produce every period, so sharding into N sets is N proofs/day and shifts the node's SLA and gas usaged since more proofs per day. The flip side is that a cap on pieces-per-set + sharding could help delete throughput, localization, and per-tenant rails. I don't have the answer — it's exactly the kind of thing we need FilOZ to weigh in on, and it's tied to whether we can drop cross-object aggregation entirely. Let's track it in that note.
There was a problem hiding this comment.
On the security/probabilistic half (the 5-challenges-over-1M-roots dilution argument): I'd rather not pull that thread here. This doc treats the PDP/PDPVerifier contract security model as a vetted assumption, and how hard it is to cheat over a proving window is covered by the contract security analysis, not this doc.
I agree to not question the PDPVerifier contract's security model.
What I am questioning: Did FilOz consider the security implications of very large ProofSets (like 100TiB+)? If they did, what is their recommendation for the maximum proofset size that still keeps PDPVerifier security promises?
I think it's important to include this point in our discussions with FilOz.
alanshaw
left a comment
There was a problem hiding this comment.
Can this please be a PR against the RFC repo?
I haven't yet reviewed rfc-pdp-minimum-piece-size.md but I have review fatigue so I'm going to stop there for today and publish the comments I have.
| - **`min_aggregate_size` (floor, ~8 MiB).** Any blob **≥ floor** becomes its **own** on-chain piece, | ||
| so deleting it removes a whole piece (O(1)) with no survivors to re-hash. Any blob **< floor** joins | ||
| a **cross-object** sub-floor aggregate built up to the floor — but **lazily**: never repacked on | ||
| delete; dead bytes linger and compact periodically (or never). They are cheap bytes. |
There was a problem hiding this comment.
I'd like to not never repack. I'm unsure how we square this off, i.e. if we allow storage nodes to prove over pieces that no customer is paying for, it's basically on us to pick up the bill. Furthermore how do we distinguish a uncollected garbage piece from a completely random piece the storage node inserted to earn more $$$.
There was a problem hiding this comment.
Agreed — "never" was too strong, and I think the cleanest way to "not never repack" is to not cross-object aggregate in the first place. I've written this up as a top-level note ("Should Piri do cross-object aggregation at all?"). If each object is its own piece(s), there's nothing to repack on delete (you retire the object's own piece), and the concerns somewhaat dissolve:
- Unpaid pieces: there's no shared aggregate accumulating dead bytes from other objects, so the proven set maps 1:1 to objects we're paying for.
- Garbage vs. fabricated — and these split on trust, which I want to be careful about: the garbage half (dead bytes from our own deletes) is honest-operator GC — non-adversarial — so our own bookkeeping tells us which pieces are no longer referenced and we schedule them for retirement on the normal delete path. The fabricated-piece half is adversarial and harder. If we, or a client ran Ingot we could reconcile on-chain roots against
blob_refs. But that does not work for if the SP runs Ingot since:blob_refsis keyed by the blob's sha256, but the on-chain root is a CommP, and there's no way to verify a CommP corresponds to a given sha256 — that binding is a UCAN attestation (trust), not a computation- the SP operator is the one running Ingot, so reconciling against their reverse index is letting the adversary audit themselves.
The trust-correct anchor has to be FilOne-side: Sprue issues every accept, so FilOne holds a central, SP-independent record of what it authorized (space, digest, size), and FilOne is the FilecoinPay payer, so the real backstop is reconciling the on-chain dataset size/accounting against the authorized total (which needs no per-piece CommP↔sha256 identity) and halting the rail on divergence. That covers inflate-to-earn; it doesn't by itself cover byte-substitution, but that's always been the case for this deployment configuration requiring mnual download and verification by a trusted party.
There was a problem hiding this comment.
From what I remember, the TX adding a new root to a PDP ProofSet must contain extraData signed by the Filecoin Pay payeer (FilOne). This extraData also contains the CommP of the added root. In other words, the SP cannot add arbitrary roots to PDP behind our back; every such addition must be signed by FilOne's wallet.
Also, since PDP is using CommPv2, we know what size we are signing over when FilOne is authorising a PDP addRoot transaction.
The point I want to make: I think we don't need to compare the total PDP proofset size against the authorised total, because we should be able to verify the size on a per-object basis.
I don't fully understand how Forge interacts with PDP, so I may be wrong.
| ~8 MiB floor (per-SP / per-dataset), keeping aggregation adaptive: blobs ≥ floor become their own | ||
| piece, only the sub-floor tail pools — and **lazily** (never repacked on delete). The RFC §7 carries | ||
| the spec. This is mostly a constant change plus a lazy-compaction path — Piri's aggregator is already | ||
| size-driven — not a new grouping primitive. |
There was a problem hiding this comment.
I'm starting to wonder if we even bother with a minimum size...
While there's probably some aspect of churn related to size, my suspicion is that time is the dominant factor for churn. In other words, the longer a piece lives for, the less likely it is to be deleted.
What if Piri just put every piece on the chain as is, but had a background process to aggregate old pieces together? It allows new pieces to churn easily with minimal cost, it only aggregates pieces that are unlikely to be removed (and thus reduces re-aggregation work/cost), and it keeps the total number of proving roots low.
We can also implement this in 2 steps. Step 1 is just put every piece on chain as is. Ship it, then...Step 2 is add the generational aggregator background process.
There was a problem hiding this comment.
This is the comment thats reshaping my thinking, and I've written the full version up as a top-level note ("Should Piri do cross-object aggregation at all?") — let me anchor the discussion there, but the short version: I think you're right, and I'd go further than "maybe no minimum" to no cross-object aggregation at all. Your "time dominates churn" prior is backed by the gas RFC (on-chain cost favors bigger pieces at every churn level, §3.2), so the only reason to aggregate is throughput/root-count, not cost — and if the contracts can take the piece volume, the cleanest answer is one object = its own piece(s), no co-tenancy, deletion always O(1).
In that world the two steps map cleanly: Step 1 is object=piece (no floor) — register everything as-is; Step 2, the generational background aggregator, becomes the optional relief valve for root-count/throughput, aggregating only old, cold pieces unlikely to be deleted (so it rarely triggers a repack).
Two caveats I'd flag rather than gloss:
- "every piece as-is" maximizes piece count, which makes Miro's ~11M-txn / send wall worse for a heavy tail of tiny objects — so whether we can do
this at all depends on the contract-practicality question we need to raise with FilOZ in the top-level note (and on raising the extraData cap / sharding across data sets). - Generational aggregation reduces the live-root count, but because
nextPieceIdis monotonic it doesn't undo the proving-depth ratchet, and registration txns are paid per-object up front regardless — so it's relief, not a free lunch.
Net: this is my preferred direction and I'd ship it iff FilOZ confirms the piece volume is practical; if they say it isn't, that's exactly when the size floor comes back as the fallback.
There was a problem hiding this comment.
I'd go further than "maybe no minimum" to no cross-object aggregation at all.
Sounds great to me! 💯
Generational aggregation reduces the live-root count, but because nextPieceId is monotonic it doesn't undo the proving-depth ratchet, and registration txns are paid per-object up front regardless — so it's relief, not a free lunch.
I am wondering if we can work around the monotonic nextPieceId by creating a new proofset for each generation aggregation run?
- Flag the current proofset as "gc_pending".
- Create a new proofset that will be used for new uploads from now on.
- Wait until all TXs adding pieces to the gc_pending proofset are settled
- Perform generational aggregation for pieces in the gc_pending proofset
- Create a new proofset to hold the aggregated roots
- Iterate over all roots in the processed proofset
- Large root - move to the new proofset as-is
- Aggregate small roots into bigger ones
- Schedule the processed proofset for deletion
Anyhow, I agree generational aggregation is post-MVP (post-M2), so we don't need to worry about it right now.
| invisible to storage and retrieval — it matters only for proving and deletion. | ||
| - **The catalog names content; the indexer locates it.** Ingot's manifest pins body **digests** | ||
| (stable across any chain-side reshuffling); the indexer maps a digest to its current physical | ||
| location. A read joins the two. |
There was a problem hiding this comment.
Just to clarify, my mental model of this currently is that a body digest is the hash of the entire object (single part) OR the hash of a root UnixFS node that links to all shards of the object (multi part).
Location claims are created by Piri nodes for each shard, telling us where the shard(s) for an object are.
A (sharded DAG) index is created by Ingot for the object - the index lists the shard digests that comprise the blob. The new nodes property (used only for multipart uploads) carries the root UnixFS node and it tells us the order the shards need to be served for retrieval. See RFC for details.
An object read queries the indexer for the body digest and gets back location claims and an index, allowing retrieval to take place.
What we could do, to remove the indexer would be:
- Assume all blobs are stored at a specific Piri node URL (configurable probably).
- Encode the (ordered) shard hashes in the object manifest.
This does however make the MST and blobs undiscoverable without Ingot. This actively prevents (or at least makes it hard) to credible exit.
| └ body | ||
| ├ size 629_145_600 (600 MiB total) | ||
| ├ sha256 <whole-body digest> (integrity) | ||
| └ blobs[] ── ordered, contiguous, covers [0, size) ──┐ |
There was a problem hiding this comment.
Ah I see, the plan is to store these in the object manifest anyway? What do we get from this? We have to go to the indexer anyway to get the locations...
Consider calling this "shards" to align with what they are referred to in Forge.
| durable. The catalog plane is the per-operation delta. | ||
|
|
||
| Because many tiny blocks share a CAR, catalog retrieval uses the indexer's **index-claim / | ||
| sharded-dag-index path** (block CID → byte range within its CAR shard). This two-level lookup is |
There was a problem hiding this comment.
Of course, we still need to index the MST blocks...
|
|
||
| To stay out of that window, a blob's local copy is retained until the object is **published** — | ||
| confirmed by an independent, cache-cold indexer probe actually resolving the digest (or a fixed | ||
| margin past the TTL) — not merely until `accept` returns. |
There was a problem hiding this comment.
Um, so this is basically the reason the indexer exists. Items written to the cache are given ample time for IPNI to catch up (7 days) so this shouldn't be necessary.
That said a belt and braces approach might be a good idea. We have a library for getting notifications that an IPNI chain has "done a sync" you might want to take a look at: https://github.com/storacha/go-libstoracha/blob/main/ipnipublisher/notifier/notifier.go
| - A **within-space** re-PUT short-circuits in Sprue, which replays the existing claim — no PUT, no | ||
| new accept, same location returned. The reference index simply gains another version. | ||
| - A **cross-space** dedup hit (bytes present from another space) returns no upload URL but still | ||
| mints a fresh per-space location claim. |
There was a problem hiding this comment.
| mints a fresh per-space location claim. | |
| issues a fresh per-space location commitment. |
| Object bodies resolve straight from a digest: `accept` publishes an `/assert/location` commitment | ||
| keyed by the blob's own digest with a whole-blob range, and the manifest's ordered blob list carries | ||
| the byte-range map for split/multipart objects. So body retrieval needs only a digest → location | ||
| lookup, no per-CAR sharded-dag-index. |
There was a problem hiding this comment.
I guess so. I just don't know why you wouldn't use an index? You have to go to the indexer anyway and we should probably try to put as little information in the object manifest as possible as we don't have a good garbage collection story for the calalog.
If we ever want to access that object outside of the catalog then it is impossible - you can't ask the indexer about that object CID. 🤷♂️ IDK maybe not important, just seems weird to break compatibility here.
How about as a compromise we don't create indexes when the object is single part, but if there is > 1 shard then we do?
Should Piri do cross-object aggregation at all?A theme runs through a lot of these comments, so I want to pull it out here and link the individual threads back to it. The assumption I want to challenge. Piri has aggregated blobs into larger on-chain pieces since it was first written, and I think we've all just assumed that was necessary — for gas/throughput reasons — without ever actually stress-testing it. Reading this review, it struck me that almost every hard problem in this doc traces back to one thing: cross-object aggregation. Deletion complexity, compaction, "never repack," proving over pieces nobody's paying for, telling uncollected garbage from a fabricated piece, the GDPR compaction window, the size floor itself — they exist only because we pool blobs from different objects into a shared piece. Remove that and the whole cluster collapses. The proposal (the simple world). Drop cross-object aggregation. Store each S3 object as its own content-addressed blob(s): one on-chain piece if it fits under a ceiling, or a handful of object-owned pieces if it's larger (split at the proving-code ceiling; per-part for multipart, which keeps us compatible with the encryption RFC's per-part envelopes). No floor, no co-tenancy — a piece belongs to exactly one object. Then:
The catch, and the real point: this hinges on a contract-practicality question we don't have the answer to. Object=piece maximizes piece count, so it only works if the PDP contracts and proving can take that volume in practice. We've never actually asked, though we meant to at the FilDev summit ( 🤦 ) The open questions for FilOZ / FOC — as the experts:
The decision rule. I want to build around the assumption that object=piece is viable, because if it is, this gets dramatically simpler. But I'm treating it as an assumption to stress-test, not a settled fact:
So the floor stays in the doc — but as the fallback, not the default. The default I want to pursue is "no cross-object aggregation," pending FilOZ telling us whether the assumption holds. This is also why I'd treat the aggregation strategy (and, relatedly, the indexer scope) as open in both this doc and the appliance plan until we have that answer. How this relates to the S3 sharding RFC. I think these are two halves of one model, not competing proposals. The RFCs flat-file sharding strategy is the object→shard half: for each |
- Aggregation: pivot to "no cross-object aggregation (object=piece), floor as fallback pending FilOZ" across the gate brief + RFC §7, matching the top-level review note. - v1.3.0: remove the EXTRA_DATA_MAX_SIZE batch-cap framing (cap was removed upstream); neutralize the throughput numbers to "bound by the FVM PiecesAdded event-size + gas, pending measurement"; repoint pricing to lib/PriceListUSDFC.sol; note the new USDFC fee layer; tighten "no slashing" wording; refresh moved citations. - architecture: add scope note vs the appliance release plan; fix §5 cache TTLs to production values + demote the read-after-write floor; retitle §8 to "when bodies need a sharded-dag-index" + single/multi split; fold MST credible-exit rationale into §4; clarify O(log).
| - **the delete path is real end-to-end** — the contract accepts pieces down to 32 B, but Piri's | ||
| whole-root delete (`schedulePieceDeletions`) is **wired but unsigned today** — it needs its | ||
| extraData signature finished and exercised; |
There was a problem hiding this comment.
question: Can you say more? I didn't follow this line at all.
There was a problem hiding this comment.
Yeah, sorry. This is a very subtle reference to the fact that Piris RemoveRoot method is incomplete.
| - **proving stays bounded under churn** — the monotonic `nextPieceId` ratchet must not drift proving | ||
| cost or the chain-reconciliation view-getters into trouble at high churn. |
There was a problem hiding this comment.
question: I didn't follow this either. I know what the nextPieceId is, but I'm not following why it's mentioned here, and I'm having trouble parsing "must not drift proving cost or the chain-reconciliation view-getters into trouble at high churn". Must not drift it into trouble?
There was a problem hiding this comment.
Whoops, yeah I need to clear this up. What I am trying to indicate is that some methods on the PDPVerifier, and iirc a view contract over it will iterate from N->nextPieceId (where N = 0, and sometimes > 0). And since nextPieceId only ever increases, even with deletes, as the dataset churns iteration will become prohibitivly expensive over time.
| On-chain dollar cost is negligible at **every** piece size — so this is **not** a cost optimization, | ||
| it is a **capability gap**. The two real risks are exactly the two asks: (a) the off-chain repack on | ||
| churn — which **one-object-one-piece eliminates** (no survivors to re-hash); and (b) throughput — more | ||
| pieces means a sender fleet. "Piece = object" is simultaneously the cost-optimal *and* the only |
There was a problem hiding this comment.
question: I'm not sure what that means. What's a "sender fleet"?
There was a problem hiding this comment.
another whoops 😅 this leaked in - its a separate idea in Piri I have been playing with that fell out of this whole exploration, will provide a better definition and motivation for it in doc.
Basically its the idea of multiple blockchain accounts Piri submits transactions from in parallel, so registrations aren't all stuck single-file behind one account's queue. i.e. an account's messages must apply in strictly increasing nonce order, one stuck transaction stalls every higher nonce behind it — an underpriced tx during a gas spike, a revert, a dropped message, a bad gas estimate. At millions of registrations under variable base fee, that single-file fragility is the problem. I might be overthinking this here, but seems like a real issue that could popup at high throughput.
| bytes in large objects), object lifespan by size class, multipart fraction and the actual part sizes | ||
| clients use, and the delete/overwrite/version/lifecycle mix. Pull initial shapes from object-store | ||
| workload literature (IBM COS / SNIA traces); **instrument Ingot from day one** so the floor/ceiling | ||
| become measured, not guessed. |
There was a problem hiding this comment.
question: What are our knobs to adjust in response to signals? Does instrumenting after launch still give us room to respond to observation?
There was a problem hiding this comment.
Currently we have no knobs, reaction would be via an operator re-configuring their node - say for a lower floor (if we even go that route), different aggregation threshold, re-pack strats, etc. Or via an update we ship, preference for the former.
There was a problem hiding this comment.
Gotcha. Let's still note what those "knobs" are, even if they require work from the operator or writing new code. In other words, what can we safely change after we've started, contrasted with decisions we have to make up front that will be baked into, say, data that's hard to migrate.
| - **The catalog names content; the indexer locates it.** Ingot's manifest pins body **digests** | ||
| (stable across any chain-side reshuffling); the indexer maps a digest to its current physical | ||
| location. A read joins the two. |
There was a problem hiding this comment.
suggestion: Let's reuse a noun here instead of introducing "body", to clarify what we're talking about. This is the blob digest, right?
| **Object → blobs.** A body is hashed (sha256 for content addressing, md5 for the ETag) in a single | ||
| streaming pass and written to the local store. It becomes an ordered list of content-addressed | ||
| blobs, each `≤ max_blob_size`: one blob for objects within the ceiling, a coarse split (e.g. 256 MiB | ||
| granularity, not fine chunking) for larger ones. Each blob is uploaded to Piri by digest ([§7](#7-cross-cutting-durability-concurrency-retrieval)). |
There was a problem hiding this comment.
thought: Two things:
- The stream will also encrypt, yielding a FEE envelope of the blob on disk and a region-wrapped CEK to store in the DB. Doesn't necessarily need to be explicit here, but I want to make sure that's in your head too.
- It sounds like this is writing the whole thing out to disk before moving on, but we can upload each part blob as it's complete on disk. I had been thinking of this as bounding the space Ingot needs on disk, because we can throw out blobs after they're sent to Piri, but it turns out we want to hold onto them anyway for read-after-write and as a cache, so…that's less important. 😛
| - **`min_aggregate_size`** — the deletion-granularity knob (today a hardcoded 128 MiB). A blob | ||
| **≥ min** becomes its **own** on-chain piece. A blob **< min** is folded with other small blobs | ||
| into a shared aggregate piece (built up to ~min). | ||
| - **`max_blob_size`** — the largest blob Piri accepts (currently 256 MiB, liftable). Larger objects | ||
| are split into `≤ max` blobs by the data layer. |
There was a problem hiding this comment.
question: Are these sizes in plaintext, or in FEE envelopes?
| `min` is the central lever because on-chain cost is **count-driven, not size-driven** (gas RFC). A | ||
| small `min` means most objects are their own piece, so most deletes are O(1) (below); the price is | ||
| more pieces — more `addPieces` transactions, a one-time registration tax, and an O(log) proving | ||
| ratchet under churn — all bounded, none a per-live-piece cost. The RFC's proposed knee is **8 MiB** |
| Postgres writes, and the guarded root swap — so it is small and bounded (sub-millisecond order). No | ||
| object is ever held whole in RAM. The catalog manifest is written only after its body blobs are | ||
| durable and accepted in Piri, so a crash never leaves a catalog entry pointing at non-durable data. |
There was a problem hiding this comment.
question: The entire manifest is held in RAM, right? How pathological does a bucket have to be for that to be a problem?
| The **multi-shard** case (an object split at the ceiling, or a multipart object) does need an ordering | ||
| record, and here there is a deliberate choice. Ingot's manifest carries the ordered | ||
| `{digest, offset, length}` list (Ingot-private, lean, no extra index block). The sharding RFC's | ||
| alternative is a UnixFS File root node + a sharded-dag-index `nodes` property, which preserves | ||
| **credible exit**: `guppy retrieve <root-cid>` reassembles *and decrypts* the whole object with no Ingot | ||
| in the loop (guppy can decrypt via the FEE tenant-recipient/recovery path), where the flat manifest | ||
| cannot. So the open call for multi-shard objects is **flat manifest (lean, Ingot-only) vs. UnixFS root + | ||
| index (stock-tooling plaintext recovery)** — a deliberate trade, not settled here. The compromise: | ||
| **skip the index for single-shard objects; build it only for >1 shard.** |
There was a problem hiding this comment.
question: In other words, for credible exit, multipart manifests need to exist in the space somewhere and not just in a Postgres DB?
hannahhoward
left a comment
There was a problem hiding this comment.
This is broadly excellent.
My only hard change is I'd like to have a pretty serious conversation about my proposed alternate form for doing versioning, which I think more closely mirrors the domain model for how S3 versioning is supposed to work.
Beyond that, is just amazing well thought out.
I do want to call attention to the fact that some of the most thinking goes into questions we may not need to answer immediately:
- The first version of deletion is just crypto-shredding the regional encryption key
- The absolute top level goal in R0 is standup an end to end Forge provider in FilOne. No versioning, no real deletion, no various other things.
So my main direction is is focus on "how can I deliver this incrementally to meet R0 (which is just staging and can be completely wiped), R1 (which needs to be data format complete, but need not be feature complete), R2 (where we really want everything working)
| > - **Composite key (chosen) vs. per-key version index.** Keying the MST by `(key, version)` keeps all | ||
| > versions in one sorted space and amortizes well for many-versioned keys. The alternative keys the | ||
| > MST by object key alone, with the leaf pointing at an explicit newest-first version index — which | ||
| > removes the inversion, the escaping, and the key-budget pressure, but rewrites and grows that | ||
| > index block on every new version. Revisit if hot keys accumulate very many versions. |
There was a problem hiding this comment.
I want to second this and make a concreate proposal, after honestly rabbit holing an entire day of this design and how S3 versioning is supposed to work.
Before I get to what I propose, let me cover what I learned about S3 Version:
In particular: in S3 version id is different from its ordinal order:
seq— the per-bucket monotonic ordinal (buckets.next_version_seq). Its only job is ordering (recency). Internal; never leaves the server.version_id— a string, the S3 client handle (x-amz-version-id). Its job is identity. It's opaque, and for suspended/unversioned writes it is the literal string"null".
Unfortunately this distinction is not just an information leaking concern.
For numbered versions they can coincide (version_id can just be seq rendered, information concerns not withstanding). They diverge for the null version: its version_id is frozen at "null" by S3, but it still has a real ordering (a seq).
Blame S3 for a wildly complicated concept.
invertedVersionId collapses the two — it's derived from the ordinal but handed back as the client version_id — and the null version is where that breaks: its id has to be "null", not an encoded ordinal, and there is only ever one null per key, replaced in place (verified against real S3 - I encourage you to make a bucket and mess around with turning on versioning, suspending and re-enabling multiple times etc). Keeping seq and version_id as separate fields makes that case fall out instead of needing a sentinel.
Ok now to the concrete proposal built on @Peeja 's idea:
- Top MST stays keyed by the plain object key
- Each leaf is:
node = {seq, version_id, CatalogCID}
leaf = { current: node, prev: MST<seq -> node >, null_seq?: seq }
current gives the fastest current path — GET / ListObjects read one leaf, no descent. prev is a per-key sub-tree of older versions keyed by seq; because it's scoped to one buckey key, the prev key is just the ordinal — no fancy error prone string math to produce an object-key + version composite; "order an integer" is all that's left. null_seq is to track if there is a null version id in the prev tree -- because of S3's very unique way of having exactly one "null" in the version history.
Write rule — depending on versioning state, push the displaced current into prev or discard it; and since S3 allows only one null per key, creating a new null also evicts any existing one:
retain = (versioning == Enabled) || (displaced.version_id != "null")
if retain:
prev = put(prev, displaced.seq, displaced)
if displaced.version_id == "null": null_seq = displaced.seq
else:
gc_release(displaced)
if new.version_id == "null":
if null_seq: prev = remove(prev, null_seq)
null_seq = undefined
current = new
| Mode | Displaced | retain |
|---|---|---|
| Enabled | numbered or null | push (S3 keeps a re-enabled null as noncurrent "null") |
| Suspended | numbered | push |
| Suspended | null | discard (replace the null in place) |
| Unversioned | null | discard |
prev is written only when we retain history — always for Enabled, never for Unversioned, only at the numbered→null boundary for Suspended. The lingering null is often noncurrent (it lands in prev when an Enabled write displaces it), so the leaf tracks its seq (null_seq);
Optional (to speed up lists): lift is_delete + render fields (etag/size/last_modified) into the leaf and prev entries, so ListObjects/ListObjectVersions render straight from the tree without fetching manifests, still verifiable. Left out above to keep the base shape minimal.
Cost: Enabled writes do a prev insert + top resplice each time — more blocks per write than a flat splice, but scoped to keys you're actually versioning. One thing to confirm: rebuild assumes logstore keeps per-op records / op-roots that point at created manifests, not just the latest forge_root_cid.
|
|
||
| This layer is how blobs become proven storage, and how the size knobs govern deletion. | ||
|
|
||
| **Storage.** `allocate` → `PUT` parks a blob in MinIO keyed by its digest. `accept` commits it to |
There was a problem hiding this comment.
MinIO or "storage backend" -- I do think there's a world where some people just wanna throw ZFS at this.
bajtos
left a comment
There was a problem hiding this comment.
I finished reading the version from yesterday and have a few more comments. Next, I'll read the discussion and the updated version.
|
|
||
| ## The model that fixes it: one object = one piece (no cross-object aggregation) | ||
|
|
||
| The proposal is to **stop pooling unrelated objects into shared pieces** and store each S3 object as |
There was a problem hiding this comment.
What happens if the objects are less than 8 MiB in size? Should they get padded or are they going to be aggregated with unrelated objects into a shared piece like they are aggregated today?
| blobs, each `≤ max_blob_size`: one blob for objects within the ceiling, a coarse split (e.g. 256 MiB | ||
| granularity, not fine chunking) for larger ones. Each blob is uploaded to Piri by digest ([§7](#7-cross-cutting-durability-concurrency-retrieval)). | ||
|
|
||
| **The local store (spool + cache).** Each blob is written locally before upload — both because the |
There was a problem hiding this comment.
question: Is Sprue aware if Ingot node also doubles as Piri node? Will it prioritize such node for blob slot allocation over the other nodes since there's no moving bytes across the wire?
| GET key[?versionId] (after §3 precondition checks) | ||
| MST → manifest → ordered [blobDigest, byteRange] list (catalog via the index-claim path) | ||
| per covering blob: | ||
| in local cache/store → serve locally |
There was a problem hiding this comment.
Is Ingot going to check UCAN delegations for local reads?
FWIW, Filecoin Onchain Cloud uses exactly this design (or at least used to use it back in Q1 2026): one file = one PDP piece. So PDP & FWSS was designed with the assumption that there will be one piece per file, with files as small as ~32 bytes. Of course, the question is whether FOC has ambitions to handle the storage scale FilOne is aiming for, or whether their design works for FOC only because they operate at a much smaller scale. |
Where this PR is going — a stake in the ground, not a contractThank you all — this is some of the most thorough review I've had, and the discussion has genuinely reshaped the design (the cross-object aggregation thread especially). It's also gotten big enough that the real forks are getting lost in the volume, so this comment is to set the frame for how we land it and what's actually still open. The ask: I want to merge this as a living design baseline — the agreed place to build the next increment from — not as a finalized design that merging signs everyone up to. Several of the biggest questions here are explicitly unresolved (and some can only be answered by FilOZ/FOC, not by us). Landing it means "this is the shared starting point and the open questions are written down," not "every decision is final." It should be a stake in the ground near where it needs to be; we'll move it as we learn. How to read the doc going forwardI'm tagging every major area with a decision status so it's clear what merging does and doesn't commit us to:
Status of the major areas
Path forward
The three questions for FilOZ / FOC (the gate)Object=piece maximizes piece count, so it only works if the contracts can take the volume — which we've never actually asked. (@bajtos notes FOC already does one-file = one-piece, which is our leading hypothesis.)
Routing your commentsIf I don't reply to a thread directly, it's in one of the buckets below — accepted as suggested, captured for implementation, or answered here — and I'll resolve it as I apply the change. That's a deliberate ack, not a miss; say so if you want any of them reopened for real discussion.
|
Co-authored-by: Petra Jaros <peeja@peeja.com>
…ix) (FIL-473) The reference's body AAD hardcoded the "Encrypt0" Enc_structure context for every envelope, even tag-96 — a deviation from RFC 9052 §5.3 surfaced while building these vectors and fixed upstream in foc-encryption-demo PR #2. Re-pin the vectors to that fix and match it on the Go side: - pull-foc-encryption.sh: pin REF_SHA to PR #2 head (158571a) via refs/pull/2/head instead of master (still buggy); raw fallback uses the same SHA. - helpers_test.go: composeFEE/decryptFEE now select the body AAD context by envelope structure (Encrypt for tag-96, Encrypt0 for tag-16), via the ContextEncrypt/ContextEncrypt0 constants already in fee/cose. - Regenerated multi-recipient-go (tag-96, now Encrypt context) and multi-chunk-ts (re-sealed by the fixed reference). Deterministic tag-16 Go fixtures unchanged. - doc.go / README.md: document the pin to PR #2 and the structure-driven context. Cross-implementation check passes against the fixed reference (4/4 fixtures decrypt under real foc-encryption; Go decrypts all + unwraps both recipients). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
The reference pin moved to a commit reachable from master once upstream PR #2 merged, but three doc spots still described the pre-merge state and contradicted the script: they claimed master seals tag-96 bodies under the buggy "Encrypt0" context and told the reader to re-pin later, and the regeneration section still named refs/pull/2/head. Describe the pin as what it is — a fixed commit on master carrying the merged RFC 9052 5.3 fix — and keep the caveat where it belongs: commits before that fix are not comparable with these vectors. Signed-off-by: Miroslav Bajtoš <oss@bajtos.net> Assisted-by: Claude:claude-opus-5
…ix) (FIL-473) The reference's body AAD hardcoded the "Encrypt0" Enc_structure context for every envelope, even tag-96 — a deviation from RFC 9052 §5.3 surfaced while building these vectors and fixed upstream in foc-encryption-demo PR #2. Re-pin the vectors to that fix and match it on the Go side: - pull-foc-encryption.sh: pin REF_SHA to PR #2 head (158571a) via refs/pull/2/head instead of master (still buggy); raw fallback uses the same SHA. - helpers_test.go: composeFEE/decryptFEE now select the body AAD context by envelope structure (Encrypt for tag-96, Encrypt0 for tag-16), via the ContextEncrypt/ContextEncrypt0 constants already in fee/cose. - Regenerated multi-recipient-go (tag-96, now Encrypt context) and multi-chunk-ts (re-sealed by the fixed reference). Deterministic tag-16 Go fixtures unchanged. - doc.go / README.md: document the pin to PR #2 and the structure-driven context. Cross-implementation check passes against the fixed reference (4/4 fixtures decrypt under real foc-encryption; Go decrypts all + unwraps both recipients). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
The reference pin moved to a commit reachable from master once upstream PR #2 merged, but three doc spots still described the pre-merge state and contradicted the script: they claimed master seals tag-96 bodies under the buggy "Encrypt0" context and told the reader to re-pin later, and the regeneration section still named refs/pull/2/head. Describe the pin as what it is — a fixed commit on master carrying the merged RFC 9052 5.3 fix — and keep the caveat where it belongs: commits before that fix are not comparable with these vectors. Signed-off-by: Miroslav Bajtoš <oss@bajtos.net> Assisted-by: Claude:claude-opus-5
…ix) (FIL-473) The reference's body AAD hardcoded the "Encrypt0" Enc_structure context for every envelope, even tag-96 — a deviation from RFC 9052 §5.3 surfaced while building these vectors and fixed upstream in foc-encryption-demo PR #2. Re-pin the vectors to that fix and match it on the Go side: - pull-foc-encryption.sh: pin REF_SHA to PR #2 head (158571a) via refs/pull/2/head instead of master (still buggy); raw fallback uses the same SHA. - helpers_test.go: composeFEE/decryptFEE now select the body AAD context by envelope structure (Encrypt for tag-96, Encrypt0 for tag-16), via the ContextEncrypt/ContextEncrypt0 constants already in fee/cose. - Regenerated multi-recipient-go (tag-96, now Encrypt context) and multi-chunk-ts (re-sealed by the fixed reference). Deterministic tag-16 Go fixtures unchanged. - doc.go / README.md: document the pin to PR #2 and the structure-driven context. Cross-implementation check passes against the fixed reference (4/4 fixtures decrypt under real foc-encryption; Go decrypts all + unwraps both recipients). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
The reference pin moved to a commit reachable from master once upstream PR #2 merged, but three doc spots still described the pre-merge state and contradicted the script: they claimed master seals tag-96 bodies under the buggy "Encrypt0" context and told the reader to re-pin later, and the regeneration section still named refs/pull/2/head. Describe the pin as what it is — a fixed commit on master carrying the merged RFC 9052 5.3 fix — and keep the caveat where it belongs: commits before that fix are not comparable with these vectors. Signed-off-by: Miroslav Bajtoš <oss@bajtos.net> Assisted-by: Claude:claude-opus-5
…ix) (FIL-473) The reference's body AAD hardcoded the "Encrypt0" Enc_structure context for every envelope, even tag-96 — a deviation from RFC 9052 §5.3 surfaced while building these vectors and fixed upstream in foc-encryption-demo PR #2. Re-pin the vectors to that fix and match it on the Go side: - pull-foc-encryption.sh: pin REF_SHA to PR #2 head (158571a) via refs/pull/2/head instead of master (still buggy); raw fallback uses the same SHA. - helpers_test.go: composeFEE/decryptFEE now select the body AAD context by envelope structure (Encrypt for tag-96, Encrypt0 for tag-16), via the ContextEncrypt/ContextEncrypt0 constants already in fee/cose. - Regenerated multi-recipient-go (tag-96, now Encrypt context) and multi-chunk-ts (re-sealed by the fixed reference). Deterministic tag-16 Go fixtures unchanged. - doc.go / README.md: document the pin to PR #2 and the structure-driven context. Cross-implementation check passes against the fixed reference (4/4 fixtures decrypt under real foc-encryption; Go decrypts all + unwraps both recipients). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HB9xeMxQFD67u61tgyoKHS
The reference pin moved to a commit reachable from master once upstream PR #2 merged, but three doc spots still described the pre-merge state and contradicted the script: they claimed master seals tag-96 bodies under the buggy "Encrypt0" context and told the reader to re-pin later, and the regeneration section still named refs/pull/2/head. Describe the pin as what it is — a fixed commit on master carrying the merged RFC 9052 5.3 fix — and keep the caveat where it belongs: commits before that fix are not comparable with these vectors. Signed-off-by: Miroslav Bajtoš <oss@bajtos.net> Assisted-by: Claude:claude-opus-5
What this is
The design doc set for the Ingot S3-over-Forge redesign, bundled as one PR so the architecture
and the analysis that backs it are reviewed together. No code changes — these four docs are the
shared understanding we need before building.
Start here
Suggested reading order
aggregation-gate.md— the one-page framing. Thesis: none of the S3 design runs on Forge until Piri stops size-pooling unrelated objects into ~128 MiB pieces. Read this first; it tells you why the other two docs exist and what the two asks of the rest of the stack are.architecture.md— the target architecture, layer by layer (S3 → catalog → data → Forge/chain → cross-cutting), then a system contract, topology, gaps, and a Postgres schema appendix. This is the artifact most of your review time should go to.rfc-pdp-minimum-piece-size.md— open to the Decision summary (top). That page is the whole argument; §1–§10 are the supporting analysis and the Appendices (behind the "Evidence" banner) are the raw gas audit — skim unless you're checking the numbers.pdp-cost-calculator.html— play with piece size / churn / base fee / sender count to pressure-test the economics yourself.Where your input is most wanted
The docs call these out explicitly; these are the decisions we want ratified, not just read:
aggregation-gate.md,architecture.md§6,rfc§7): the proposal is no cross-object aggregation — store each object as its own piece(s) — with the size floor (min_aggregate_size, ~8 MiB) demoted to the fallback if the contracts can't take that piece volume. The open question for FilOZ / FOC: can the PDP contracts/proving take object=piece at PiB scale? (See the top-level note "Should Piri do cross-object aggregation at all?")architecture.md§3 callout): composite-key vs per-key version index; andversionIdas a direct locator (scan-free, but leaks version ordering) vs opaque. We picked the direct locator — weigh the trade-off.aggregation-gate.md): (1) rethink Piri's aggregation — target no cross-object aggregation (object=piece), with a generational background aggregator as the relief valve and the size floor as the fallback; (2) prove small pieces are operationally viable in the PDP contracts at PiB scale. Do you agree this is the gate, and the sequencing?Correctness — what was verified against source
Every gas/contract claim in the RFC was checked against the actual contracts (by Claude!)
(
PDPVerifier.solandFilecoinWarmStorageService.sol, re-verified against FWSS v1.3.0 / PDPVerifier v3.4.0 after an upstream contract update) and the calibnetgas-benchmark fixtures. The load-bearing (dammit claude) findings held: no proof-slashing exists (the new 0.1 FIL cleanup deposit is refundable, not a penalty), add cost is size-independent (~120,700 gas), every on-chain hot path is bounded (no O(live pieces) loop), and the calibnet anchors matched exactly. Corrections that came out of the audit and are already baked in:
addPiecesbatch cap was removed upstream (FWSS v1.3.0 / PDPVerifier v3.4.0).EXTRA_DATA_MAX_SIZE/MAX_ADD_PIECES_EXTRA_DATA_SIZEare gone; batch size is now bound by the FVMPiecesAddedevent-size + per-tx gas. So the registration-throughput numbers (pieces/tx, txns/PiB, sender fleet) are an open measurement, not fixed — the earlier ~13-pieces/tx / ~11M-txn / ~12-sender figures are withdrawn pending re-measurement. (Good news for "many small pieces": the contract wall that would have throttled them is gone.)lib/PriceListUSDFC.sol); v1.3.0 also adds a per-dataset USDFC fee layer. Doesn't move the margin conclusion.nextPieceIdnow:800, standalone-submitjobqueue.go:205, challenge-window 60 mainnet / 20 calibnet / 10 devnet, etc.).Scope / non-goals
allocate-by-sizesecurity model, and the batch allocate/accept wire format are named as known gaps, not solved here (architecture.md§11).piri,filecoin-services/FilOzone) by design, since the gas model is derived from them.