Skip to content

rfc: write removal spec - #13

Merged
frrist merged 2 commits into
mainfrom
frrist/spec/remove-reject-abort
Jul 23, 2026
Merged

rfc: write removal spec#13
frrist merged 2 commits into
mainfrom
frrist/spec/remove-reject-abort

Conversation

@frrist

@frrist frrist commented Jul 20, 2026

Copy link
Copy Markdown
Member

📖 Preview

Summary

This RFC proposes a set of UCAN capabilities for releasing storage in the Forge network: /blob/remove (agent → upload service) releases a space's claim on an accepted blob and is translated into /blob/release on the storage node; /blob/abort//blob/reject unwind an in-flight upload that was never accepted (including allocations whose bytes never arrived); and /upload/remove deletes an upload's index entry. References are counted per (digest, space) — so removal is idempotent, safe under content-addressed deduplication across tenants, and physical deletion happens only once no space references the bytes and their on-chain proof obligations are retired.

@frrist frrist self-assigned this Jul 20, 2026
@frrist
frrist requested a review from a team July 20, 2026 22:43
Comment thread rfcs/2026-07-forge-blob-removal.md Outdated

### Blob lifecycle

A blob upload proceeds through `/space/blob/add` (agent → upload service), which drives `/blob/allocate` (upload service → storage node) and an HTTP PUT of the bytes to the node. At this point the blob is **parked**: allocated and uploaded, but not committed. A parked blob has exactly two mutually exclusive exits:

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.

/space prefix has been dropped since the transition to UCAN 1.0.

Suggested change
A blob upload proceeds through `/space/blob/add` (agent → upload service), which drives `/blob/allocate` (upload service → storage node) and an HTTP PUT of the bytes to the node. At this point the blob is **parked**: allocated and uploaded, but not committed. A parked blob has exactly two mutually exclusive exits:
A blob upload proceeds through `/blob/add` (agent → upload service), which drives `/blob/allocate` (upload service → storage node) and an HTTP PUT of the bytes to the node. At this point the blob is **parked**: allocated and uploaded, but not committed. A parked blob has exactly two mutually exclusive exits:

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.

Fixed — dropped the /space prefix here and everywhere else it appeared.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated
A blob upload proceeds through `/space/blob/add` (agent → upload service), which drives `/blob/allocate` (upload service → storage node) and an HTTP PUT of the bytes to the node. At this point the blob is **parked**: allocated and uploaded, but not committed. A parked blob has exactly two mutually exclusive exits:

- `/blob/accept` — commit. The node enqueues the blob for PDP aggregation, mints an `/assert/location` claim, and the upload service registers the blob against the space. The blob is now **accepted**, and the only way out is the claim-release path of `/blob/remove`.
- `/blob/reject` — drop. The node releases the allocation and deletes the bytes as if the upload never happened.

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.

Worth mentioning that this also covers the allocated but not uploaded case? e.g. network conditions meant that the client was unable to upload to the target node (but they were able to talk to the upload service).

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 catch — broadened the definition of parked to cover allocated-but-never-received, and called the case out explicitly in the /blob/abort and /blob/reject sections.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated
- `/blob/accept` — commit. The node enqueues the blob for PDP aggregation, mints an `/assert/location` claim, and the upload service registers the blob against the space. The blob is now **accepted**, and the only way out is the claim-release path of `/blob/remove`.
- `/blob/reject` — drop. The node releases the allocation and deletes the bytes as if the upload never happened.

Removal is therefore split along the same line as the lifecycle: `/blob/remove` operates on accepted blobs, `/blob/abort`/`/blob/reject` operate on parked ones. The boundary is **per space**: a storage node MUST refuse to reject a blob that the invoking space has accepted (see [`/blob/reject`](#blobreject)), so the two paths cannot race each other into an inconsistent state. Another space's acceptance of the same digest is irrelevant to this guard — each space exits its own lifecycle independently, and shared bytes are protected by claim counting, not by the reject guard.

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.

/blob/abort//blob/reject operate on parked ones.

...and "unparked" ones - allocated but not received.

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.

Done — "parked" now spans allocation→acceptance whether or not the bytes arrived, and this sentence now explicitly includes allocations whose bytes were never received.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated
- `/blob/accept` — commit. The node enqueues the blob for PDP aggregation, mints an `/assert/location` claim, and the upload service registers the blob against the space. The blob is now **accepted**, and the only way out is the claim-release path of `/blob/remove`.
- `/blob/reject` — drop. The node releases the allocation and deletes the bytes as if the upload never happened.

Removal is therefore split along the same line as the lifecycle: `/blob/remove` operates on accepted blobs, `/blob/abort`/`/blob/reject` operate on parked ones. The boundary is **per space**: a storage node MUST refuse to reject a blob that the invoking space has accepted (see [`/blob/reject`](#blobreject)), so the two paths cannot race each other into an inconsistent state. Another space's acceptance of the same digest is irrelevant to this guard — each space exits its own lifecycle independently, and shared bytes are protected by claim counting, not by the reject guard.

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.

claim counting

Reference counting?

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.

Renamed to reference counting throughout.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated

**Provider-rooted (upload service → storage node).** The UCAN subject is the **provider DID**. At registration time the storage node delegates `/blob/remove` and `/blob/reject` to the upload service — the same registration delegation that already carries `/blob/allocate` and `/blob/accept`. The upload service invokes under this delegation, and the space travels in the invocation *arguments*, not the subject.

The same command string (`/blob/remove`) is used on both legs with different subjects. `/blob/abort` exists only on the space-rooted leg; the upload service *translates* it into a provider-rooted `/blob/reject`.

@alanshaw alanshaw Jul 22, 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.

Hmm, IDK how can we define this same command in libforge with 2 different argument/return types? What if one needs to accept different args/return values from the other in the future?

I would pick a different command name for the invocation from Sprue to Piri.

In this case I'd suggest /blob/release, like the Objective-C release keyword - which reduces reference count to a particular object (which is essentially what we're doing) or /blob/free, in the sense of malloc/free from C.

Either of these above options seem like reasonable antonyms to /blob/allocate to me.

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 — went with /blob/release. It is now its own capability (subject = provider, space in args) and /blob/remove is client-facing only; the delegation-regimes section now describes the remove→release / abort→reject translation and why distinct names keep the legs free to evolve independently.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated
type AbortArguments struct {
space String # DID of the space abandoning the upload
digest Bytes # multihash of the blob
cause Link # the /space/blob/add task link

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
cause Link # the /space/blob/add task link
cause Link # the /blob/add task link

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.

Applied.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated

```ipldsch
type AbortArguments struct {
space String # DID of the space abandoning the upload

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 is the subject in this invocation no?

Suggested change
space String # DID of the space abandoning the upload

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.

Removed — space now appears as an argument only on the provider-rooted leg (/blob/release, /blob/reject).

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated

```go
type AbortArguments struct {
Space did.DID `cborgen:"space"`

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 is the subject.

Suggested change
Space did.DID `cborgen:"space"`

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.

Removed.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated
"sub": "did:key:space",
"cmd": "/blob/abort",
"args": {
"space": "did:key:space",

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.

Unnecessary - the space is the subject.

Suggested change
"space": "did:key:space",

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.

Removed.

Comment thread rfcs/2026-07-forge-blob-removal.md Outdated

```ipldsch
type RemoveArguments struct {
space String # DID of the space releasing its 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.

So, per my other comment, the invocation from agent to sprue would have space as the subject so not needed in the arguments and the /blob/release invocation would have the space in the arguments like this.

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.

Yep, done exactly that — dropped from the space-rooted arguments, kept in the /blob/release (and /blob/reject) arguments.

- Split the Sprue→Piri leg of /blob/remove into its own /blob/release
  capability so the two legs' argument/result types can evolve independently
- Drop the space argument from space-rooted invocations (/blob/remove,
  /blob/abort) — the space is the UCAN subject there; it remains an explicit
  argument on the provider-rooted leg (/blob/release, /blob/reject)
- Broaden 'parked' to cover allocated-but-never-received blobs, and call the
  case out in the abort/reject paths
- Rename claim counting to reference counting
- /space/blob/add → /blob/add (prefix dropped in the UCAN 1.0 transition)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist merged commit e28b0c2 into main Jul 23, 2026
2 checks passed
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 added a commit to fil-forge/smelt that referenced this pull request Jul 28, 2026
…pload

Mirrors piri's registration delegation list for the blob-removal chain
(fil-one/RFC#13): each node delegates /blob/release (the provider-rooted
claim release sprue forwards when translating a client /blob/remove) and
/blob/reject (retires a parked, never-accepted blob — the node half of a
client /blob/abort) to the upload service, alongside allocate/accept.
libforge pinned to the fil-forge/libforge#49 merge commit for the
bindings.

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

2 participants