Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
All notable changes to `layr8`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versioning follows [SemVer](https://semver.org/).
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 `parentDid` now sends `delegation_refresh: true`. When
the node announces `ephemeral_delegation_refresh/1`, it pushes a
`delegated_credentials` event 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 whose `revision` is not newer than the one it holds, ignores a
push that does not parse (and an `unread` push, which the node never sends),
and the client replaces the wallet's delivered set and calls the new
`:on_delegation` option 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/1` and
`refresh_capability/0`.
## [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.Reading` —
`status` 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/1` | `supports_ephemeral_delegation/1` | Meaning |
|---|---|---|
| `nil` | `true` | the join named no parent |
| `%Reading{status: :complete, credentials: []}` | `true` | the parent's wallet was **read** and it grants nothing |
| `%Reading{status: :complete, credentials: [_ \| _]}` | `true` | read, and here is all of it |
| `%Reading{status: :partial, credentials: [_ \| _]}` | `true` | read, and some of it could not be delegated |
| `%Reading{status: :unread, credentials: []}` | `true` | the wallet could **not** be read; the `[]` measures nothing |
| `nil` | `false` | the 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 `/didcomm` → `messages-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 connection** — `Layr8.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/3`** — `request/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.
[0.3.2]: https://github.com/layr8/elixir_sdk/releases/tag/v0.3.2
[0.3.1]: https://github.com/layr8/elixir_sdk/releases/tag/v0.3.1
[0.3.0]: https://github.com/layr8/elixir_sdk/releases/tag/v0.3.0
[0.2.14]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.14
[0.2.13]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.13
[0.2.12]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.12
[0.2.11]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.11
[0.2.10]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.10
[0.2.9]: https://github.com/layr8/elixir_sdk/releases/tag/v0.2.9