Delegation
Delegation is how authority is passed down a chain in TinyCloud: a delegator grants a capability to a delegatee, and the delegatee may in turn re-grant a narrowed subset to someone else. The chain's root is a SIWE→CACAO (ReCap) grant signed by a space's owner DID; every child is a UCAN signed by the parent's delegatee. A delegation only confers authority if every link verifies — signature, time-bounds, and scope-containment — back to the owner.
Actors
- Delegator — the principal granting authority; a DID string (
delegation.delegator). - Delegatee — the principal receiving it (
delegation.delegatee); becomes the delegator of any child. - Root authority — the space's owner DID (an Ethereum did:pkh). A delegation whose caps all root at a space the delegator owns needs no parent (
is_root_authority). - Parent delegation(s) — for a non-root cap, the prior delegation(s), referenced by CID in the UCAN
prooffield / ReCapprfarray, that must cover the child's scope. - The host node — verifies and records the delegation (
/delegateroute →delegation::process).
Sequence
A delegation is processed in three phases — process() calls verify() → validate() → save() (tinycloud-core/src/models/delegation.rs:128):
- Decode — the
/delegaterequest'sAuthorizationheader is decoded into aTinyCloudDelegation, which is either a UCAN (string contains.) or a CACAO (base64url DAG-CBOR), then lifted into aDelegationInfo { capabilities, delegator, delegate, parents, expiry, not_before, issued_at }byTryFrom<TinyCloudDelegation>intinycloud-core/src/util.rs. For the CACAO arm this runs ReCap extraction (SiweCap::extract_and_verify) to pull capabilities and parent CIDs; for the UCAN arm it readspayload().attenuationandpayload().proof. verify()— cryptographic + self-consistent-time check (see crypto).validate()— the chain check (below) — splits caps into root vs. dependent, looks up parents, enforces time-containment and scope-coverage.save()— persists the delegation (content-hashed PK, idempotent on-conflict-do-nothing), its abilities, and its parent-edges into thedelegation/abilities/parent_delegationstables.
The chain check (validate)
validate() (delegation.rs:167) is the heart of delegation. For each capability in the new delegation:
- It is root-authorized if
is_root_authority(cap, delegator)— the cap's space-owner DID matches the delegator (or, for an encryption-network URN, the network-owner DID matches). Root caps need no parent. - Otherwise it is a dependent cap and must be backed by a parent.
The branch table (delegation.rs:181):
| dependent caps | parents present | result |
|---|---|---|
| none | — | Ok (pure root grant) |
| some | none | MissingParents |
| some | some | check parents (below) |
When parents are required, the node:
- Loads candidate parents by CID and delegatee == this delegation's delegator (
Column::Delegatee.eq(delegator)) — a parent only counts if it was granted to the one now re-granting. No match →MissingParents. - Filters parents by time-containment: child
expiry ≤ parent.expiryand childnot_before ≥ parent.not_before, with aNoneparent bound meaning unbounded. All parents filtered out →ExpiryExceedsParentorNotBeforePrecedesParent. - Loads each surviving parent's abilities and requires every dependent cap to be
extends-covered by some parent ability:c.resource.extends(&pc.resource) && c.ability == pc.ability. Any uncovered cap →UnauthorizedCapability(resource, ability). This is attenuation.
Crypto
verify() (delegation.rs:142) checks signature and the delegation's own time validity (the relative time-containment against the parent happens later in validate):
- UCAN child —
ucan.verify_signature(&AnyDidMethod::default())validates the JWT signature against the issuer DID's key, thenpayload().validate_time(None)checksnbf/expagainst now. (AnyDidMethod::default()is a// TODO go back to static DID_METHODSplaceholder.) - CACAO root —
cacao.verify()runs EIP-191personal_signrecovery (Eip191::verifyin cacao): the CACAO payload is converted to a SIWEMessageandverify_eip191(sig)recovers the signer and checks it equals thedid:pkhaddress. Thenpayload().valid_now()checksnbf/exp. The ReCap statement is also verified during extraction inutil.rs—extract_and_verifyrejects a SIWE whose human-readable statement does not match the encodedurn:recap:capabilities (see cacao-chain-validation).
Replay is bounded by the SIWE nonce + the [nbf, exp] window; there is no separate nonce-dedup table on the delegation path (the content-hash PK makes re-submission idempotent).
Edge cases
- Widening is impossible. A child can never grant a resource/ability its parent lacked — attenuation via
ResourceId::extends+ exactability ==is the only coverage rule. - Wrong delegatee. A parent granted to DID A cannot back a re-grant by DID B, even with a valid CID (the
Column::Delegateefilter). - Time can only shrink. Child windows must sit inside parent windows; a child claiming a later
expor earliernbfthan its parent is rejected. - Unhosted space. A delegation-only transaction referencing a space that does not yet exist is skipped, not errored (the space is created lazily by a
tinycloud.space/hostroot grant — see space-hosting). - DID fragments are stripped before matching (
strip_fragment→principal_did), sodid:key:z6Mk…#z6Mk…anddid:key:z6Mk…compare equal.
Trace
An owner did:pkh:eip155:1:0xf39f…2266 signs a SIWE message bearing a urn:recap: capability for ability tinycloud.kv/get over tinycloud:pkh:eip155:1:0xf39f…2266:applications/kv/com.listen.app/ with a 24-hour exp, audience = a session key did:key:z6Mk…. The CACAO is posted to /delegate: verify() recovers the wallet signature and confirms the ReCap statement matches; validate() finds the cap is root-authorized (delegator owns the space) so no parent is needed; save() records it. The session key then mints a UCAN re-delegating tinycloud.kv/get over …/com.listen.app/transcript/ (a strict prefix) to an agent DID, citing the root CACAO's CID in proof. On /delegate, validate() now finds a dependent cap, loads the root by CID (delegatee == session key ✓), confirms the agent's window ⊆ root window, and that …/transcript/ extends …/com.listen.app/ with the same ability ✓. The agent can now invoke reads under transcript/, nothing more. (See example-listen.)
Status & drift
Shipped. The on-wire ability form is exactly {namespace}.{service}/{action} (e.g. tinycloud.kv/get); the whitepaper's looser prose is non-canonical (see contradictions). DID resolution uses AnyDidMethod::default() pending a return to a static method allow-list (// TODO, delegation.rs:145).
Sources
tinycloud-node:tinycloud-core/src/models/delegation.rs:128-283(process/verify/validate/is_root_authority),tinycloud-core/src/util.rs:112-163(DelegationInfoextraction, CACAO→ReCap path),tinycloud-auth/src/authorization.rs:27-70(TinyCloudDelegationencode/decode)