Attaching Verifiable Grants to outbound messages.
Why this exists
The cloud-node REQUIRES a VG for any message its policy does not allow outright, and until now nothing in this SDK attached one. There was no enforcement on outgoing requests — there was no mechanism. An agent that connected directly, on any protocol, sent nothing and was denied with "no grant covers this call" — a message that reads as "your grant is misconfigured" when the truth is "no credential was ever put on the wire".
That misreading is the expensive part. Two teams spent days on it: checking
the grant, the Space policy, whether the PDP expanded messageTypes: ["*"].
The sender is the only party that knows it attached nothing, so the sender is
the only one that can say so — see :on_grant_miss on Layr8.Client.
Cross-language contract: ~/Developments/contracts/sender-cn-vg-attachment.md.
@layr8/sdk's src/wallet.ts is the same abstraction on the Node side.
The attachment shape is load-bearing
media_type is the ONLY thing the node's credential extractor filters on: it
keeps attachments whose media type is exactly "application/vc+jwt" and drops
every other one SILENTLY, before looking at the data at all. A Verifiable
Presentation (application/vp+jwt) is discarded on that rule, and the denial
that follows is byte-for-byte the one you get for attaching nothing — which is
how a partner team spent a day looking at a grant that was fine.
format is NOT the filter. This module writes media_type, and
Layr8.Attachment's docs used to point the other way.
data.jws is the primary place the JWS is read from, and what this SDK
writes. data.base64 is NOT dropped: the extractor falls back to it and
base64url-decodes it. data.jws is still the right choice — it is the field
the extractor reaches for first and the one the whole ecosystem writes — but
the reason is "primary path", not "the alternative is discarded".
Over-attaching is free; under-attaching is not
grant.rego allows on the FIRST passing grant and simply ignores the rest, so
an extra credential on the wire costs nothing. A credential withheld costs a
working call, and the failure is invisible — it presents as the same "no grant
covers this call" this module exists to end.
That asymmetry decides every judgement call here. Nothing filters on the
grant's credentialSubject.grant.tools allowlist: no policy reads it, helix
evaluates credentialSubject.constraints.rego keyed by grant id, which this
side cannot reproduce and should not try to. tools only ranks candidates
when the cap bites.
Selection mirrors the policy, and deliberately errs wide
covers?/4 mirrors helix's structure_v2.rego: some scope entry must match
the protocol, the message type and the resource. What this does NOT do is
decide anything the PDP decides — revocation and validity windows are checked
there, against sources this side cannot see. Attaching a revoked or expired
grant costs one denial; withholding one because a local cache thought it was
dead costs a working call, and that failure is silent.
Summary
Types
A grant this DID holds, decoded far enough to decide whether it covers a
message. raw_jwt is what actually goes on the wire.
Reads the stored credential records for a holder DID.
The wallet's grant cache. Immutable — every read returns a new one.
Functions
The attachments for one outbound message.
The grants did holds, read from the node and cached for ttl_ms.
The most credentials put on one message.
Builds a wallet over a Layr8.REST client, or over any reader/0.
Decodes one stored credential record into a held/0, or nil when it is
not a Verifiable Grant.
Drops the cached grants for did (or all of them), forcing the next send to
re-read.
The covering set for one outbound message, as ready-to-send attachments.
Splits a DIDComm type into {protocol, message_type}.
The tool name the policy will match, if this body carries one.
Types
@type held() :: %{ id: String.t(), raw_jwt: String.t(), scope: [map()], tools: [String.t()], expires_at: integer() | nil }
A grant this DID holds, decoded far enough to decide whether it covers a
message. raw_jwt is what actually goes on the wire.
Reads the stored credential records for a holder DID.
A function rather than a Layr8.REST client so the cache and its failure TTL
are testable without a node — and so a consumer that already holds its
credentials somewhere else can say so.
@type t() :: %Layr8.Wallet{ cache: %{ optional(String.t()) => {integer(), {:ok, [held()]} | {:error, term()}} }, failure_ttl_ms: non_neg_integer(), read_timeout_ms: pos_integer(), reader: reader(), ttl_ms: non_neg_integer() }
The wallet's grant cache. Immutable — every read returns a new one.
Functions
@spec attachments_for(t(), String.t(), map(), (map() -> any()) | nil) :: {:ok, [Layr8.Attachment.t()], t()} | {:error, term(), t()}
The attachments for one outbound message.
Returns {:ok, attachments, wallet} — where attachments may legitimately be
[], since most DIDComm traffic (discovery, trust-ping, problem reports)
rides the node's allow rules with no grant at all — or {:error, reason, wallet} when the grants could not be READ, which is never a normal outcome.
on_capped is called with %{covering: n, attached: m} when the cap left
credentials off. Silence there is the same class of failure this module exists
to end: the holder is the only party that knows a covering credential never
reached the wire.
The grants did holds, read from the node and cached for ttl_ms.
Cached because a send should not cost a round trip.
The most credentials put on one message.
Over-attaching is free at the policy, but not on the wire: a holder with
per-tool grants can hold dozens, each a 1-2KB JWT, on every message. The cap
is far above any real holding; when it bites, the entries kept are the most
likely to matter (see select_for/3) and the caller is TOLD, because a
credential dropped here produces the same indistinguishable denial as one
never held.
@spec new( Layr8.REST.t() | reader(), keyword() ) :: t()
Builds a wallet over a Layr8.REST client, or over any reader/0.
Options
:ttl_ms— how long a successful read is cached. Default60_000.:read_timeout_ms— deadline on one credential read. Default2_000.:failure_ttl_ms— how long a FAILED read is remembered. Defaults tomin(ttl_ms, max(5_000, read_timeout_ms)).
A failure is cached, briefly. Only caching successes meant an agent whose API key cannot read credentials paid a full failing round trip on EVERY outbound message, forever — turning a config mistake into a permanent latency tax. Short, because the fix for that mistake should take effect without a restart; and never shorter than the read deadline, because the entry is stamped with the time the read STARTED, so a failure TTL at or below the deadline is already lapsed the moment a timeout records it.
Decodes one stored credential record into a held/0, or nil when it is
not a Verifiable Grant.
Drops the cached grants for did (or all of them), forcing the next send to
re-read.
The cache TTL is the whole freshness story: a grant minted seconds ago is invisible until it lapses. An agent that has just been TOLD it was granted something should not have to wait out a timer it cannot see.
@spec select_for([held()], map(), (map() -> any()) | nil) :: [Layr8.Attachment.t()]
The covering set for one outbound message, as ready-to-send attachments.
recipients is the message's to: the node evaluates one decision per
recipient, so a credential covering ANY of them belongs on the wire.
An empty result is a legitimate outcome, not an error.
Splits a DIDComm type into {protocol, message_type}.
A type URI is <protocol>/<messageType> and the policy matches the two
separately. Splitting on the LAST slash is what the node's own parser does.
The tool name the policy will match, if this body carries one.