feat: deferred-accept multipart (FIL-520) + network blob removal on DeleteObject (FIL-588) - #40
feat: deferred-accept multipart (FIL-520) + network blob removal on DeleteObject (FIL-588)#40frrist wants to merge 21 commits into
Conversation
…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>
…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>
…-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>
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>
…(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)
…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
left a comment
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
sealed?
| // PutInvocation is the sealed /http/put invocation. Its metadata embeds | |
| // PutInvocation is the issued /http/put invocation. Its metadata embeds |
| // 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
The /space prefix was dropped when we transitioned to UCAN 1.0.
| 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 |
There was a problem hiding this comment.
UCANs are not sealed.
| 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 ( |
There was a problem hiding this comment.
Not sure there's any getting away from this...park in ingrained. What about multipart_blobs or multipart_added_blobs?
| // 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 { |
There was a problem hiding this comment.
| type BlobPark struct { | |
| type MultipartAddedBlob struct { |
or
| type BlobPark struct { | |
| type MultipartBlob struct { |
| 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) |
There was a problem hiding this comment.
I think "parks" is too obtuse.
| parks map[string]registry.BlobPark // keyed by string(digest) | |
| blobAdds map[string]registry.MultipartAddedBlob // keyed by string(digest) |
| // 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) { |
There was a problem hiding this comment.
Again, maybe just an option for no-conclude to existing UploadBlob?
|
|
||
| // ParkStore persists deferred-accept park state between UploadPart and | ||
| // Complete/Abort (§7.2). | ||
| type ParkStore interface { |
There was a problem hiding this comment.
| 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 { |
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/
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.
Only if billing keys on accepted bytes — and it doesn't have to. Every allocation transits sprue (
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 Assumptions I'm operating on, flagged so they can be attacked individually:
If any of these fall, the conclusion moves — genuinely open questions, not rhetorical |
|
so let me break down this as I see it:
|
|
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. |
|
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. |
|
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>
… 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>
Two features riding the blob-removal chain, aligned with the blob-removal RFC (fil-one/RFC#13) and merged onto main's Hilt architecture:
/blob/abort→/blob/reject. A node'sBlobAcceptedrefusal is treated as final (a concurrent session in the space accepted the same content — the reference index owns the blob), not a retryable fault./blob/remove(space-subject,{digest}args) through the upload service, which forwards/blob/releaseto 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/versitygwfork): the deferred seams (UploadBlobParked/ConcludeBlob/AbortBlob) andRemoveBlobtake 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).seedSpaceDelegationsstays deleted — space authority is Hilt-issued per access key.Open dependencies
s3perm.godelegates noblob.Abortat all today, so every/blob/abort(client Abort, the session-expiry sweeper, DeleteBucket's implicit abort) goes out proofless and parked part blobs leak on abort. feat(s3perm): grant blob.Abort with the write command set hilt#36 addsblob.Abortto the write command set;CompleteMultipartUpload_conditional_writesand theAbortRejectsgate promote once a hilt image with it publishes./blob/releasenode delegation) sits on a pre-Hilt base and must rebase onto smelt main; this branch pins main's Hilt-era smelt until then, so the two forge itests below can't pass end-to-end yet.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 hiltblob.Abortgrant → 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) andTestForgeDeferredMultipart(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 thenINGOT_ITEST_PIRI_IMAGE/INGOT_ITEST_HILT_IMAGEcover local runs.🤖 Generated with Claude Code