Skip to content

docs: write architecture document - #2

Merged
frrist merged 5 commits into
mainfrom
frrist/docs/architecture
Jun 23, 2026
Merged

docs: write architecture document#2
frrist merged 5 commits into
mainfrom
frrist/docs/architecture

Conversation

@frrist

@frrist frrist commented Jun 17, 2026

Copy link
Copy Markdown
Member

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.

docs/
├── aggregation-gate.md            ← start here (5 min): why this is gated on Piri
├── architecture.md                ← the target design (the main artifact)
├── rfc-pdp-minimum-piece-size.md  ← the PDP gas/economics + Piri policy that the size knobs rest on
└── pdp-cost-calculator.html       ← interactive model behind the RFC (open in a browser)

Start here

Suggested reading order

  1. 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.
  2. 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.
  3. 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.
  4. 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 model (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?")
  • Versioned key encoding (architecture.md §3 callout): composite-key vs per-key version index; and versionId as a direct locator (scan-free, but leaks version ordering) vs opaque. We picked the direct locator — weigh the trade-off.
  • The two asks (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.sol and FilecoinWarmStorageService.sol, re-verified against FWSS v1.3.0 / PDPVerifier v3.4.0 after an upstream contract update) and the calibnet
gas-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:

  • ⚠️ The addPieces batch cap was removed upstream (FWSS v1.3.0 / PDPVerifier v3.4.0). EXTRA_DATA_MAX_SIZE / MAX_ADD_PIECES_EXTRA_DATA_SIZE are gone; batch size is now bound by the FVM PiecesAdded event-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.)
  • Storage price is 2.5 USDFC/TiB/mo (not 0.9), now an immutable constant (lib/PriceListUSDFC.sol); v1.3.0 also adds a per-dataset USDFC fee layer. Doesn't move the margin conclusion.
  • Citation fixes (nextPieceId now :800, standalone-submit jobqueue.go:205, challenge-window 60 mainnet / 20 calibnet / 10 devnet, etc.).

Scope / non-goals

  • Docs only — no implementation in this PR. The architecture explicitly supersedes the bootstrap MVP currently in the tree.
  • Catalog GC, the allocate-by-size security model, and the batch allocate/accept wire format are named as known gaps, not solved here (architecture.md §11).
  • Some RFC code references point at sibling repos (piri, filecoin-services/FilOzone) by design, since the gas model is derived from them.

frrist added 2 commits June 17, 2026 12:59
…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
@frrist frrist self-assigned this Jun 18, 2026

@bajtos bajtos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md Outdated
Comment thread docs/aggregation-gate.md Outdated
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@frrist frrist Jun 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md
Comment thread docs/aggregation-gate.md

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering how you chose 8 MiB as the new floor, rather than 1, 2, 4, 16, or 32 MiB?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Thank you for the explanation; now the choice makes sense to me!

Comment thread docs/architecture.md
Comment thread docs/architecture.md Outdated
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md Outdated
Comment thread docs/architecture.md
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@frrist frrist Jun 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bajtos bajtos Jun 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 alanshaw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md Outdated
- **`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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $$$.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
    1. blob_refs is 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
    2. 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.

@bajtos bajtos Jun 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md Outdated
~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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. "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).
  2. 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.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

  1. Flag the current proofset as "gc_pending".
  2. Create a new proofset that will be used for new uploads from now on.
  3. Wait until all TXs adding pieces to the gc_pending proofset are settled
  4. Perform generational aggregation for pieces in the gc_pending proofset
    1. Create a new proofset to hold the aggregated roots
    2. Iterate over all roots in the processed proofset
      • Large root - move to the new proofset as-is
      • Aggregate small roots into bigger ones
    3. 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.

Comment thread docs/architecture.md
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.

@alanshaw alanshaw Jun 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Assume all blobs are stored at a specific Piri node URL (configurable probably).
  2. 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.

Comment thread docs/architecture.md
└ body
├ size 629_145_600 (600 MiB total)
├ sha256 <whole-body digest> (integrity)
└ blobs[] ── ordered, contiguous, covers [0, size) ──┐

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/architecture.md
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, we still need to index the MST blocks...

Comment thread docs/architecture.md Outdated

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread docs/architecture.md
- 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mints a fresh per-space location claim.
issues a fresh per-space location commitment.

Comment thread docs/architecture.md Outdated
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@frrist

frrist commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

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:

  • Deletion is ~O(1) per object, always — delete the object's piece(s), no survivors to re-hash, no compaction, no SLA window to chase.
  • The garbage-vs-fabricated-piece question splits cleanly: garbage (our own dead bytes from deletes) is honest-operator GC; fabricated pieces are adversarial and can't be caught by Ingot's blob_refs (sha256↔CommP isn't verifiable — it's a UCAN attestation and the SP runs Ingot, so it can't audit itself), so that one anchors on FilOne-side accounting (Sprue's central accept log) + payment leverage. Still an open problem, but a tractable, correctly-anchored one.
  • Per-tenant attribution gets cleaner, and the entire floor / "Regime B" compaction machinery in this doc just disappears.

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:

  1. Is there a practical ceiling on the number of pieces in a single proof set? Where does it bite first — registration throughput (the addPieces batch is no longer contract-capped as of FWSS v1.3.0 — it's now bound by the FVM PiecesAdded event-size + per-tx gas, so what's the measured pieces/tx?), the monotonic nextPieceId proving-depth ratchet, the MAX_ENQUEUED_REMOVALS=2000/period delete cap, or the view-getter scans?
  2. How many proof sets should a node maintain? Today one Piri node = one data set, for operational simplicity — but every data set is a proof the node has to produce each period, so N sets = N proofs/day and it shifts the node's SLA, proving burden, and most importantly cost via extra gas. Is a cap on pieces-per-set + sharding across multiple sets the right move (it would help delete throughput)? If so, what's the right number?
  3. What's the registration-throughput ceiling now that you've removed the extraData cap? As of FWSS v1.3.0 / PDPVerifier v3.4.0 the addPieces byte cap (EXTRA_DATA_MAX_SIZE / MAX_ADD_PIECES_EXTRA_DATA_SIZE) is gone — the limit is now the FVM PiecesAdded event-size + per-tx gas. A measured pieces/tx (and whether that event-size limit is worth lifting at the protocol level) is what we need to size the sender fleet for "many small pieces."

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:

  • If FilOZ confirms the contracts can take it → we drop cross-object aggregation; the floor and compaction in this doc go away; Alan's generational background-aggregator becomes an optional relief valve for root-count/throughput (it only ever aggregates old, cold pieces unlikely to be deleted, so it rarely triggers a repack); and Miro's multi-dataset sharding is how we scale it.
  • If FilOZ says there's a hard practical limit → we keep the size floor exactly as this doc outlines, as the documented fallback.

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 PutObject/UploadPart it shards data at 256 MB into ordered, content-addressed blobs (I think we can tune this limit as needed). This thesis is the shard→piece half: each of those shards, being its own object-owned blob, becomes its own on-chain PDP piece. The join is a single number — the RFCs 256 MB shard threshold, this thesis's blob ceiling, and Piri's MaxMemtreeSize = 256<<20 (pkg/pdp/proof/proof.go:114) are the same constant for the same reason (the commP memtree is built in RAM, for now, we will be improving this via code from Curio implementation). So under no-aggregation, shard == blob == piece, 1:1, and per-object deletion is per-shard == per-piece == O(1). The thesis is therefore the Shard RFC plus one line: each shard is its own piece. And because retrieval addresses bytes by digest, the aggregation decision is invisible to its read path entirely — the only thing the two halves must negotiate is the multi-shard ordering record (see the §8 / arch:467 thread).

- 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).
Comment thread docs/aggregation-gate.md
Comment on lines +70 to +72
- **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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Can you say more? I didn't follow this line at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sorry. This is a very subtle reference to the fact that Piris RemoveRoot method is incomplete.

Comment thread docs/aggregation-gate.md
Comment on lines +73 to +74
- **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I'm not sure what that means. What's a "sender fleet"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/aggregation-gate.md
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What are our knobs to adjust in response to signals? Does instrumenting after launch still give us room to respond to observation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/architecture.md
Comment on lines +36 to +38
- **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Let's reuse a noun here instead of introducing "body", to clarify what we're talking about. This is the blob digest, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 yes the blob digest.

Comment thread docs/architecture.md
Comment on lines +243 to +246
**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)).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 😛

Comment thread docs/architecture.md
Comment on lines +306 to +310
- **`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Are these sizes in plaintext, or in FEE envelopes?

Comment thread docs/architecture.md Outdated
`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**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: …🦵❓

Comment thread docs/architecture.md
Comment on lines +388 to +390
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: The entire manifest is held in RAM, right? How pathological does a bucket have to be for that to be a problem?

Comment thread docs/architecture.md
Comment on lines +488 to +496
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.**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: In other words, for credible exit, multipart manifests need to exist in the space somewhere and not just in a Postgres DB?

@hannahhoward hannahhoward left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread docs/architecture.md
Comment on lines +133 to +137
> - **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Top MST stays keyed by the plain object key
  2. 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.

Comment thread docs/architecture.md

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MinIO or "storage backend" -- I do think there's a world where some people just wanna throw ZFS at this.

@bajtos bajtos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I finished reading the version from yesterday and have a few more comments. Next, I'll read the discussion and the updated version.

Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/architecture.md
Comment thread docs/aggregation-gate.md
Comment thread docs/aggregation-gate.md

## 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread docs/aggregation-gate.md Outdated
Comment thread docs/architecture.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread docs/architecture.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Ingot going to check UCAN delegations for local reads?

@bajtos

bajtos commented Jun 19, 2026

Copy link
Copy Markdown
Member

The proposal (the simple world). Drop cross-object aggregation. Store each S3 object as its own content-addressed blob.
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.

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.

@frrist

frrist commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Where this PR is going — a stake in the ground, not a contract

Thank 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 forward

I'm tagging every major area with a decision status so it's clear what merging does and doesn't commit us to:

  • Decided — settled; build on it.
  • Provisional (default) — we're building on this assumption; it's reversible and the fallback is named.
  • Open — explicitly not settled; the comment notes who owns the answer and what unblocks it.

Status of the major areas

Area Status Who owns / what unblocks
Cross-object aggregation → object = piece Provisional (default) Open-pending FilOZ/FOC (3 questions below) + a scaled proof-of-capability. We build assuming object=piece; the size floor is the documented fallback, not the default.
Size floor / Regime-B compaction Fallback only Activates only if FilOZ says the contracts can't take the piece volume. Stays specified for that reason.
Versioning data model (§3) Open — design call @hannahhoward + @Peeja — nested-MST vs composite-key, seq vs version_id. hannah's one hard change-request; a thread can't settle it.
Indexer: keep (target) vs drop (R0/R1) Open — design call @hannahhoward + @alanshaw — reconcile with the appliance plan.
Milestone scoping (R0 / R1 / R2) To be added to the doc Me — map deletion=cryptoshred@R0, versioning@R2, instrumentation-tuning@R1, etc. (hannah's incremental-delivery ask).
Deletion / GDPR compliance story Open — needs a subsection Coordinate with RFC #9 owners — cryptoshred + the FEE two-copy recoverability problem + the 24h window.
Read cache, instrumentation timing, Ingot-in-Piri packaging Provisional Minor/reversible; tuning is R1-gated.
S3 layer, catalog/data plane split, retrieval addressing, write/multipart/latch Decided (stable) The core of the doc; this is what merging actually establishes.
Deduplication Decided Low value once encryption is on (fresh per-object CEK); machinery kept for re-PUT of identical objects.
Postgres schema (Appendix C) Non-binding pseudocode Illustrative only; names/types/constraints get designed at implementation. Schema-detail comments captured for then, not blocking now.

Path forward

  1. Unblock the critical path (now, in parallel). Send FilOZ/FOC the three aggregation questions; start the scaled register→prove→delete proof-of-capability and finish Piri's schedulePieceDeletions signature (wired but unsigned today). This is the cheapest way to find out if object=piece is dead on arrival — everything downstream waits on it.
  2. Two design calls. (a) Versioning model with hannah + Peeja → update §3. (b) Indexer + R0/R1/R2 scoping with hannah + alan → map the doc to milestones.
  3. Write the deletion/GDPR-compliance subsection, coordinated with RFC Architecture migration (Phases 1–6): object-aligned blobs, sync durability, dedup/reference-counted delete, S3 correctness + multipart #9.
  4. Apply the cleanup threads (schema naming, ETag rationale, "shards" naming, NUL-encoding edge case, clarity edits) as non-binding revisions.
  5. Land as the baseline; remaining opens become tracked issues.

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.)

  1. Is there a practical ceiling on pieces per proof set, and where does it bite first (registration throughput, the monotonic nextPieceId proving-depth ratchet, the MAX_ENQUEUED_REMOVALS=2000/period delete cap, or view-getter scans)?
  2. How many proof sets per node should we run — is a pieces-per-set cap + sharding across sets the right scaling move, and at what number?
  3. What's the registration-throughput ceiling now that FWSS v1.3.0 removed the extraData cap (limit is now the FVM PiecesAdded event-size + per-tx gas) — measured pieces/tx?

Routing your comments

If 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.

  • Design forks (versioning, indexer/scope) → being taken to the two calls above; please hold those threads for there.
  • Clarifications (the O(log) terms, ETag, "blob digest" naming, data-layer ownership, credible-exit definition) → folding into the next revision; thank you.
  • Cleanups / schema details → applying as non-binding; the schema appendix stays pseudocode until we design it for real.
  • @alanshaw on RFC repo: I'd like to keep these in ingot for now as a living baseline (they move fast and are ingot-specific), and promote the aggregation decision to the RFC repo once FilOZ answers and it's a cross-team decision rather than an open question. Will do that as a follow-up.
  • @hannahhoward on CHANGES_REQUESTED: both your items are on the path — versioning gets its own call, and I'll add the R0/R1/R2 incremental-delivery mapping to the doc. Flag if you'd rather those land before merge or as immediate follow-ups.

Co-authored-by: Petra Jaros <peeja@peeja.com>
@frrist
frrist merged commit ec50539 into main Jun 23, 2026
4 checks passed
Peeja pushed a commit that referenced this pull request Jul 2, 2026
…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
bajtos added a commit that referenced this pull request Jul 30, 2026
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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
…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
alanshaw pushed a commit that referenced this pull request Jul 31, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants