Skip to content

feat: clients can find proof chains as servers do#85

Merged
volmedo merged 7 commits into
mainfrom
vic/feat/prune-proofs
Mar 17, 2026
Merged

feat: clients can find proof chains as servers do#85
volmedo merged 7 commits into
mainfrom
vic/feat/prune-proofs

Conversation

@volmedo

@volmedo volmedo commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Ref. storacha/guppy#378

This PR adds validator.PruneProofs and validator.SelectProofs. They are for clients what validator.Access and validator.Claim are for servers:

  • SelectProofs walks a successful Claim authorization tree and returns the minimal flat set of delegation.Delegations needed to prove the claimed capability, including any ucan/attest delegations used along the way.
  • PruneProofs is the client-side counterpart to Access: given a draft delegation carrying the full candidate proof pool, returns only the delegation.Proofs actually needed to form a valid chain. Each returned proof is re-exported into a fresh blockstore (via Export()) so it carries only its own chain blocks rather than the entire inherited pool.

These functions enable a client to confirm the proof chain is correct before sending it to the server and also reduce the amount of data sent. The latter is especially interesting when these delegations need to travel in HTTP headers.

Implementation details

Changes to existing functions have the goal of surfacing ucan/attest delegations as part of the chain. The current implementation checks them (VerifySession) but doesn't add them to the returned Authorization object. This works because servers only use Access to confirm the chain is valid, but rarely use the actual proofs in the chain. The client, however, needs all the relevant proofs in the chain if it wants an invocation to succeed.

To do so, Validate and VerifyAuthorization now return the ucan/attest authorization used when verifying a non-did:key issuer. This is threaded through ResolveSources, ResolveMatch, Authorize, and Claim, and exposed via a new Attestations() []Authorization[any] method on the Authorization interface. Authorize uses an attestationsFor helper to filter attestations down to only those whose nb.proof link matches delegations that match.

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

Urgh, I cannot wait for us to get to UCAN 1.0.

Proofs() []Authorization[Caveats]
// Attestations returns ucan/attest delegations that were used to authorize
// non-did:key issuers (e.g. did:mailto accounts) in this authorization.
Attestations() []Authorization[any]

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.

Why not?

Suggested change
Attestations() []Authorization[any]
Attestations() []Authorization[Caveats]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it's not Caveats because these are ucan/attest delegations, as opposed to the other ones, which refer to the claimed capability.

Comment thread validator/lib.go Outdated
// delegation being built by a client to send over a size-constrained channel
// (e.g. an HTTP header).
//
// dlg must be built with the full candidate proof pool — all proof blocks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a little odd - why would you go through the process of creating and signing a delegation that will subsequently be thrown away?

I'd perhaps frame this as pruning proofs from a delegation whose proof set may contain additional delegations that are not necessary to prove the delegation is valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I changed the wording in the comment in an attempt to clarify and cover what I explained in my comment below, let me know if that works better.

Comment thread validator/lib.go Outdated
walk(attest)
}
for _, prf := range a.Proofs() {
walk(prf)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will hoist all proofs in the chain and they will end up listed as proofs of the root delegation, even if they do not directly prove it.

i.e. this shouldn't be necessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ah, yeah, good catch, thanks!

Comment thread validator/lib.go Outdated
// Note: the capability in vctx must match the capability in dlg, or
// PruneProofs will return [Unauthorized]. If dlg contains multiple
// capabilities, only the chain for the first matching one is discovered.
func PruneProofs[Caveats any](ctx context.Context, dlg delegation.Delegation, vctx ValidationContext[Caveats]) ([]delegation.Proof, Unauthorized) {

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 it's okay for this to exist, but ideally you want to create a delegation with only the proofs that are necessary. If you have the ability to prune, then you have the ability to select the minimal set of proofs for your delegation in the first place.

This is perhaps useful when you are given a random delegation and want to send it somewhere for storage, and you want to minimise the amount of data stored/transferred. In the case where you use said delegation (in an invocation) then you can select the relevant proofs and you wouldn't use this function...right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, I admit is a bit awkward. The thing is that principal alignment is checked per "level". If there is a root delegation, Claim will attempt to find a valid chain that aligns with the issuer of the delegation. However, if given a list of delegations at the same level, the first one that is self-issued (such as one from the space to the account) would work as a valid chain.

This is really an artefact of me trying to re-use Claim, and Claim being tailored towards verifying proof chains in the server, where everything emanates from a single invocation. In the client, we need a way to tell Claim what's the intended principal we want to build a chain for. Encapsulating that in a delegation is what allows re-using Claim as is. We could also modify it to get that target principal, but I'd rather touching that code as little as possible. Besides, Claim is called recursively in a bunch of places where an additional target principal doesn't really make sense.

I do see building delegations from scratch as a main use case of this function. You can also prune random delegations, but you'll only be able to properly sign the pruned version if you were the original issuer. Alternatively, you can use the output of PruneProofs to filter blocks from the delegation's blockstore without actually rebuilding it. It will still link to proofs whose blocks are not attached anymore, but that shouldn't break anything, I think.

Maybe having SelectProofs take a single delegation instead of a list would make this more clear. I used a list to mirror the Access+Claims pattern, but it might just be confusing.

Comment thread validator/lib.go Outdated

// Re-export the delegation into a fresh blockstore so it only carries
// the blocks from its own proof chain, not the full candidate pool that
// was inherited from the draft's blockstore.

@alanshaw alanshaw Feb 25, 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.

Right, but Export() doesn't prune proofs of proofs. i.e. what if the proof has a bunch of candidates?

...I guess you can't really do this since you can't re-sign a proof....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, if they contain unnecessary proofs themselves there's not much we can do

@codecov

codecov Bot commented Mar 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.55556% with 56 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/delegation/delegate.go 0.00% 26 Missing and 1 partial ⚠️
validator/proof_pruning.go 50.94% 22 Missing and 4 partials ⚠️
validator/lib.go 92.50% 3 Missing ⚠️
Files with missing lines Coverage Δ
validator/authorization.go 87.50% <100.00%> (+37.50%) ⬆️
validator/lib.go 87.14% <92.50%> (+0.94%) ⬆️
validator/proof_pruning.go 50.94% <50.94%> (ø)
core/delegation/delegate.go 21.68% <0.00%> (-10.46%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

I think this can merge after #86 merges in here.

Offer proof chain optimization (proof pruning) as an option when
creating delegations.

Removes the need of callers having to roll their own logic to implement
the `draft delegation -> proof pruning -> final delegation` pattern and
encapsulates it in a single place so that it's easier to refactor in the
future.

The `ProofChainOptimizer` type is required to prevent an import cycle
issue, because the `validator` package imports `delegation`, so we
cannot use `validator` directly here. Callers will need to call
`validator.NewProofChainOptimizer` themselves and pass the result in the
option. Not the most straightforward API, but it's a good trade-off. An
alternative would be to extract the shared types into a third package,
but that would require a larger refactor (we might consider doing it in
the future).
@volmedo volmedo merged commit cfa6420 into main Mar 17, 2026
2 checks passed
@volmedo volmedo deleted the vic/feat/prune-proofs branch March 17, 2026 08:59
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