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_resolutionenabled, and only clients that branch on the status of a failed foreign retrieval. A 5xx is still a 5xx; a client that matchedinternal_errorshould matchcross_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
lineagesdirectly (a report, dashboard or ad-hoc SQL). Querycontextsgrouped bylineage_idinstead; 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 malformedauth.jwt_private_key_pemalready stopped the server from starting, but only after migrations had run. It is now refused during config validation, before the database is touched, whateverauth.enabledsays. The error namesauth.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
acdp0.15 or later, including theacdp-clientSDK 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'sbodybyte-for-byte rather than re-serializing it. A consumer onacdp0.15 recomputescontent_hashover the rawbody(RFC-ACDP-0001 §5.7), so before this release a foreign body whose producer spelled a timestamp any other way (+00:00, legacy…:00Z) failedhash_mismatchdownstream. The proxiedregistry_stateis now the origin's (so a retracted or superseded foreign context reports that) instead of an always-activeplaceholder. POST /auth/tokenanswers502to transient DID failures. When the agent'sdid:webdocument 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 returns502 key_resolution_unreachableinstead of403 not_authorized. This is the same transient/permanent split the publish path applies (RFC-ACDP-0001 §5.11 step 3, conformance fixturedid-001). A permanent failure (a document that is not JSON, a key still absent after a fresh fetch, an SSRF refusal) is still403 not_authorized. A client that treats every non-200 from/auth/tokenas "credentials rejected" should instead retry a502with a new challenge. Monitoring that alerts on/auth/token5xx 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
acdp0.15 producer is publishing here. Once one is, 0.5.0 rejects its publishes withhash_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 (theacdp-clientSDK,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
200and accept201as well (and200, for a replay). There is no configuration switch. Location. A fresh publish carriesLocation: /contexts/<ctx_id>, path-relative, with thectx_idpercent-encoded as one segment:acdp://registry.example.com/<uuid>becomes/contexts/acdp%3A%2F%2Fregistry.example.com%2F<uuid>. A replay answers200with the same body and the sameLocation. Browser clients on a configuredregistry.cors.allowed_originsorigin can read it (the reply now listsLocationinAccess-Control-Expose-Headers).- Replays are no longer re-announced. An idempotent replay used to be counted as
insertedinacdp_registry_publish_total, fire a secondcontext.publishedwebhook with a freshevent_id, and bump the receipt and log-leaf counters. Now it counts asoutcome="idempotent_replay"on every path (before, only the unpinned playground path did) and does none of the rest. Dashboards that expected replays underinsertedwill see them move. A replay that carries a differentX-Run-Idthan the original no longer produces a second run-linkedcontext.publishedevent. - Unchanged:
POST /contexts/{ctx_id}/retractand/republishstill answer200; 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
200and 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, orauth.enabled = false) that leave the key unset and receiveX-Tenant-Id. The registry no longer trusts that header, so any request carrying it without a signedtenantclaim or matching[[auth.tenant_agents]]binding gets403 not_authorizedwithX-Tenant-Id is not trusted from this peer (auth.tenant_header_trust = "none"); …. 0.3.0 printedauth.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/statusnow 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 inrate_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 shippeddocker/config.docker.tomlnow 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_peeron 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-004and the RFC-ACDP-0013 §6 clarification that a byte-identical lifecycle retry includessignature.value: a retry whose signature differs (for example the high-S twin of an ECDSA-P256 signature, which verifies) is refused with400 schema_violation, not answered as an idempotent200. No config or storage change. GET /admin/statusgains atenancygroup:require_tenant, the effectivetenant_header_trust, whether that key was set, and the number oftrusted_proxiesentries. Strict JSON consumers that reject unknown keys should allow it. See HTTP-API.md.GET /admin/contexts(playground builds only) honoursX-Tenant-Idfrom an admin-bearer caller in everytenant_header_trustmode. Underrequire_tenantwithnoneit 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}/retractand/republishnow charge the per-agent bucket only for an event theacdpSDK 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-producerdid:webactor is no longer resolved. No response code, header, config key or metric label changed. acdpSDK 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:
- Re-enable lifecycle (recommended):
[lifecycle] enabled = true, orACDP_REGISTRY_LIFECYCLE__ENABLED=true. The state is served exactly as before, now conformantly (the profile is advertised and the retract/republish endpoints answer). - Purge the lifecycle state — irreversible; back up first. Every retracted context becomes
activeagain. The SQL is in CONFIGURATION.md[lifecycle].
What failure looks like if you skip the check:
- Railway: with the healthcheck configured as
docker/RAILWAY.mdrecommends (/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 therefusing to startline and the error naming both exits. - docker compose: the
registrycontainer exits with status 1 and stays down; if you added arestart:policy (the shippeddocker/docker-compose.ymlsets none) it crash-loops.docker compose logsshows 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 honoursX-Tenant-Idunlesstenant_header_trustdeclares the boundary (trusted_proxiesorany_peer); with the key unset it defaults tonone. Affected requests — no bearer, or a token without atenantclaim, plus the header — now get403 not_authorizedwith the messageX-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 inrate_limit.trusted_proxies. Startup now refusesrequire_tenant = truewith no[[auth.tenant_agents]]and the header distrusted (no request could ever resolve a tenant), and refusestrusted_proxieswith an emptyrate_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 becomesnonein 0.4.0 for every mode: set the key now (any_peerto keep partitioning by header on a dev or test registry). A separate warning fires wheneverany_peeris in effect on a non-loopback bind. (Done in 0.4.0: see 0.4.0.) - With
none(or an untrusted peer undertrusted_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. Removetenant_header_trustfrom the config file andACDP_REGISTRY_AUTH__TENANT_HEADER_TRUSTfrom 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 publish | before 0.1.5 | 0.1.5 |
|---|---|---|
context_type: "key-revocation" (standard form) | accepted | accepted — unchanged |
context_type: "acdp:key-revocation" (interim form), schema-valid | accepted | 400 schema_violation |
context_type: "acdp:key-revocation", schema-invalid | 400 schema_violation | 400 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 attempt | before 0.1.5 | 0.1.5 |
|---|---|---|
| non-revocation predecessor → revocation successor | accepted — unchanged | accepted — unchanged |
| revocation (or interim-form) predecessor → revocation successor | accepted — unchanged | accepted — unchanged |
| revocation (or interim-form) predecessor → non-revocation successor | not specifically checked | 400 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.4 | 0.1.4 | |
|---|---|---|
tls.enabled = false (the recommended topology) | serves plain HTTP | unchanged |
tls.enabled = true with a valid cert/key | logs listening, then exits 101; port refuses connections | serves HTTPS |
tls.enabled = true with a missing cert/key | clean error, exit 1 | unchanged |
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 send | before 0.1.4 | 0.1.4 |
|---|---|---|
application/acdp+json, with or without ; charset=utf-8 | parsed | parsed — unchanged |
application/json | parsed | parsed — unchanged |
no Content-Type header | parsed | parsed — unchanged |
text/plain, application/xml, anything else | 400 schema_violation | 415 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:
| case | status |
|---|---|
| body exceeds the size limit | 413 — unchanged |
unacceptable Content-Type | 415 — unchanged |
| malformed JSON | 400 — 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 /contexts | before 0.1.4 | 0.1.4 |
|---|---|---|
application/acdp+json | parsed | parsed — unchanged |
application/acdp+json; charset=utf-8 | parsed | parsed — unchanged |
application/json | parsed | parsed — unchanged |
no Content-Type header | parsed | parsed — unchanged |
text/plain, application/xml, anything else | 400 schema_violation | 415 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 pull | result |
|---|---|
| new compose file + new image | boots |
| new compose file + old image | fails at startup, before serving a request |
| old compose file + new image | boots |
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.