All notable changes to layr8. Format loosely follows Keep a Changelog; versioning follows SemVer.
This file starts here. Earlier releases (through 0.2.8) are recorded only in git history.
[Unreleased]
0.3.2 - 2026-09-16
Added
- A borrowed child's delegated set is kept current while it is connected.
A join that names a
parentDidnow sendsdelegation_refresh: true. When the node announcesephemeral_delegation_refresh/1, it pushes adelegated_credentialsevent with the whole new set whenever the parent's grants change. The channel applies it for the DID whose topic it arrived on, ignores a push whoserevisionis not newer than the one it holds, ignores a push that does not parse (and anunreadpush, which the node never sends), and the client replaces the wallet's delivered set and calls the new:on_delegationoption with(did, reading). A rejoin starts the revision again from the join reply. A delegation callback that raises, throws or exits is logged and does not stop the client or the channel. Layr8.Client.supports_ephemeral_delegation_refresh/2,Layr8.Channel.supports_ephemeral_delegation_refresh/2,Layr8.Delegated.parse_push/1,join_revision/1andrefresh_capability/0.
0.3.1 - 2026-09-16
Added
The
trace_contextplaintext header is carried. A DIDComm message may carry a W3C trace context in a top-leveltrace_contextobject (traceparent, optionaltracestate). The SDK used to drop it on parse and never wrote it. It is nowLayr8.Message'strace_contextfield, a map with string keys"traceparent"and optionally"tracestate":parse/1reads it,marshal/1writes it, andsend/requestcarry a value the caller sets.Layr8.Message.read_trace_context/1is the one reader.A handler's reply joins the request's trace. A
{:reply, msg}copies the request'strace_contextunchanged unless the handler set its own, next to where the reply'sthread_idis defaulted. The problem report sent for a raising handler or an{:error, _}result copies it too.A value that is not a map with a string
traceparentis dropped, never a parse error, and members other thantraceparentandtracestateare not forwarded. The SDK does not validate thetraceparentformat. It does not yet create a trace context for a new request that has none. The node and Go SDKs make the same change.
0.3.0 - 2026-09-15
Changed
Both entries below change an existing API, so this release is a minor, not a
patch. The Go SDK shipped the same lastmod_time change as a breaking one in
v0.2.0. A dependency pinned as ~> 0.2.14 does not pick up 0.3.0; move it to
~> 0.3.0.
Layr8.Attachment'slastmod_timeisnon_neg_integer() | String.t() | nil. DIDComm v2 states no type for the field: its Attachments section says only "OPTIONAL. A hint about when the content in this attachment was last modified", while the same document pinscreated_timeandexpires_timeto "UTC Epoch Seconds (seconds since 1970-01-01T00:00:00Z) as an integer". The omission is visible rather than accidental, so a receiver is not entitled to demand an integer. Senders have put both an integer and an ISO-8601 string on the wire.Nothing converted the value before and nothing converts it now — this SDK passes the hint through in both directions. What changes is the typespec, which declared an integer for a field that held whatever arrived. A caller that wants a moment out of it matches on what came, and
test/layr8/attachment_lastmod_time_test.exsasserts the three forms — absent, integer, string — stay three distinct values. It also asserts the typespec itself, so narrowing the field back fails here rather than in a caller: a typespec constrains nothing at runtime and this repo's CI runs no static checker, so every other test in that file passes with the field declared an integer.Layr8.Message'sattachmentsisnilwhen theattachmentsheader could not be read, with the reason in the newattachments_unreadfield. An absent header still reads as[]. Returning[]for a header nobody decoded would report "this message carried no attachments", which is a measurement that was never taken — and that is what a non-list header used to do.
Fixed
An undecodable attachment no longer takes the client process with it.
Layr8.Message.parse/1declares{:ok, t()} | {:error, term()}, but an attachment entry that was not a map raisedFunctionClauseErrorout ofLayr8.Attachment.parse/1, straight past that contract and out of the client'shandle_info/2. Nothing there rescues, so theLayr8.Clientprocess died and took every request waiting on it — measured: the process goesDOWNwith{:function_clause, [{Layr8.Attachment, :parse, …}]}. Ane.m.authz.deniedproblem report carrying a decision attachment could therefore arrive as a dropped connection rather than a denial, and being refused and being disconnected are different events.Attachments are now decoded in a second pass:
parse/1returns, the message is delivered, and the header it could not read is reported as unread.Layr8.Mediation.handle_delivery/2no longer raises on such a message either.sign_credential/3fillsidandissuerwhen the credential omits them. The node's credential parser requires both keys and answers a missing one with422 Invalid credential: missing required fields, without naming which; it does not copyissuer_didintoissuer. The SDK documented neither field as required, and its own README example omitted both, so a credential carrying onlycredentialSubjectwas refused.Layr8.Credentials.sign_credential/3now sends a copy withidset tourn:uuid:<random UUID v4>andissuerset to the:issuer_didoption (Layr8.Client.sign_credential/3defaults that to the agent DID) whenever the key is absent,nilor"". A value the caller gave — under a string or an atom key — is never replaced, and no second form of a key is added. When:issuer_didis itself empty,issueris left alone; the node rejects that call for the emptyissuer_did. The Node, Go and Python SDKs receive the same fix.
0.2.14 - 2026-09-10
Added
A join can name the parent whose authority its DID borrows, and this SDK derives the name.
:parent_didis optional and is sent only when set, so a join that names no parent puts exactly the payload on the wire it did before — asserted byte for byte intest/layr8/borrowed_did_test.exs. Pass:parent_didand leave:agent_didempty, and the client joins as<parent_did>:<segment>: twelve characters of Crockford base32 from:crypto.strong_rand_bytes/1, generated once when the configuration is resolved, so a reconnect returns under the same DID and the node re-mints the same credentials for it.The reason the shape is fixed: a cloud-node API key restricts which DIDs it may bind, and an entry is either an exact DID or a prefix with a trailing
*. While a borrower's name was unrelated to its parent — and generated per connection — no entry could be written for it in advance, so the only key that admitted a borrower was one with no restrictions at all, which admits every DID on the node. Named beneath its parent, one key carrying the parent andLayr8.ChildDid.did_namespace_of/1admits the parent and its borrowers and nothing else.A caller that supplies its own
:agent_didthat is not named beneath the parent gets a raisedLayr8.ErrorfromLayr8.Config.resolve!/1, before anything is written; adid_spechanded toLayr8.Client.join_did/2that does the same gets{:error, %Layr8.Error{}}and writes no frame. The node refuses such a join withe.join.plugin.child.not-beneath-parent, and a refusal at connect time in production is the expensive way to learn this.New module
Layr8.ChildDid:random_child_segment/0,did_namespace_of/1,beneath_parent?/2,resolve_borrower_did/2,settle/2.did_spec.childNameSourceis sent alongsideparentDid—"sdk"when this library generated the segment,"client"when the caller supplied the whole DID, and the key is absent when neither applies. A generated name and a hand-built one that conforms are otherwise identical bytes, so without it a malformed borrower DID could not be told apart as this library's defect from a caller's typo. The absent case is never folded into"client".A join that names a parent is sent with
storage: "ephemeral"unless the caller's owndid_specsays otherwise: only a temporary identity may borrow, and the node refusespersistent+parentDidwithe.join.plugin.child.storage-not-ephemeral.The join reply carries the credentials the node signed for this DID.
Layr8.Client.delegated_credentials/1returns aLayr8.Delegated.Reading—statusandcredentials— with one entry per grant the named parent holds. The node signs them at join, narrowed to no more than the parent carries and citing it incredentialSubject.delegation.parentCapability. When:attach_grantsis on they are attached to outbound messages automatically; there is nothing to wire up. A DID joined withjoin_did/2gets its own reading, read withdelegated_credentials/2.Four readings from that function, and six with
Layr8.Client.supports_ephemeral_delegation/1. Collapsing any pair reports something nobody measured.delegated_credentials/1supports_ephemeral_delegation/1Meaning niltruethe join named no parent %Reading{status: :complete, credentials: []}truethe parent's wallet was read and it grants nothing %Reading{status: :complete, credentials: [_ | _]}trueread, and here is all of it %Reading{status: :partial, credentials: [_ | _]}trueread, and some of it could not be delegated %Reading{status: :unread, credentials: []}truethe wallet could not be read; the []measures nothingnilfalsethe node never looked Anything that is not a well-formed reading — absent, a bare list from an older node, an unknown status — is
nil, never an empty:completeone: that would state that a wallet was read and grants nothing, which is the one thing none of those inputs says.A reading arrives on every join and rejoin, including one that carries no reading at all — that clears whatever the previous join seeded, because the node mints a fresh set per join and the previous set names a DID document a rejoin may have replaced.
The credential exists nowhere but the join reply. The node stores nothing about it, so
GET /api/v1/credentialswill never return it; rejoin to be issued a new one. It is not individually revocable — authority is withdrawn by revoking or expiring the parent's grant. Because that endpoint is not their source,Layr8.Walletholds them apart from its read cache: they do not lapse on a TTL that exists to re-read a source that will never have them, and a failed read of it no longer withholds them from a message they cover.
0.2.13 - 2026-09-04
Fixed
- Mediation now binds both protocols at join. A mediated client bound only
messagepickup/3.0(via the delivery handler) and relied on the cloud-node negotiatingreply_protocol/1to deliver thecoordinate-mediation/3.0mediate-grant. Against a node that does not negotiate it, the grant reply was dropped and enrolment timed out.connect/1now also subscribes tocoordinate-mediation/3.0when a mediator is configured (#29).
0.2.12 - 2026-09-04
Added
Layr8.Mediation— store-and-forward through a Space mediator. An agent that is not always connected gives the client a mediator DID (:mediator/LAYR8_MEDIATOR_DID) and, on every connect and reconnect, the client enrols (mediate-request,recipient-update), declares the mediator on its own node (PUT /api/v1/dids/:did/mediator, cloud-node ADR 0005), collects everything queued (delivery-request→ re-injection through the node's/didcomm→messages-received) and turns live delivery on, handling the mediator'sdeliverypushes the same way. The SDK never decrypts: the mediator holds the original ciphertext and the node verifies it on re-injection as a first arrival. Each step is also callable by hand and none raises. New config:mediator_live,:didcomm_url(LAYR8_MEDIATOR_LIVE,LAYR8_DIDCOMM_URL); newLayr8.REST.put/4,delete/3,post_didcomm/3;Layr8.Client.mediator/1.
0.2.11 - 2026-08-21
Added
Layr8.Identity.attachment/1andattachment!/1— a first-class way to attach an identity credential (a credential about who the sender is, with nocredentialSubject.scope) so it reaches the cloud-node'ssender_credentialspolicy input, where a grant'ssenderCredentialsrequirement can see it. They build the attachment; the caller names the credential. The SDK does not choose: the requirement being satisfied lives in the recipient's grant and never reaches the sender, so automatic selection could only mean "attach everything the holder has", which is a disclosure decision, not a convenience.{:error, :not_compact_jws}for anything that is not a compact JWS, and{:error, :credential_is_grant}for a credential that carries a scope — that is a grant, and attached this way it would be routed as one and satisfy nothing.Layr8.Identity.attachment?/1, the same test applied to an attachment already on a message.
Changed
- Caller-supplied attachments still displace the wallet, with one narrowing: when they are all identity credentials, the wallet's grants are appended after them instead — on the send path and on the handler-reply path alike. Saying who you are must not stop you saying what you may do; under the old rule it did, and the node's denial then read "no grant covers this call". Anything else a caller attaches behaves exactly as before.
0.2.10 - 2026-08-17
Added
More than one DID on a single connection —
Layr8.Client.join_did/3joins an additionalplugins:<did>topic on the WebSocketconnect/1already opened, and returns aLayr8.DidHandlethat sends and requests as that DID. Until now one client meant one DID, so an agent speaking for many identities (a workflow per DID, an account per DID) needed one connection each; the cloud-node's plugin socket has always matchedplugins:*, so the limit was the SDK's alone.Each joined DID carries its own handlers (
:handlers,:handle_all), its own protocol subscription (:protocols) and its own:did_spec— includingcontroller, which decides whose grants can cover it. The client-global registry stays the fallback for every DID. Joined DIDs are re-joined automatically after a reconnect.New API:
Layr8.Client.join_did/3,leave_did/2,joined_dids/1,send_from/4,request_from/4,request_result_from/4, and theLayr8.DidHandlemodule. Existing single-DID use is unchanged.Modelled on the node-sdk's
joinDid/DidHandle, which mcp-pod uses in production to host one Instance DID per connected account.A directory-visible check that the node really does host many DIDs — a
multi_didcompat scenario.join_did's three load-bearing properties belong to the cloud-node, not to this library, so no unit test can establish them: that the node accepts more than one topic on one socket, that an inbound frame reaches the handler of the DID it was addressed to, and that after the socket drops every joined DID comes back by itself. The last is the one worth having: losing an extra DID on a reconnect is silent, with nothing to observe on this side.
Fixed
joined_dids/1no longer reports a DID the node has not acknowledged. It answered from the client's handler table, which survives a dropped socket untouched, so across a reconnect it listed DIDs whose re-join was still in flight and a send to one of them raisedNotConnectedError. Measured against a real node: at the momenton_reconnectfired, the re-join replies were still 60ms and 100ms away. It now answers from the channel, which tracks what the server confirmed. Never shipped —joined_dids/1is new in this release.
0.2.9 - 2026-08-10
Added
Verifiable Grants are attached to outbound messages — automatically, on every send path (
send/3,request/3, and a handler's reply). The cloud-node requires a grant for anything its policy does not allow outright, and nothing in this SDK attached one: an agent that connected directly 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".Layr8.Walletreads the holder's credentials from the node, caches them for:grant_cache_ms(default 60s) and selects the covering set with a mirror of helix'sstructure_v2.rego. Caller-supplied attachments are never displaced, and a wallet failure never blocks the send.New config:
:attach_grants(defaulttrue, envLAYR8_ATTACH_GRANTS),:grant_cache_ms,:grant_read_timeout_ms,:on_grant_miss. New API:Layr8.Client.refresh_grants/2.Contract:
contracts/sender-cn-vg-attachment.md.:on_grant_miss— told when the node denied a message that went out with nothing attached, when the covering set had to be capped at 16, or when the grants could not be read at all. It deliberately stays quiet on "nothing covered this message" alone: most traffic (discovery, trust-ping, problem reports) needs no grant.Layr8.Mcp— MCP over DIDComm.Layr8.Client.mcp/2registers the protocol subscription and returns a binding;Layr8.Mcp.peer/2yields a caller withinitialize/3,list_tools/2andcall_tool/4. It handles thetools/call→#{base}/tools-calltype mapping, the JSON-RPC envelope and unwrappingresult. Must be called beforeconnect/1, likehandle/3. Contract:contracts/mcp-over-didcomm.md.Layr8.Client.request_result/3—request/3without the raises, for callers that route on failure rather than rescuing it.Layr8.Mcpuses it, which is why its whole surface is tagged tuples.:rest_timeout_ms(default 30s, envLAYR8_REST_TIMEOUT_MS) — a deadline on every credential and presentation call, not just the grant read.Reqhas none of its own, so a node that accepted the connection and went quiet left those calls hanging.0disables it.
Changed
Every send now performs a credential read against the node before the message goes out (once per
:grant_cache_msper DID; failures are cached for a shorter window so a misconfigured API key is not a per-message round trip). A node that cannot serve/api/v1/credentialsdegrades to sending unattached — the previous behaviour — and:on_grant_missreports it. Setattach_grants: falseto opt out entirely.Layr8.RESTno longer retries.Reqretries safe requests by default with a 1s/2s/4s backoff, and becausereceive_timeoutis per attempt, that silently multiplied every deadline by four: measured, a 2s grant read against a node that accepts the connection and goes quiet took 7.6 seconds to return. A deadline exists here so a hung node cannot stall the sends queued behind it, soretry: falsenow goes wherever a deadline goes. Retrying is the caller's decision, with its own knowledge of whether the call is worth repeating.Layr8.REST.new/2,get/2andpost/3gained optional trailing arguments; the existing arities are unchanged.
Fixed
Layr8.Attachment's documentation pointed at the wrong field. It said to putapplication/vc+jwtinformatand to carry credentials indata.base64.media_typeis the ONLY field the node's credential extractor filters on, by exact string equality, and it drops everything else before looking at the data — producing a denial byte-for-byte identical to the one for attaching nothing. Following those docs attached nothing at all. The same correction is onLayr8.Presentations.sign_presentation/3, which is not the authorization path either.