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.

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.