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.1 - 2026-09-16

Added

  • The trace_context plaintext header is carried. A DIDComm message may carry a W3C trace context in a top-level trace_context object (traceparent, optional tracestate). The SDK used to drop it on parse and never wrote it. It is now Layr8.Message's trace_context field, a map with string keys "traceparent" and optionally "tracestate": parse/1 reads it, marshal/1 writes it, and send / request carry a value the caller sets. Layr8.Message.read_trace_context/1 is the one reader.

  • A handler's reply joins the request's trace. A {:reply, msg} copies the request's trace_context unchanged unless the handler set its own, next to where the reply's thread_id is 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 traceparent is dropped, never a parse error, and members other than traceparent and tracestate are not forwarded. The SDK does not validate the traceparent format. 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's lastmod_time is non_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 pins created_time and expires_time to "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.exs asserts 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's attachments is nil when the attachments header could not be read, with the reason in the new attachments_unread field. 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/1 declares {:ok, t()} | {:error, term()}, but an attachment entry that was not a map raised FunctionClauseError out of Layr8.Attachment.parse/1, straight past that contract and out of the client's handle_info/2. Nothing there rescues, so the Layr8.Client process died and took every request waiting on it — measured: the process goes DOWN with {:function_clause, [{Layr8.Attachment, :parse, …}]}. An e.m.authz.denied problem 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/1 returns, the message is delivered, and the header it could not read is reported as unread. Layr8.Mediation.handle_delivery/2 no longer raises on such a message either.

  • sign_credential/3 fills id and issuer when the credential omits them. The node's credential parser requires both keys and answers a missing one with 422 Invalid credential: missing required fields, without naming which; it does not copy issuer_did into issuer. The SDK documented neither field as required, and its own README example omitted both, so a credential carrying only credentialSubject was refused. Layr8.Credentials.sign_credential/3 now sends a copy with id set to urn:uuid:<random UUID v4> and issuer set to the :issuer_did option (Layr8.Client.sign_credential/3 defaults that to the agent DID) whenever the key is absent, nil or "". 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_did is itself empty, issuer is left alone; the node rejects that call for the empty issuer_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_did is 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 in test/layr8/borrowed_did_test.exs. Pass :parent_did and leave :agent_did empty, 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 and Layr8.ChildDid.did_namespace_of/1 admits the parent and its borrowers and nothing else.

    A caller that supplies its own :agent_did that is not named beneath the parent gets a raised Layr8.Error from Layr8.Config.resolve!/1, before anything is written; a did_spec handed to Layr8.Client.join_did/2 that does the same gets {:error, %Layr8.Error{}} and writes no frame. The node refuses such a join with e.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.childNameSource is sent alongside parentDid"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 own did_spec says otherwise: only a temporary identity may borrow, and the node refuses persistent + parentDid with e.join.plugin.child.storage-not-ephemeral.

  • The join reply carries the credentials the node signed for this DID. Layr8.Client.delegated_credentials/1 returns a Layr8.Delegated.Readingstatus and credentials — 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 in credentialSubject.delegation.parentCapability. When :attach_grants is on they are attached to outbound messages automatically; there is nothing to wire up. A DID joined with join_did/2 gets its own reading, read with delegated_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 nothing
    nilfalsethe 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 :complete one: 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/credentials will 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.Wallet holds 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 negotiating reply_protocol/1 to deliver the coordinate-mediation/3.0 mediate-grant. Against a node that does not negotiate it, the grant reply was dropped and enrolment timed out. connect/1 now also subscribes to coordinate-mediation/3.0 when 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 /didcommmessages-received) and turns live delivery on, handling the mediator's delivery pushes 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); new Layr8.REST.put/4, delete/3, post_didcomm/3; Layr8.Client.mediator/1.

0.2.11 - 2026-08-21

Added

  • Layr8.Identity.attachment/1 and attachment!/1 — a first-class way to attach an identity credential (a credential about who the sender is, with no credentialSubject.scope) so it reaches the cloud-node's sender_credentials policy input, where a grant's senderCredentials requirement 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 connectionLayr8.Client.join_did/3 joins an additional plugins:<did> topic on the WebSocket connect/1 already opened, and returns a Layr8.DidHandle that 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 matched plugins:*, 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 — including controller, 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 the Layr8.DidHandle module. 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_did compat 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/1 no 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 raised NotConnectedError. Measured against a real node: at the moment on_reconnect fired, 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/1 is 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.Wallet reads 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's structure_v2.rego. Caller-supplied attachments are never displaced, and a wallet failure never blocks the send.

    New config: :attach_grants (default true, env LAYR8_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/2 registers the protocol subscription and returns a binding; Layr8.Mcp.peer/2 yields a caller with initialize/3, list_tools/2 and call_tool/4. It handles the tools/call#{base}/tools-call type mapping, the JSON-RPC envelope and unwrapping result. Must be called before connect/1, like handle/3. Contract: contracts/mcp-over-didcomm.md.

  • Layr8.Client.request_result/3request/3 without the raises, for callers that route on failure rather than rescuing it. Layr8.Mcp uses it, which is why its whole surface is tagged tuples.

  • :rest_timeout_ms (default 30s, env LAYR8_REST_TIMEOUT_MS) — a deadline on every credential and presentation call, not just the grant read. Req has none of its own, so a node that accepted the connection and went quiet left those calls hanging. 0 disables it.

Changed

  • Every send now performs a credential read against the node before the message goes out (once per :grant_cache_ms per 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/credentials degrades to sending unattached — the previous behaviour — and :on_grant_miss reports it. Set attach_grants: false to opt out entirely.

  • Layr8.REST no longer retries. Req retries safe requests by default with a 1s/2s/4s backoff, and because receive_timeout is 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, so retry: false now 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/2 and post/3 gained optional trailing arguments; the existing arities are unchanged.

Fixed

  • Layr8.Attachment's documentation pointed at the wrong field. It said to put application/vc+jwt in format and to carry credentials in data.base64. media_type is 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 on Layr8.Presentations.sign_presentation/3, which is not the authorization path either.