Skip to content

feat: deferred-accept multipart (FIL-520) + network blob removal on DeleteObject (FIL-588) - #40

Open
frrist wants to merge 21 commits into
mainfrom
frrist/fil-588-blob-remove
Open

feat: deferred-accept multipart (FIL-520) + network blob removal on DeleteObject (FIL-588)#40
frrist wants to merge 21 commits into
mainfrom
frrist/fil-588-blob-remove

Conversation

@frrist

@frrist frrist commented Jul 17, 2026

Copy link
Copy Markdown
Member

Two features riding the blob-removal chain, aligned with the blob-removal RFC (fil-one/RFC#13) and merged onto main's Hilt architecture:

  • FIL-520 — multipart, completed: ListParts, ListMultipartUploads, S3 validation, abort/expiry hygiene; parts park on piri at UploadPart (allocated + PUT, not accepted), Complete concludes them into acceptance, Abort unwinds them via /blob/abort/blob/reject. A node's BlobAccepted refusal is treated as final (a concurrent session in the space accepted the same content — the reference index owns the blob), not a retryable fault.
  • FIL-588 — DeleteObject releases network blobs: the last reference to a blob triggers /blob/remove (space-subject, {digest} args) through the upload service, which forwards /blob/release to the nodes; bytes are released by piri's async removal sweep.

Merged with main's Hilt refactor (per-bucket spaces, request-scoped proofs, fil-forge/versitygw fork): the deferred seams (UploadBlobParked/ConcludeBlob/AbortBlob) and RemoveBlob take the bucket's space; park/upload capture the request's per-key proof store as the space's write authority, and abort/remove resolve request-first with that captured store as the off-request fallback (the session-expiry sweeper). seedSpaceDelegations stays deleted — space authority is Hilt-issued per access key.

Open dependencies

Landing order

fil-forge/libforge#49 (client-verb args + named errors) → fil-forge/piri#30 (/blob/release + /blob/reject) → fil-forge/sprue#33 + fil-forge/smelt#19 (rebased) + the hilt blob.Abort grant → this. Re-pin libforge/smelt to merge commits as they land.

Validated: full unit suite, the S3 conformance partition (219 pass / 0 fail locally), gofmt. The end-to-end gates — TestForgeDeleteReleasesNetworkBlob (delete-finality through piri's removal sweep) and TestForgeDeferredMultipart (park/conclude round-trip + abort→reject) — now run on the stock smelt-SDK images like every other itest, and both pass locally against branch-built piri#30 and hilt#36 images (the full chain: ingot → sprue#33 → piri /blob/release+/blob/reject, delegations from smelt#19). They go green in CI once piri#30 and hilt#36 publish to :main; until then INGOT_ITEST_PIRI_IMAGE / INGOT_ITEST_HILT_IMAGE cover local runs.

🤖 Generated with Claude Code

frrist and others added 10 commits July 17, 2026 12:57
…S3 validation, abort/expiry hygiene

Completes the S3 multipart surface on the spool model (parts spool
locally at UploadPart; ship+accept at Complete — the R1 architecture
decision recorded on FIL-520; network unallocate-on-abort stays with the
parking refinement, FIL-589/623/624).

New surface:
- ListParts: pagination (part-number-marker / max-parts, default+cap
  1000), NoSuchUpload for unknown/mismatched/closed uploads, per-part
  LastModified (new multipart_parts.created_at).
- ListMultipartUploads: (key, initiated) ordering, prefix/delimiter with
  CommonPrefixes, key/upload-id marker semantics incl. the
  InvalidUploadIdMarker validation, in-flight sessions only, session
  checksum algorithm/type echo.

Conformance fixes:
- UploadPart against a mismatched key -> NoSuchUpload (was silently
  accepted — a real bug: any valid uploadId accepted parts for any key).
- Complete: part-number range check (InvalidArgCompleteMpPartNumber)
  before order check; EntityTooSmall for sub-5MiB non-final parts;
  x-amz-mp-object-size validation; conditional writes per S3 (If-Match /
  If-None-Match:* honored, concrete If-None-Match -> NotImplemented);
  duplicate Complete is idempotent — sessions are retained in a new
  'completed' state instead of deleted.
- CreateMultipartUpload: trailing-slash keys -> DirectoryObjectContainsData;
  content-encoding/disposition/language, cache-control, expires, and
  website-redirect-location persist through the session into the
  manifest (manifest gains WebsiteRedirectLocation; PutObject/Head/Get
  wire it too).

Hygiene ('remove uploaded parts on abort/expiry', spool-model edition):
- Abort and part re-upload delete the parts' now-unreferenced spooled
  blobs + intents, guarded against content-addressed sharing (other
  sessions via new CountPartRefs, committed objects via blob_refs
  claims, shipped blobs via intent state).
- Abort honors If-Match-Initiated-Time.
- Background sweeper (multipart_session_ttl, default 7d; negative
  disables) aborts stale open sessions and reaps terminal rows.

Registry: multipart_sessions gains header columns + completed state
(migration 00004); MultipartStore gains ListSessions /
ListStaleSessions / CountPartRefs (postgres + inmem; live-test
coverage for ordering, cutoff, bytea[] ANY-match, and the widened
CHECK constraint).

itest: 28 cases promoted xfail->pass (217 pass / 76 known-fail+skips,
was 189/104). TestForgeScenarios gains MultipartAbortCleansSpool and
uses >=5MiB non-final parts per the new EntityTooSmall enforcement.
Remaining multipart xfail is FIL-620 (checksums), FIL-534/525
(tagging/lock/ACL), FIL-586 (UploadPartCopy).

Refs: FIL-520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github.com/fil-forge/libforge aac837a — Space on RemoveArguments plus
the /blob/abort binding the deferred-multipart abort path invokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(FIL-588)

forgeclient gains BlobRemove (subject = space, digest in args); the
uploader's RemoveBlob no-op becomes the real call, so the reference-index
bookkeeping (claims→0) now releases the space's claim through sprue → piri.
Delete finality is claim-release-now, bytes-at-root-death: piri deletes
unaggregated bytes immediately and defers aggregated ones until the PDP
root retires on-chain.

itest: TestForgeDeleteReleasesNetworkBlob is the regression gate — PUT,
prove the blob serves from piri (spool wipe + read-through), DeleteObject,
then assert piri executed /blob/remove and queued the piece for removal.
Gated behind ITEST_PIRI_BIN/ITEST_SPRUE_BIN until published piri/sprue
images carry the handlers.

docs: architecture.md §2/§9 status rows flip remove(digest) to exists;
deferred-glue note updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The e2e flushed out two gaps: the agent's seeded space delegations did not
cover /blob/remove, so the removal invocation shipped proofless and sprue's
validator rejected it; and releaseBlobs discards errors by design, which
made that failure invisible — RemoveBlob now logs its own errors. The
seeding sentinel moves to /blob/remove so stores seeded by older builds
re-seed the new cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The delete-finality e2e needs smelt's piri registration proofs to include
blob/remove (fil-forge/smelt frrist/fil-588-blob-remove-v2). Drop this
commit and repin once that smelt PR merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ete, unallocate on abort (§7.2)

UploadPart now uploads each part blob to its provider before returning 200:
forgeclient.BlobAddParked runs /blob/add + the HTTP PUT but postpones the
/http/put conclude — the conclude is what makes sprue fire /blob/accept, so
the bytes are durable but stay out of the PDP pipeline. The park state
(add/accept task links + the sealed put invocation) persists in the new
blob_parks table (migration 00005). CompleteMultipartUpload concludes each
parked blob (BlobConclude: send the put receipt, await the accept receipt's
location commitment); blobs that never parked fall back to the whole
synchronous upload. Abort, the TTL sweeper, and part-supersede unwind parked
blobs with /blob/unallocate (cause = the add task link), behind the existing
live-part/CountPartRefs/CountClaims guards — an upload now ends in exactly
one of accept or unallocate.

BlobAdd is unchanged behavior: it is BlobAddParked + BlobConclude composed.
The harness NopUploader accepts immediately, so the in-process flow is
unchanged. Architecture §2/§7.2/§9 rows flipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TestForgeDeferredMultipart (env-gated like the delete-finality test):
RoundTrip proves parts are durable-but-parked at UploadPart (piri allocates,
no accept), concluded at Complete (accepts land, GET round-trips);
AbortUnallocates proves abort unwinds a parked blob end-to-end via
/blob/unallocate. Plus a park round-trip in the postgres live test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Piri's byte release is now fully asynchronous (defer-by-default RemovePiece
+ PDPRemoveSweep periodic task): the sweep re-verifies claims and pipeline
state before deleting. Follow the renamed 'queueing piece removal' log line
and additionally wait for 'finalized piece removal' — proving end-to-end
that the sweep actually released the bytes, not merely queued the request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The abandon verb split per hop in review: clients (ingot) invoke
/blob/abort on the upload service, which forwards /blob/reject to the
storage node. BlobUnallocate → BlobAbort (Cause now required by type),
uploader iface UnallocateBlob → AbortBlob, seeded space delegation +
itest log-greps renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Curio-based piri requires a Postgres piri node (harmonydb); the
gated delete-finality and deferred-multipart tests now request one and
honor ITEST_BLOCKCHAIN_IMAGE until the published localdev image carries
the mockrpc Ticket fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI has no sibling smelt checkout; pin the pushed branch commit
(fil-forge/smelt#19, 2718317) instead. Bump to the merge commit when
that PR lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist requested a review from alanshaw July 21, 2026 17:19
…usals

The blob-removal RFC drops the space from the client-leg arguments —
/blob/remove is {digest} and /blob/abort is {digest, cause}, with the
space as the invocation subject. Ingot already invoked with the space as
subject, so only the argument literals change.

Treat the node's BlobAccepted refusal on abort as final rather than a
fault: it means this space accepted the same content concurrently, so
the blob belongs to the reference index and is released via
/blob/remove when its last claim drops. The uploader logs it distinctly
instead of as an error; the multipart cleanup path already discards the
park row, which is obsolete either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist marked this pull request as ready for review July 23, 2026 22:03
…-remove

Ports the deferred-park multipart and blob-removal flow onto main's Hilt
architecture. The load-bearing resolutions:

- Space is per-bucket and threaded as a parameter (main's model): the
  DeferredBodyUploader seams (UploadBlobParked/ConcludeBlob/AbortBlob),
  BlobRemover, and the s3frontend park/conclude/cleanup paths all take
  the bucket's space, resolved via the new Backend.bucketSpace helper
  (the session-expiry sweeper resolves it per session).
- Proof chains are request-scoped (main's model): park and upload
  require the IAM-attached reqscope store and capture it as the space's
  write authority; abort and remove resolve reqscope-first with the
  captured store as the off-request fallback (the sweeper path).
  forgeclient.BlobRemove/BlobAbort accept WithProofStore like BlobAdd.
- forgeclient.BlobAddParked/BlobConclude re-derived from main's BlobAdd:
  per-call proof store, no /blob/accept re-delegation (sprue owns
  accept), conclude carries no space proof.
- seedSpaceDelegations and standaloneApp stay deleted (main): space
  authority is Hilt-issued per access key. NOTE: hilt's s3perm map does
  not yet delegate /blob/abort — multipart abort needs a hilt follow-up.
- MultipartSessionTTL ported into config.ServerConfig; the sweeper keeps
  running under main's config package.
- Fork-era s3err constants: ErrInvalidPartNumberRange for the Complete
  part-number range check, ErrInvalidRequest for invalid list markers.
- versity partition: kept the fil-520 placements (ListParts implemented,
  Complete validation fixed) and moved main's two XFail cases
  (etag_quoting_consistency, missing_part_fields) into the passing
  lists; forge itests provision via hiltProvisionTenant.
- go.mod: libforge at the RFC-alignment pin, smelt at main's Hilt-era
  pin — the smelt fil-588 branch (/blob/release delegation) must rebase
  onto smelt main before the removal itests can run end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist frrist self-assigned this Jul 24, 2026
frrist added a commit to fil-forge/libforge that referenced this pull request Jul 27, 2026
Splits the provider leg out of `/blob/remove` into a new `/blob/release`
binding, per the blob-removal RFC
([fil-one/RFC#13](fil-one/RFC#13), merged):
`/blob/remove` and `/blob/abort` are now space-subject client verbs
whose arguments no longer carry the space (it is the invocation
subject), while `/blob/release` carries `{space, digest}` under the
provider subject, matching `/blob/allocate` and `/blob/accept`. Cause
references say `/blob/add` throughout — the `/space` command prefix died
with the UCAN 1.0 transition.

Defines the RFC's named errors where every service can share them:
`BlobAccepted` (reject refused because **the invoking space** accepted
the blob — the guard is space-scoped, not digest-scoped, so another
tenant's acceptance never strands a parked allocation) and
`MissingCause` (abort's cause is missing or doesn't resolve to a known
`/blob/add` task).

## Landing order

**This PR is the root of the removal chain** — fil-forge/piri#30,
fil-forge/sprue#33, fil-forge/smelt#19, and fil-forge/ingot#40 all pin
this branch's head (`3e5e6ba`) and re-pin to the merge commit once this
lands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
frrist added a commit to fil-forge/sprue that referenced this pull request Jul 27, 2026
…(FIL-522) (#33)

Serves the upload-service half of blob removal, aligned with the
blob-removal RFC
([fil-one/RFC#13](fil-one/RFC#13)):

- `/blob/remove` (subject = the space, args `{digest}`) — releases a
space's claim on an accepted blob: recovers every provider holding it
(primary via the registration's receipt chain, plus non-failed
replicas), forwards a provider-rooted **`/blob/release`** `{space,
digest}` to each (best-effort), and deregisters last so the receipt
chain survives for a retry if every forward fails.
- `/blob/abort` (subject = the space, args `{digest, cause}`) — abandons
a parked (never-accepted) blob: the provider is recovered from the
cause's receipt chain and the abort is forwarded as `/blob/reject`. A
cause that doesn't resolve to a known `/blob/add` task fails with the
named error `MissingCause`; a node refusing because the space accepted
the blob has its `BlobAccepted` re-surfaced in the abort receipt, so
clients can distinguish "use `/blob/remove`" from a retryable fault. No
local state mutates, so aborts are safely retryable.
- `/upload/remove` (subject = the space, args `{root}`) — deletes the
upload's root→shards index entry only. Shard blobs are the client's
per-digest `/blob/remove` decision (content addressing shares shards
between uploads).

All handlers are idempotent (unknown/already-removed → success). Named
errors come from libforge, shared with piri.

## Landing order

fil-forge/libforge#49 (`/blob/release` binding + named errors; pinned at
its branch head, re-pin on merge) → fil-forge/piri#30 (piri serves
`/blob/release` + `/blob/reject`) → **this** — the forwards here invoke
piri capabilities that only exist on #30.

Same chain: fil-forge/smelt#19 (devnet delegations), fil-forge/ingot#40
(S3 surface). Validated end-to-end by ingot's delete-finality and
deferred-multipart itests against piri #30 + this branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
frrist and others added 4 commits July 28, 2026 13:44
…588-blob-remove

Union merges throughout (parks/multipart seams alongside the CORS config
and the InclusionStore); adopt main's locationFromAdded rename at the
PR's call sites; re-pin libforge to the fil-forge/libforge#49 merge
commit (v0.0.0-20260727220215-5e299c46f62f), which supersedes both
parents' pins now that #49 has merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
goose refuses duplicate version numbers, and this branch carried
00004/00005 twice (its multipart_listing/deferred_accept beside main's
bucket_created_at_space/segment_bucket) since the Hilt merge — breaking
forge-mode startup. Renumber the unreleased branch migrations to
00007/00008 so a fresh database applies main's sequence first. Verified
with TestUp_Live against a throwaway Postgres (migrates to version 8,
second Up is a no-op).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blob-removal chain landing across the stack (this branch's
DeleteObject → /blob/remove, sprue#33's forwarding, piri's release
sweep) unblocked bucket-delete teardown, flipping every
"teardown-blocked" xfail row green: promote all 50 (PutObject 5,
GetObject 17, HeadObject 14, DeleteObject 1, CopyObject 13) to the
pass tables. Demote four multipart cases that genuinely fail — the
upstream error-code mismatches (ListMultipartUploads
invalid_uploadId_marker, CompleteMultipartUpload invalid_part_number),
missing-ETag validation on Complete (missing_part_fields), and
conditional writes on Complete (conditional_writes) — with reasons in
the tables. Validated with a full local TestForgeVersity run against
fresh :main images: 216 pass, 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght uploads on DeleteBucket

Three upstream conformance cases return to the pass tables with
behavior fixes instead of xfail rows:
- CompleteMultipartUpload: a part entry missing PartNumber or ETag is
  MalformedXML, and a part number below 1 is InvalidArgument
  (PartNumber), mirroring versitygw's posix backend; the ETag match is
  now mandatory and the >10000 special case folds into the membership
  check (InvalidPart).
- ListMultipartUploads: upload-id-marker validation mirrors upstream's
  MultipartUploadLister — the marker must be a valid UUID naming an
  upload of the first key group at/after the key marker (else
  InvalidArgument, upload-id-marker), and listing resumes past it.

DeleteBucket now implicitly aborts the bucket's open multipart sessions
(upstream's teardown never aborts them and expects the delete to
succeed), releasing their parked part blobs before the hilt space
delete. CompleteMultipartUpload_conditional_writes stays xfail: its
conditional matrix passes, but the implicit abort's /blob/abort goes
out proofless — hilt's per-operation grants carry blob.Abort for the
S3 Abort operation only. Promote once hilt's s3perm map grants
blob.Abort on bucket delete.

Validated: full local TestForgeVersity, 219 pass / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ucket abort, and spool eviction

The local-table Locator read tier is wired and validated
(TestForgeReadAfterEviction; #44 for retention-retired catalog blocks),
so the "not wired" bullet inverts: the remaining gap is spool eviction
itself — unbounded today, DeleteObject releases network-side only —
tracked in #48 alongside §5's bounded-cache spec. Record DeleteBucket's
implicit abort of in-flight multipart sessions (and its hilt blob.Abort
gate), point §11's cache-eviction open question at #48, and note the
forge paths are now exercised by the in-repo smelt itest harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop ci-error.log

- The delete-finality and deferred-multipart gates run on the stock
  smelt-SDK images like every other itest: env gate and binary
  injection dropped. They need piri:main ≥ piri#30 and a hilt with
  hilt#36's blob.Abort grant; until those publish, the standard
  INGOT_ITEST_PIRI_IMAGE / (new) INGOT_ITEST_HILT_IMAGE overrides
  cover local runs. Validated end-to-end with branch-built piri+hilt
  images: both gates green, AbortRejects 0.12s.
- Pin smelt at the smelt#19 branch head — its generated stack proofs
  carry the /blob/release + /blob/reject node delegations the gates
  exercise; re-pin on merge.
- Complete's 5 MiB minimum-part floor now reads backend.MinPartSize:
  it is S3's protocol constant, not an operator knob.
- Log the previously discarded errors: the post-commit latch to
  'completed', and DeletePark / spool.Remove / DeleteIntent in
  cleanupPartBlobs.
- Restore the captured-store mask in abortOpenSession with the
  corrected rationale: s3:DeleteBucket delegates no blob commands, so
  DeleteBucket's implicit abort must run on the authority captured at
  UploadPart (blob.Abort rides the write set per hilt#36); fix the
  xfail and architecture-doc comments that misattributed the grant.
- Remove the stray ci-error.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Do we need to defer accept? By not accepting the Piri operator is storing data and not getting paid. There's also an attack vector here where users upload many multipart parts and the server runs out of space...they have no incentive to not do this as they will not be charged until they complete/abort. This is at odds with the AWS S3 implementation which charges you for storage of multipart parts until they are aborted or the session expires (which IIRC by default it does not).

Comment thread forgeclient/blobadd.go Outdated
AddTask cid.Cid
// AcceptTask is the /blob/accept task link BlobConclude polls.
AcceptTask cid.Cid
// PutInvocation is the sealed /http/put invocation. Its metadata embeds

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.

sealed?

Suggested change
// PutInvocation is the sealed /http/put invocation. Its metadata embeds
// PutInvocation is the issued /http/put invocation. Its metadata embeds

Comment thread forgeclient/blobadd.go Outdated
// not just the client's token store.
// - No /blob/accept re-delegation: sprue owns accept (as it owns
// allocate), so the conclude/put-receipt dance carries no space proof.
// - BlobAdd decomposes into BlobAddParked + BlobConclude so multipart can

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 not a big fan of the park metaphor. It was kinda relevant//cute/fun when we were uploading CAR files but I'm not sure this holds now.

Could we instead just add a option to BlobAdd e.g. WithConclude(false). We add the fields from ParkedBlob to AddedBlob and let Location be nil when you don't conclude?

Then we just expose BlobConclude to allow later conclusions...

-- content-addressed dedup shares a park across sessions and parts.
CREATE TABLE ingot.blob_parks (
digest bytea PRIMARY KEY,
add_task bytea NOT NULL, -- /space/blob/add task CID (unallocate cause)

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.

The /space prefix was dropped when we transitioned to UCAN 1.0.

Suggested change
add_task bytea NOT NULL, -- /space/blob/add task CID (unallocate cause)
add_task bytea NOT NULL, -- /blob/add task CID (unallocate cause)

digest bytea PRIMARY KEY,
add_task bytea NOT NULL, -- /space/blob/add task CID (unallocate cause)
accept_task bytea NOT NULL, -- /blob/accept task CID (conclude poll target)
put_invocation bytea NOT NULL, -- sealed /http/put invocation

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.

UCANs are not sealed.

Suggested change
put_invocation bytea NOT NULL, -- sealed /http/put invocation
put_invocation bytea NOT NULL, -- issued /http/put invocation

-- metadata embeds the derived signer keys; rows are deleted promptly at
-- conclude/unallocate. Keyed globally by digest, like upload_intents:
-- content-addressed dedup shares a park across sessions and parts.
CREATE TABLE ingot.blob_parks (

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.

Not sure there's any getting away from this...park in ingrained. What about multipart_blobs or multipart_added_blobs?

Comment thread registry/stores.go
// whose metadata carries the derived signer keys needed to conclude —
// sensitive, deleted at conclude/abort. Keyed globally by Digest (like
// upload_intents: content-addressed dedup shares parks across sessions).
type BlobPark struct {

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
type BlobPark struct {
type MultipartAddedBlob struct {

or

Suggested change
type BlobPark struct {
type MultipartBlob struct {

Comment thread inmem/store.go
intents map[string]registry.UploadIntent // keyed by string(digest)
locations map[locKey]registry.BlobLocation // keyed by (space, digest)
inclusions map[locKey]registry.BlobInclusion // keyed by (space, digest)
parks map[string]registry.BlobPark // keyed by string(digest)

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 think "parks" is too obtuse.

Suggested change
parks map[string]registry.BlobPark // keyed by string(digest)
blobAdds map[string]registry.MultipartAddedBlob // keyed by string(digest)

Comment thread uploader/blob.go Outdated
// accepted bytes for this content (dedup). Like UploadBlob it runs on the
// request ctx and requires the request-scoped proof store, which it captures
// for the off-request abort path (the session-expiry sweeper).
func (u *Forge) UploadBlobParked(ctx context.Context, space did.DID, digest multihash.Multihash, size int64, localPath string) (*ParkedBlobState, *BlobLocation, error) {

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.

Again, maybe just an option for no-conclude to existing UploadBlob?

Comment thread registry/stores.go

// ParkStore persists deferred-accept park state between UploadPart and
// Complete/Abort (§7.2).
type ParkStore interface {

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
type ParkStore interface {
type MultipartBlobStore interface {

I mean, you get the idea right?

// must invoke blob capabilities from such a request (DeleteBucket's implicit
// abort of in-flight multipart uploads) hides the request store so the
// uploader falls back to the space authority captured at UploadPart.
func WithoutProofStore(ctx context.Context) context.Context {

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.

Clever!

@frrist

frrist commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Do we need to defer accept?

It's a real architectural fork, and the preliminary sim results say defer. An aborted/expired part is a small, hours-lived blob — the worst churn class in the pdp-sim study (FIL_ONE_FINDINGS.md). Accepted at UploadPart, each one costs AddPieces gas on entry, fil.one-paid proving while it sits in an aggregate, and a remove → compaction rewrite (write-amp double-proving) on exit — costs no storage fee covers. Parked, the same garbage is disk-hours, reclaimed off-chain at zero gas. It's the entry-side mirror of fil-forge/piri#46: a bounded off-chain window at each end of the blob lifecycle (delete SLA there, session TTL/Expires here) so only data that completes into live objects does on-chain work.

By not accepting the Piri operator is storing data and not getting paid.

True. The SP holds parked bytes unpaid until Complete accepts them and pdp pipeline runs — but the exposure is infra-only disk-hours, bounded by the session TTL / allocation expiry below, and per the sim that bounded window is cheaper for every actor than optimistically pushing each part through the pipeline.

There's also an attack vector here ... they have no incentive to not do this ...

Only if billing keys on accepted bytes — and it doesn't have to. Every allocation transits sprue (/blob/add carries space + digest + size; conclude and /blob/abort mark the exits), and sprue is where billing decisions traditionally have lived. Bill open allocations — you pay for reserved space from UploadPart until complete/abort/expiry — and the incentive gap closes at exact AWS parity. Two pieces are missing, both sprue-side and neither a protocol change:

  1. materialization — sprue registers blobs only at accept (blob_registry); parked allocations exist only in the agent_index token log, which isn't space-queryable. An allocations table (open → accepted/aborted/expired) fixes that.
  2. termination — billing needs a guaranteed exit, and complete/abort are best-effort (that's AWS's forever-billing footgun). The allocation already carries the deadline: ratify Address.Expires as the reservation lifetime and every party enforces it unilaterally — piri GCs at Expires, sprue closes the billing row at Expires, ingot sweeps sessions before it (invariant: session TTL ≤ Expires, so Complete never lands on GC'd parts). Until then, sprue can already bound SP exposure at the /blob/add choke point: per-space open-allocation quotas + delinquency gating. Note accepting at UploadPart would make this attack worse, not better — it upgrades a bounded, GC-able disk-space attack into on-chain gas + proving spend disproportionate to any storage fee the attacker would owe.

This is at odds with the AWS S3 implementation which charges you for storage of
multipart parts until they are aborted or the session expires (which IIRC by
default it does not).

Depends on the billing choice above — with allocation billing it's parity or stricter: your AWS recollection is right (parts bill forever unless an opt-in lifecycle rule aborts them), whereas here every reservation has a default terminal: the ingot sweep (7d default, configurable) with Expires as the protocol backstop.


Assumptions I'm operating on, flagged so they can be attacked individually:

  1. Billing design is a product decision, not mine. "Bill open allocations" above is a claim that sprue can (the state transits it), not that we will. Whether clients pay for reservations, and from when, needs a product owner — same open question I flagged on the piri thread.
  2. The sim is preliminary and unverified Its churn/lifetime means are assumed, so I trust the relative ranking — defer beats accept-early on churny parts — more than any absolute number. And the deferral benefit scales with the multipart abort/expiry rate, which we have no production data for: completed sessions cost the same on-chain either way, just later. If real abort rates are ~0, this is mostly moot and the attack economics are the whole argument.
  3. Expires as reservation lifetime is my reading, not ratified. Today it's minted as the PUT-address deadline and probably sized like one (minutes, not days). Treating it as the allocation's billing/GC deadline needs an RFC blessing and value co-design.

If any of these fall, the conclusion moves — genuinely open questions, not rhetorical
ones.

@hannahhoward

Copy link
Copy Markdown
Contributor

so let me break down this as I see it:

  1. From a product perspective, you should pay for multipart uploads parts as soon as you request the space. Alan is right there's an attack vector and I don't want to get burned by that.
  2. IMHO what @frrist proposes is somewhat reasonable -- as soon as you reserve space on our network, you should pay for it cause it's space not going to someone else. Which would correspond with blob/add+/blob/allocate.
  3. We've traditionally waited for /ucan/conclude + /blob/accept, because we wanted to make sure you actually uploaded before you got charged. I think that made more sense in the storacha case where most of the clients were random JS clients that were on less stable internet connections and who knows what could happen, and more importantly there was no explict way to say "CANCEL!". Now, with the new /blob/abort + /blob/reject flow in implement blob/release and blob/reject; migrate jobqueue into harmony task engine piri#30 plus the fact that most of the non-multipart /blob/add flows are actually a local file not even moving over a WAN, I think doing it on allocate actually maybe makes sense? Even in a non-ingot context with WAN transfers it would make sense in a worse case scenario to call /blob/abort if your upload failed.
  4. We still do need to have some kind of cleanup mechanism for multipart -- we could either implement the whole AWS configurable expiration, or we could just set a reasonable default.
  5. We still ought to think about the provider getting paid case and whether they should really defer blob/accept. Taking the 50TB upload case, this seems like a situation where finishing the upload could be days not hours, and I wonder if it's in their interest to add the parts tp a dataset... not sure the math on this. Either way, having a defined window on multipart, perhaps based on size, seems important to me -- if we're going to have them allocate 50TB, they can't allocate it for that long without getting paid, so perhaps we just have a default "max days" for multipart and use it to cleanup ingot and piri.

@hannahhoward

Copy link
Copy Markdown
Contributor

also @frrist I personally don't like the "park" metaphor -- I feel like this is both a CAR thing and Curio thing and left to my own devices I would use a different name. But I'm not going to force this.

@alanshaw

Copy link
Copy Markdown
Member

I just think that if you're going to go through the trouble of creating a multipart request, you're likely going to complete it eventually. Multipart is for data that is bigger than 5GB. I just don't think this is the kind of thing that churns much because there's a big time investment in uploading it. i.e. I'm betting that users don't often decide to stop uploading 3TB of data half way through...

One thing to bear in mind is that a typical multipart upload will be filling aggregates entirely so an abort is not root removals + re-aggregations, it's just root removals.

Finally, (as Hannah pointed out) if you're uploading a huge dataset, I think it's unacceptable (no pun intended) to be using space on a storage node and not paying for it for days on end.

@alanshaw

alanshaw commented Jul 30, 2026

Copy link
Copy Markdown
Member

I have since learned that most official S3 clients (albeit high level transfer clients) will switch to multipart requests for any data that is over 8MB in size (although varies from 5MB to 16MB). With that in mind it seems likely that we'll see more allocations without accepts (for uploads that are canceled) than I had originally thought and the existing direction and proposal to make the change to charge on allocation seems perfectly reasonable.

… add/upload split

Fold the PR #40 API-shape review (r3674608706, r3674935223): the deferred
accept is an option on the one entry point, not a second entry point.

- forgeclient: ParkedBlob's fields (AddTask, AcceptTask, PutInvocation)
  fold into AddedBlob; Location == nil marks an unconcluded add. BlobAdd
  takes WithConclude (default true); BlobAddParked becomes the private
  durable half. BlobConclude takes the AddedBlob, no-ops when already
  concluded, and drops the spent PutInvocation from its result.
- uploader: UploadBlob gains UploadOption and returns UploadedBlob
  (nil-able *BlobLocation + the pending-accept state, populated only
  while parked); DeferredBodyUploader embeds BodyUploader and keeps
  ConcludeBlob/AbortBlob. Callers guard the "concluding upload always
  returns a location" contract explicitly instead of deref-panicking on
  a misbehaving impl.
- s3frontend: parkBlobs passes WithConclude(false); the dedup-vs-parked
  branch is a documented Location nil-check.

The park vocabulary stays in the registry layer (blob_parks, BlobPark,
ParkStore) and docs — it is the blob-removal RFC's lifecycle term for the
allocated-but-unaccepted state; only the redundant API split is gone.

Validated: unit suite; full itest partition green against piri#30 head
(fil-forge/piri@b5208a3) + hilt#36 images.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist added a commit to fil-forge/piri that referenced this pull request Jul 30, 2026
… task engine (#30)

Adds the removal half of the blob lifecycle and moves the aggregation
pipeline onto the harmony scheduler. Aligned with the blob-removal RFC
([fil-one/RFC#13](fil-one/RFC#13)).

**Protocol**: `/blob/release` — the claim-release inverse of
`/blob/allocate`, invoked by the upload service translating a client
`/blob/remove` — drops a space's claim on an accepted blob (claim +
acceptance + allocation deleted; bytes released once no space holds an
allocation or acceptance). `/blob/reject` retires a parked blob — the
"don't accept" exit of allocate→accept|reject. Its `BlobAccepted` guard
is **scoped to the invoking space** per the RFC: only that space's own
acceptance refuses the reject; another tenant's acceptance of the same
content-addressed bytes just drops this space's allocation and retains
the bytes (multi-tenant liveness). Both named errors come from libforge,
shared with sprue. The registration delegation grants `/blob/release` +
`/blob/reject` to the upload service. Physical deletion is always
deferred to a sweep that re-verifies claims before every destructive
step.

**Pipeline**: the commp/aggregator/manager jobqueues (and their separate
aggregator_db) are replaced by harmonytask tasks — `PDPCommP`,
`PDPAggregate`, `PDPAddRoots`, `PDPRemoveSweep` — with all state in
harmonydb (`pdp_blob_pipeline`, `pdp_root_submissions`). Stage
transitions are transactional with the tables the removal machinery
inspects, which closes the accepted-blob removal races by construction:
pre-commp rows cancel transactionally, the fold locks its candidates (a
row is either cancelled or folded, never both), staged pieces ride the
root lifecycle, and roots retire on-chain (`schedulePieceDeletions`)
only when every live sub-piece is pending removal.

**Validated**: full suite including a harmonydb-backed (Postgres
testcontainer) regression harness for the removal races — including the
cross-space reject case the RFC mandates; smelt e2e with the injected
binary — upload/retrieve plus the full proving loop through the new
pipeline, proof verified by the PDPVerifier contract.

**Landing order**: fil-forge/libforge#49 (the `/blob/release` binding +
shared named errors; pinned at its branch head, re-pin on merge) →
**this**. Downstream: fil-forge/sprue#33 (upload-service handlers,
forwards `/blob/release`) + fil-forge/smelt#19 (devnet delegations),
then fil-forge/ingot#40 (S3 surface).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: ash <alan138@gmail.com>
hilt#35 (which superseded hilt#36's bare blob.Abort grant) makes the
multipart operations first-class in the authorizer: DELETE ?uploadId now
classifies as s3:AbortMultipartUpload instead of falling through to
s3:DeleteObject. The harness's hiltAllPermissions list predates that, so
once hilt:main published, every client-issued S3 abort was denied at
request authorization — the whole AbortMultipartUpload versity group and
TestForgeDeferredMultipart/AbortRejects failed CI with 403 AccessDenied.

Add s3:AbortMultipartUpload plus the two multipart List permissions,
keeping the list's contract ("every S3 permission hilt recognizes").

Also refresh the conditional_writes xfail rationale: with blob.Abort now
granted its failure moved — the conditional matrix passes and teardown
fails with BucketNotEmpty on DeleteBucket, which needs its own diagnosis
before the row can promote. Validated against the published piri/hilt
:main images (post piri#30/hilt#35): deferred gate + all versity
multipart groups pass; conditional_writes still xfails (no unexpected
pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants