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.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.