Upgrading

Read this before upgrading a deployment. One section per released version, newest first. A version with no operator-visible change says so explicitly rather than being absent — an absent section is indistinguishable from one nobody wrote.

Why this file exists, and what it is not

Release notes for this workspace are per-crate and generated by release-plz from commit subjects (see CHANGELOG.md). A subject is one line. It cannot carry "upgrade these two things together or the stack will not boot", so a hazard recorded only in a commit body, ASSUMPTIONS.md or an issue reaches nobody performing an upgrade.

That gap was accepted deliberately rather than left implicit — see DECISIONS.md, U-514. This file is the named place operator-visible changes go, and docker/assert-upgrade-notes.sh fails the build if the current workspace version has no section here.

This is not a changelog. It carries only what changes an operator's actions: configuration whose meaning changed, an upgrade that must be ordered, a default that moved, a removal. Everything else belongs in the per-crate changelogs.


0.5.3

The federation proxy answers 502 when a foreign registry serves a malformed context, and the acdp dependency moves to 0.15.1.

For a foreign ctx_id, GET /contexts/{ctx_id} answers a document the origin registry got wrong (not JSON, an unknown field, a missing field) with 502 cross_registry_resolution_failed, the same as an unreachable origin. 0.5.2 answered 500 internal_error, because acdp 0.15.0 reported these documents as a serialization failure. acdp 0.15.1 reports them as schema_violation, which on its own would have answered 400 and blamed the caller for the origin's bytes; 0.5.3 is the first release on 0.15.1 and maps it to 502 instead.

  • Who is affected: only deployments with registry.cross_registry_resolution enabled, and only clients that branch on the status of a failed foreign retrieval. A 5xx is still a 5xx; a client that matched internal_error should match cross_registry_resolution_failed.
  • Rollback: safe. No migration, configuration or storage change.
  • Unchanged: configuration keys and defaults, the schema, and every other endpoint.

0.5.2

This release drops the lineages table (migration 015, both backends). On Postgres, roll back only to 0.3.0 or later; a 0.2.x binary's publishes fail against the dropped table.

lineages has been stale since 0.3.0, which stopped writing it (see that section). Nothing in the registry ever read it: GET /lineages/{id}, /current and the admin audit are served from contexts. No response changes.

  • Who is affected: only anyone who queries lineages directly (a report, dashboard or ad-hoc SQL). Query contexts grouped by lineage_id instead; that has been the advice since 0.3.0.
  • Rollback, Postgres: safe to 0.3.0 or later. The migrator tolerates the newer applied version, and those binaries never touch lineages. Rolling back straight to 0.2.x is not safe: its publish still writes the table and fails. To go that far back, restore a pre-upgrade backup.
  • Rollback, SQLite: unchanged. A database migrated by a newer binary already refuses an older one; restore the pre-upgrade file, as for any release that adds a migration.
  • Boot refuses a bad EdDSA key earlier. With auth.jwt_signing_alg = "EdDSA", a missing or malformed auth.jwt_private_key_pem already stopped the server from starting, but only after migrations had run. It is now refused during config validation, before the database is touched, whatever auth.enabled says. The error names auth.jwt_private_key_pem.
  • Unchanged: configuration keys and defaults, and every endpoint.

0.5.1

Upgrade the registry before, or together with, producers on acdp 0.15. Rolling back to 0.5.0 is safe only while no 0.15 producer publishes to it. POST /auth/token now answers 502 (was 403) when an agent's DID document is unreachable.

This release moves the acdp dependency from 0.14.5 to 0.15.0. acdp 0.15 producers emit canonical .mmmZ timestamps (2026-12-01T00:00:00.000Z) in expires_at and data_period, and hash and sign those exact bytes. A registry still on acdp 0.14.5 (registry 0.5.0 and earlier) re-renders them and rejects the publish with hash_mismatch.

  • Who is affected: any deployment that receives publishes from producers built on acdp 0.15 or later, including the acdp-client SDK and the CLI at that version.
  • What to do: upgrade this registry first, or in the same rollout as the producers. Producers on acdp ≤ 0.14.5 keep working against 0.5.1; their bodies still verify.
  • Federation proxy. GET /contexts/{foreign ctx_id} now serves the origin's body byte-for-byte rather than re-serializing it. A consumer on acdp 0.15 recomputes content_hash over the raw body (RFC-ACDP-0001 §5.7), so before this release a foreign body whose producer spelled a timestamp any other way (+00:00, legacy …:00Z) failed hash_mismatch downstream. The proxied registry_state is now the origin's (so a retracted or superseded foreign context reports that) instead of an always-active placeholder.
  • POST /auth/token answers 502 to transient DID failures. When the agent's did:web document cannot be fetched (the DID host answers non-2xx, times out or fails TLS), or the key is absent from a stale cached copy whose forced re-fetch the per-DID rate limit refused, the token endpoint now returns 502 key_resolution_unreachable instead of 403 not_authorized. This is the same transient/permanent split the publish path applies (RFC-ACDP-0001 §5.11 step 3, conformance fixture did-001). A permanent failure (a document that is not JSON, a key still absent after a fresh fetch, an SSRF refusal) is still 403 not_authorized. A client that treats every non-200 from /auth/token as "credentials rejected" should instead retry a 502 with a new challenge. Monitoring that alerts on /auth/token 5xx will now see DID-host outages.
  • Unchanged: configuration, storage and schema, and every endpoint other than the federation proxy and POST /auth/token.
  • Rollback: safe while no acdp 0.15 producer is publishing here. Once one is, 0.5.0 rejects its publishes with hash_mismatch; stored contexts are unaffected either way.

0.5.0

One breaking wire change: POST /contexts now answers 201 Created to a first successful publish (it answered 200). A client that requires exactly 200 must accept 201. Rolling back to 0.4.x is safe.

RFC-ACDP-0003 §4 requires 201 Created with a percent-encoded Location for a fresh publish and §6.2 requires 200 OK for an idempotent replay. Earlier releases answered 200 with no Location to both (conformance fixture pub-007, the one deliberately unexercised fixture, is now exercised).

  • Who is affected: code that publishes to this registry and checks the status code for equality with 200. Clients that treat any 2xx as success (the acdp-client SDK, curl --fail, fetch().ok) are unaffected. Nothing else about the response body changed.
  • What to do before upgrading: find any caller that compares the publish status to 200 and accept 201 as well (and 200, for a replay). There is no configuration switch.
  • Location. A fresh publish carries Location: /contexts/<ctx_id>, path-relative, with the ctx_id percent-encoded as one segment: acdp://registry.example.com/<uuid> becomes /contexts/acdp%3A%2F%2Fregistry.example.com%2F<uuid>. A replay answers 200 with the same body and the same Location. Browser clients on a configured registry.cors.allowed_origins origin can read it (the reply now lists Location in Access-Control-Expose-Headers).
  • Replays are no longer re-announced. An idempotent replay used to be counted as inserted in acdp_registry_publish_total, fire a second context.published webhook with a fresh event_id, and bump the receipt and log-leaf counters. Now it counts as outcome="idempotent_replay" on every path (before, only the unpinned playground path did) and does none of the rest. Dashboards that expected replays under inserted will see them move. A replay that carries a different X-Run-Id than the original no longer produces a second run-linked context.published event.
  • Unchanged: POST /contexts/{ctx_id}/retract and /republish still answer 200; error codes and statuses; charging of the per-agent publish budget; storage and configuration.
  • Rollback: safe. No storage, configuration or schema change; 0.4.x answers 200 and any client that accepts 2xx works against both.

0.4.2

No operator-visible upgrade steps. Since 0.4.1 the only changes are the acdp dependency moving from 0.14.4 to 0.14.5 (a patch bump; no registry config, wire or storage change) and repository docs. A 0.4.1 deployment can take 0.4.2 in place.

0.4.1

No operator-visible upgrade steps. Since 0.4.0 the registry gained no runtime, config or wire change: the spec pin moved to 34f14ab (bearer_jwt is now registered upstream; the registry already advertised it), a CI guard now checks every advertised read-auth id against the pinned registry, and supply-chain and docs work landed. A 0.4.0 deployment can take 0.4.1 in place.

0.4.0

One breaking default and one new startup refusal. Lax and auth-off registries that let clients pick a tenant with X-Tenant-Id must now say so (#386), and a /0 entry in rate_limit.trusted_proxies no longer starts (#391). Rolling back to 0.3.x is safe once the key is set explicitly (below).

Breaking default (#386): an unset [auth] tenant_header_trust means none in every mode. 0.3.0 applied none only under require_tenant = true and fell back to any_peer (with a startup warning) otherwise. The fallback and its warning are gone. Strict registries see no change.

  • Who is affected: registries with require_tenant = false (lax, or auth.enabled = false) that leave the key unset and receive X-Tenant-Id. The registry no longer trusts that header, so any request carrying it without a signed tenant claim or matching [[auth.tenant_agents]] binding gets 403 not_authorized with X-Tenant-Id is not trusted from this peer (auth.tenant_header_trust = "none"); …. 0.3.0 printed auth.tenant_header_trust is not set and defaulted to "any_peer" at startup on exactly these registries, so check the old logs for it. Requests without the header are unchanged. GET /admin/status now reports the mode in effect (tenancy.tenant_header_trust), so you can check a running registry instead.
  • What to do before upgrading: if callers name their tenant with the header, declare who may send it. Set tenant_header_trust = "trusted_proxies" and list the gateway that stamps the header in rate_limit.trusted_proxies, or set "any_peer" to keep the 0.3.0 behaviour (a dev or test registry, or a boundary the registry cannot see; a non-loopback bind still warns). Environment: ACDP_REGISTRY_AUTH__TENANT_HEADER_TRUST. If nobody sends the header, do nothing: this is the safe default. The shipped docker/config.docker.toml now sets "none" explicitly. See MULTI-TENANCY.md.
  • Rollback: 0.3.x knows the key, so a config that sets it explicitly behaves the same on both versions. With the key unset, 0.3.x silently goes back to any_peer on a lax registry, so set it before rolling back. 0.2.x refuses the key (see the 0.3.0 rollback note).

New startup refusal (#391): a /0 entry in rate_limit.trusted_proxies. 0.0.0.0/0, ::/0 (or any address/0) contains every address of its family, so it trusted X-Forwarded-For from any client for the /auth/* limiter and, under tenant_header_trust = "trusted_proxies", let any client select a tenant. Startup now exits with an error naming the entry. Who needs to act: only configs with such an entry; list the proxy's own addresses instead, or use any_peer if trusting every peer really is the intent. Entries are matched as written: list IPv4 gateways in IPv4 form (an ::ffff:… entry never matches, because peers are compared after IPv4 canonicalisation).

Smaller changes, no action needed:

  • Spec pin 9deb7e7 → 6d5cdb8 (#399). Adds the strict-Ed25519 fixture sig-004 and the RFC-ACDP-0013 §6 clarification that a byte-identical lifecycle retry includes signature.value: a retry whose signature differs (for example the high-S twin of an ECDSA-P256 signature, which verifies) is refused with 400 schema_violation, not answered as an idempotent 200. No config or storage change.
  • GET /admin/status gains a tenancy group: require_tenant, the effective tenant_header_trust, whether that key was set, and the number of trusted_proxies entries. Strict JSON consumers that reject unknown keys should allow it. See HTTP-API.md.
  • GET /admin/contexts (playground builds only) honours X-Tenant-Id from an admin-bearer caller in every tenant_header_trust mode. Under require_tenant with none it used to answer 403. The admin token is already cross-tenant, so the header only narrows the listing.
  • Lifecycle charging (#393). POST /contexts/{ctx_id}/retract and /republish now charge the per-agent bucket only for an event the acdp SDK proves: the context is visible to the caller, the actor is its producer, and the signature verifies. Such an event is charged on every outcome (success, 409, a store error, a byte-identical replay), as in 0.3.0. Changed from 0.3.0: a validly signed event from an actor that is not the context's producer (403), or for an unknown or invisible context (404), is no longer charged to anyone; 0.3.0 charged it to its actor. The signature is now verified once per request instead of twice, and a non-producer did:web actor is no longer resolved. No response code, header, config key or metric label changed.
  • acdp SDK 0.14.4. 0.3.0 already shipped with it; this release adopts the lifecycle prove/commit split it added (above). Its other change, also in effect since 0.3.0: Ed25519 signatures are verified strictly (small-order public keys and nonce points are refused) on every path that verifies one — publish, lifecycle events, the auth challenge. No honest key is small-order.

0.3.0

Two behaviour changes need a look before upgrading: a deployment with [lifecycle] enabled = false over a database that holds lifecycle state no longer starts (#373) — run the pre-upgrade check below — and under require_tenant an X-Tenant-Id header is no longer honoured without a declared boundary (#374, auth.tenant_header_trust). Otherwise no config action is needed. Rolling back past this release needs the tenant_header_trust key removed first (0.2.0 refuses unknown [auth] keys). One table goes dormant, ahead of its removal, lifecycle requests are charged only once their signature verifies, and the advertised read authentication method id changes (below).

Breaking startup change (#373): lifecycle state with [lifecycle] off refuses to start. RFC-ACDP-0013 §6 says a registry that does not advertise acdp-registry-lifecycle MUST NOT emit lifecycle_events or the retracted status. Earlier releases did exactly that when lifecycle was enabled, used, then turned off: the stores project stored lifecycle state on every read regardless of the flag (it was documented as a known deviation). From this release the binary checks the store after migrations and before binding; with the flag off and any lifecycle state present it logs at ERROR and exits non-zero. Stripping the state on read instead was rejected: it would silently turn every retracted context back into an active, searchable /current head — reversing withdrawals nobody authorised.

Who is affected: only deployments whose config has [lifecycle] enabled = false (the default) while the database holds lifecycle state — i.e. lifecycle was enabled at some point, a context was retracted (or retracted and republished), and the flag was later turned off; or a backup taken while lifecycle was on was restored under a flag-off config. A registry that never enabled lifecycle is unaffected. Pre-upgrade check — run against the live database; 1 / t means this release will refuse to start under a flag-off config:

-- SQLite (`retracted` is an INTEGER 0/1 column): returns 1 or 0
SELECT EXISTS (SELECT 1 FROM lifecycle_events)
    OR EXISTS (SELECT 1 FROM contexts WHERE retracted <> 0);

-- Postgres (`retracted` is BOOLEAN): returns t or f
SELECT EXISTS (SELECT 1 FROM lifecycle_events)
    OR EXISTS (SELECT 1 FROM contexts WHERE retracted);

These are the exact probes the binary runs (has_lifecycle_state in each backend's store.rs).

If it returns 1, choose one exit before upgrading:

  1. Re-enable lifecycle (recommended): [lifecycle] enabled = true, or ACDP_REGISTRY_LIFECYCLE__ENABLED=true. The state is served exactly as before, now conformantly (the profile is advertised and the retract/republish endpoints answer).
  2. Purge the lifecycle state — irreversible; back up first. Every retracted context becomes active again. The SQL is in CONFIGURATION.md [lifecycle].

What failure looks like if you skip the check:

  • Railway: with the healthcheck configured as docker/RAILWAY.md recommends (/healthz), the new deploy never passes it (the process exits before binding), so Railway keeps the previous deployment serving and marks the new one failed. The deploy log ends with the refusing to start line and the error naming both exits.
  • docker compose: the registry container exits with status 1 and stays down; if you added a restart: policy (the shipped docker/docker-compose.yml sets none) it crash-loops. docker compose logs shows the error on every attempt.
  • Mixed Postgres fleet: all replicas must agree on [lifecycle]. A flag-off replica that is already running keeps serving whatever flag-on replicas record until its next restart, which then refuses. Set the flag identically everywhere before rolling.

Rollback: re-enable the flag (above), or roll back to the previous binary — there is no schema change in this release, so the previous binary boots against the same database and resumes the old (non-conformant) behaviour. The purge is the only one-way step, and it is never performed for you.

Wire value change (#372): read_authentication_methods is now ["bearer_jwt"]. With auth.enabled = true, GET /.well-known/acdp.json advertised ["bearer-jwt"], which fails the capabilities schema (items must match ^[a-z][a-z0-9_]*$), so strict validators rejected the whole document. The auth flow itself is unchanged. Who needs to act: no configuration action. A client that matches the exact string must accept bearer_jwt; one that also must talk to registries still on 0.2.0 should accept both. With auth disabled the field is still omitted.

Publishing no longer writes the lineages table (both backends). Nothing in the registry ever read it — lineage reads (GET /lineages/{id}, /current, the admin audit) are served from contexts — so no response changes. Rows written by earlier releases stay in place but stop being updated: if you query lineages directly (a report, a dashboard, an ad-hoc SQL check), switch to contexts, grouping by lineage_id; lineages is stale from this release on.

There is no migration in this release; the table still exists, so rolling back to 0.2.0 needs no database action (0.2.0 simply resumes writing it, and nothing reads what it writes). A later release drops the table with a new migration. That release is the one with a rollback constraint: it is safe to roll back from it to 0.3.0 or later, but not directly to 0.2.0 or earlier on Postgres, whose publishes would fail against the dropped table. Its own section here will repeat this.

Behaviour change (security fix, #375): a lifecycle request is charged only once its signature verifies. POST /contexts/{ctx_id}/retract and /republish draw on the per-agent publish bucket, keyed by the event actor. Before this release they charged that bucket before looking at the signature, so while [lifecycle] enabled was on anyone could drain an agent's publish budget with unsigned events naming it, and add a bucket per invented actor. Now an unsigned or mis-signed event costs the named agent nothing and creates no bucket. A validly signed event is charged to its actor on every outcome: success, 409 invalid_lifecycle_transition, a store error, and a 403 because the actor is not the context's producer. An actor already over budget still gets 429 before verification, as at publish. Who needs to act: nobody. No response code, header, config key or metric label changed. If you alert on acdp_registry_rate_limit_rejections_total{scope="lifecycle_per_agent"}, note that it can still be raised by unauthenticated requests naming an agent that is already over budget.

Library embedders only (#376): POST /auth/token/revoke on an AppState built without a revocation store now answers 501 not_implemented instead of a hand-built 503 service_unavailable (a code outside RFC-ACDP-0007 §5). The shipped binary always wires one, so deployments of it see no change.

Behaviour change (security fix, #374), with a config action for some strict-mode deployments and a rollback note: X-Tenant-Id is honoured only from a declared boundary. The new key [auth] tenant_header_trust = "none" | "trusted_proxies" | "any_peer" says who may select a tenant with the header when no signed tenant claim (or, for a publish, [[auth.tenant_agents]] binding) applies. trusted_proxies trusts the header only when the immediate TCP peer is listed in rate_limit.trusted_proxies (now allowed with the limiter off in this mode); the gateway there must strip or overwrite any client-supplied value. See MULTI-TENANCY.md.

  • Strict mode (require_tenant = true) no longer honours X-Tenant-Id unless tenant_header_trust declares the boundary (trusted_proxies or any_peer); with the key unset it defaults to none. Affected requests — no bearer, or a token without a tenant claim, plus the header — now get 403 not_authorized with the message X-Tenant-Id is not trusted from this peer (auth.tenant_header_trust = "none"); use a tenant-bound token or send the request through the declared gateway. Bound-agent publishes and tenant-bound tokens whose header matches are unchanged.
  • Who needs to act: strict deployments where callers name their tenant with the header. If a gateway stamps it, set tenant_header_trust = "trusted_proxies" and list the gateway in rate_limit.trusted_proxies. Startup now refuses require_tenant = true with no [[auth.tenant_agents]] and the header distrusted (no request could ever resolve a tenant), and refuses trusted_proxies with an empty rate_limit.trusted_proxies.
  • Lax and auth-off registries keep today's behaviour under the temporary default any_peer, and log a startup warning while the key is unset. The default becomes none in 0.4.0 for every mode: set the key now (any_peer to keep partitioning by header on a dev or test registry). A separate warning fires whenever any_peer is in effect on a non-loopback bind. (Done in 0.4.0: see 0.4.0.)
  • With none (or an untrusted peer under trusted_proxies), a header that is present but uncorroborated is refused with 403, not ignored, in every mode including auth-off.
  • Rollback: 0.2.0 refuses unknown [auth] keys. Remove tenant_header_trust from the config file and ACDP_REGISTRY_AUTH__TENANT_HEADER_TRUST from the environment before rolling back.

acdp SDK 0.14.3 → 0.14.4 (#392). No registry config or wire-code change. 0.14.4 verifies Ed25519 signatures strictly (small-order public keys and nonce points are refused) on every path that verifies one: publish, lifecycle events and the auth challenge. No honest key is small-order. See 0.4.0.


0.2.0

No action needed on upgrade. One optional feature, and one rollback ordering note.

[rate_limit] gains four keys — backend, backend_unavailable, backend_timeout_ms, backend_prune_seconds — all defaulted. Every config that parses today still parses and behaves identically: the default backend = "memory" is the existing per-process limiter. Setting backend = "postgres" counts the /auth/* per-IP and global ceilings across replicas in the registry's own database; see CONFIGURATION.md · [rate_limit] and OPERATIONS.md · Rate limiting. It is only useful with more than one replica, and is refused at startup unless all of these hold: storage.backend = "postgres" (which in turn needs a storage-pg build), rate_limit.enabled = true, global_per_minute above 0, and both backend_timeout_ms and backend_prune_seconds above 0. Each refusal names its fix.

On Postgres, upgrading applies migration 014 (a new, additive rate_limit_windows table; nothing an older binary reads). 0.2.0 and later tolerate a database that a newer release has already migrated (#345), so rolling back between such releases needs no database action. 0.1.5 does not: it refuses to start against a database that has applied migration 14. To roll back to 0.1.5, first run DELETE FROM _sqlx_migrations WHERE version = 14; (the orphaned rate_limit_windows table is harmless and a later upgrade re-applies the migration idempotently).

Rollback ordering. [rate_limit] rejects unknown keys, so a config carrying any of the four new keys will not parse on an older binary. Roll the config back before the binary.

acdp SDK 0.14.1 → 0.14.3. No wire change for this registry: no status code or error.code moved. 0.14.2's server fix restores two RFC-ACDP-0014 checks for the interim acdp:key-revocation form on registries advertising acdp_version in [0.3.0, 0.5.0); this registry always advertises 0.5.0, where that form is already refused (see 0.1.5 below). 0.14.2 also keeps the underlying cause in the SDK client's transport errors, so some upstream-failure messages may be more specific. 0.14.3 is a release-pipeline fix only.


0.1.5

Bumps acdp 0.13.1 → 0.14.1 and adopts ACDP spec pin 16211e6 → 9deb7e7. Three behavior changes reach the wire; all are conformance fixes closing gaps against RFC-ACDP-0014, not new features.

Wire change: a new publish under the interim acdp:key-revocation custom type is now rejected (400 schema_violation).

you publishbefore 0.1.50.1.5
context_type: "key-revocation" (standard form)acceptedaccepted — unchanged
context_type: "acdp:key-revocation" (interim form), schema-validaccepted400 schema_violation
context_type: "acdp:key-revocation", schema-invalid400 schema_violation400 schema_violation — unchanged

This registry has always advertised acdp_version >= 0.5.0 (see acdp_version_claim() in crates/acdp-registry-server/src/main.rs), which RFC-ACDP-0014 §10 requires to retire the interim form outright — but the retirement gate itself only shipped in the acdp SDK at 0.14.0, so before this release a schema-valid interim-form publish was accepted despite the registry's own advertised version. A body already published under the interim form before you upgrade is unaffected and keeps being served normally — this is non-retroactive.

Who needs to act: only a publisher still emitting acdp:key-revocation. Switch to the standard key-revocation context_type; nothing else about the record shape changes.

Wire change: superseding a key-revocation record with a non-revocation body is now its own rejection reason.

supersession attemptbefore 0.1.50.1.5
non-revocation predecessor → revocation successoraccepted — unchangedaccepted — unchanged
revocation (or interim-form) predecessor → revocation successoraccepted — unchangedaccepted — unchanged
revocation (or interim-form) predecessor → non-revocation successornot specifically checked400 superseded_target, details.reason: "revocation_type_mismatch"

RFC-ACDP-0014 §4's 0.5.0 amendment. RevocationTypeMismatch did not exist as a rejection reason before the 0.14.0 SDK; the closest earlier behavior fell through to whatever generic supersession handling applied, not this specific check. Who needs to act: only a client that supersedes a key-revocation record with a body that is not itself a revocation — branch on error.details.reason == "revocation_type_mismatch" if you need to distinguish this case from other superseded_target rejections.

Billing-visible change: a did:web publish that fails late (a store error, a duplicate-publish race) is now charged. did:key and playground-pinned publishes already charged a late failure, so three of the four publish branches now do. The fourth, playground-unpinned, never charges one, by design and permanently: nothing is verified on that branch, so there is no proven identity to charge. No client action needed — this only affects an already-failing request, and only whether it counts against the publish budget.

Billing-visible change, did:key and pinned publish only: a validly-signed but schema-invalid publish is now an uncharged rejection. Before 0.1.5 this specific case (signature/hash correct, body schema-invalid) was charged before being rejected; now the rejection happens during identity proof, before the charge is armed. Who needs to act: nobody. This only removes a charge from an already-rejected request; every other did:key/pinned rejection reason is unaffected.


0.1.4

Fix: registry.tls.enabled = true now works. It aborted the process at startup before this release.

before 0.1.40.1.4
tls.enabled = false (the recommended topology)serves plain HTTPunchanged
tls.enabled = true with a valid cert/keylogs listening, then exits 101; port refuses connectionsserves HTTPS
tls.enabled = true with a missing cert/keyclean error, exit 1unchanged

No configuration changes. If you had tls.enabled = true and concluded your certificate paths were wrong, they may well have been fine — the binary enabled two rustls crypto providers and installed neither, so rustls refused to choose. It now installs ring explicitly, before the listening log, so a failure can no longer follow a success line.

Who needs to act: nobody. This only removes a failure mode. Operators terminating TLS upstream — the recommended topology — were never affected.

Wire change: POST /contexts/{ctx_id}/retract and /republish now reject an unaccepted Content-Type with 415.

These two producer-facing lifecycle writes were the last routed body-bearing handlers with no media-type gate. They now behave exactly as POST /contexts and the /admin/* lifecycle endpoints do:

what you sendbefore 0.1.40.1.4
application/acdp+json, with or without ; charset=utf-8parsedparsed — unchanged
application/jsonparsedparsed — unchanged
no Content-Type headerparsedparsed — unchanged
text/plain, application/xml, anything else400 schema_violation415 unsupported_media_type

Who needs to act: only a client sending a Content-Type on these two routes that is neither application/json nor an application/*+json type. Parameters are ignored and an absent header is still accepted.

All five routed body-bearing handlers now share one accept-set: POST /contexts, the two routes above, and the two /admin/* lifecycle endpoints.

Wire change: /auth/* answers 400, not 422, on a wrong-shaped body.

A request to /auth/challenge, /auth/token or /auth/token/revoke whose body is valid JSON but does not match the endpoint's schema previously returned 422 Unprocessable Entity with error.code = "schema_violation". It now returns 400 Bad Request with the same code.

422 was never a deliberate choice: it is the status axum's JSON extractor attaches to a deserialization failure, and it reached the wire because the rejection passed that status through. RFC-ACDP-0007 §5 pins schema_violation to 400, and 422 appears nowhere in that RFC — so the old pair was a status/code combination the protocol does not define.

Who needs to act: any client branching on 422 from /auth/*. Branch on error.code instead — it is unchanged (schema_violation), and it is the part RFC-ACDP-0007 §5 actually pins.

Explicitly unchanged, because collapsing these into 400 is the way this fix could have gone wrong:

casestatus
body exceeds the size limit413 — unchanged
unacceptable Content-Type415 — unchanged
malformed JSON400 — unchanged

Wire change: /admin/* lifecycle endpoints now reject an unaccepted Content-Type with 415.

POST /admin/contexts/{ctx_id}/retract and .../republish were the last two routed body-bearing handlers with no media-type gate. They now behave exactly as POST /contexts does (see the entry below): an unacceptable Content-Type is 415 unsupported_media_type, media-type parameters are ignored, and an absent Content-Type is still accepted. Same extractor, same accept-set — not a third implementation.

Wire change: POST /contexts now rejects an unaccepted Content-Type with 415.

Before 0.1.4 this endpoint never looked at Content-Type. It parsed the body whatever the header said, so a request sent as text/plain was answered 400 schema_violation. It now answers 415 unsupported_media_type and does not parse the body at all.

This is a conformance fix — RFC-ACDP-0007 §4.1/§5 and spec fixture err-002 require it, and schema_violation was stating something false, since it asserts a structural validation that never ran. But it is a behaviour change on a success-adjacent path, so check it before upgrading:

what you send to POST /contextsbefore 0.1.40.1.4
application/acdp+jsonparsedparsed — unchanged
application/acdp+json; charset=utf-8parsedparsed — unchanged
application/jsonparsedparsed — unchanged
no Content-Type headerparsedparsed — unchanged
text/plain, application/xml, anything else400 schema_violation415 unsupported_media_type

Who needs to act: only a client sending a Content-Type that is neither application/json nor an application/*+json type. Media-type parameters are ignored, so ; charset=utf-8 is fine, and omitting the header entirely is still accepted — that was deliberate, because requiring it would break every publisher that does not send one.

A client that already sends application/acdp+json, or none at all, sees no difference.

Unchanged: what is hashed and what is verified. The handler still receives the raw bytes and the content hash is still recomputed from the re-serialized request, exactly as before.

Not changed in this release: /auth/* continues to reject a missing Content-Type with 415, which it always has.

Correction (2026-10): this note originally also said "/admin/* handlers are still ungated (tracked separately)". That was already false when 0.1.4 was tagged: as the entry above says, the two /admin/* lifecycle endpoints gate Content-Type exactly as POST /contexts does. The other /admin/* routes read no request body, so there is nothing to gate.

Upgrade the image and docker/docker-compose.yml together. If you deploy the compose recipe, pulling one without the other does not boot.

docker-compose.yml now passes ACDP_REGISTRY_AUTH__ENABLED through as an empty value when you have not set ACDP_REGISTRY_AUTH_ENABLED (this is the ${VAR:-} form, and it is deliberate — see below). A registry binary from before 0.1.4 rejects an empty boolean outright with invalid type: string "", so:

you pullresult
new compose file + new imageboots
new compose file + old imagefails at startup, before serving a request
old compose file + new imageboots

What changed underneath. An empty ACDP_REGISTRY_* environment variable is now treated as absent rather than as an override of the config file. Previously an empty value either replaced your TOML value with "" (string fields) or failed the config load outright (numbers, booleans, and list fields). This exists because docker compose renders an unset variable as set-to-empty rather than absent, so every ${VAR:-} passthrough silently overwrote the config file.

If you relied on an empty variable to clear a config-file value, that no longer works — set the variable to the value you want, or remove it from the config file. Any empty variable that was ignored is named in a startup warn!, so a log line tells you which.

New capability: you can now enable auth from the environment — ACDP_REGISTRY_AUTH_ENABLED=true — where previously the compose recipe offered no env path and you had to edit config.docker.toml. Note that only the ${VAR:-} form is safe to add as a passthrough: ${VAR:-false} renders the literal string "false", which is a real value and would override a true you set in your config file.


0.1.3 and earlier

No operator-visible upgrade steps recorded. These versions predate this file; their changes are in the per-crate changelogs and were not reviewed against this file's criteria retroactively. Absence here is not a statement that none existed.