Engineering log
The narrative record of what changed in this repository and why — the reasoning, the rejected alternatives, and the evidence behind each change. Entries are newest-first.
This is deliberately not the changelog. Per-release notes live in the eight
per-crate crates/*/CHANGELOG.md files, which release-plz maintains, and in
the GitHub Releases that mirror them. This file was the root CHANGELOG.md
until it was retired: it had accumulated thousands of lines under a single
## [Unreleased] heading while 0.1.0, 0.1.1 and 0.1.2 had all shipped, so
it asserted that nearly all of its own content was unreleased. See decision 15
in DECISIONS.md, which records the measurement and the command that reproduces
it.
Finding the release that contains an entry. No release attribution is written into this file, because a hand-written one goes stale at the next release — the exact defect that retired it. Derive it instead:
# which commit last touched the lines of an entry
git blame -L <start>,<end> -- docs/ENGINEERING-LOG.md
# which releases contain that commit (earliest one shipped it)
git tag --contains <commit> | grep '/v'Position is not a reliable guide to age: entries have been inserted under pre-existing category headings rather than strictly prepended, so a section can hold entries from several releases. Use the commands.
Entries
acdp-rs guide links re-pinned to acdp-v0.15.0
The move to acdp 0.15.0 (#435) left 20 acdp-rs guide links pinned to 424253b, a commit older than the tag the code now depends on:
the drift the 2026-10-08 refresh chose to handle by a maintainer re-check rather than a guard. The re-check is this change. The links
now name the tag acdp-v0.15.0 rather than a SHA, so the pin reads as the dependency version; every linked page and anchor was checked
present at the tag first. The same change records the owner's 2026-10-10 confirmation of the three publish-201 extras.
Publish answers 201 Created with a Location (U-526 resolved, 0.5.0)
POST /contexts answered 200 with no Location for every success, a deviation from RFC-ACDP-0003 §4 that conformance fixture pub-007
had been the single deliberately unexercised row for. The SDK already told the two cases apart (PublishCommitOutcome::Inserted /
IdempotentReplay) and the handler discarded it, so the fix was to keep it: the reply is now 201 + Location for a fresh publish and
200 + the same Location for a replay. Rejected: a config flag (it would make pub-007 unpinnable and be a permanent conformance-off
switch), an absolute Location (the fixture and RFC example are path-relative), and detecting replays with a pre-commit lookup (races).
The same outcome made three corrections free: replays are counted idempotent_replay on every path, and the webhook, receipt and log-leaf
counters fire on insert only. The test change was ~115 assertions, flipped by hand-checked fresh-versus-replay decisions, never loosened
to accept both codes. The mutation ratchet had to be re-measured because context.rs is a scoped file: the pins come from a run, not an edit.
Docs refresh 2 (2026-10-08) — pins, process truth, link-first
The first refresh (October 3) fixed what the code contradicted; five days and 43 commits later the same pages had drifted again,
mostly in places no guard can see. Findings: 18 acdp-rs references pinned to a commit older than the tag the code depends on
(sibling_repo_links_are_pinned accepts any 40-hex sha, so a valid but stale pin passes); three required CI checks still described
as advisory; the branch-protection snapshot predating its own application; a handful of statements about tenancy, revocation feeds
and the crate graph that the code contradicts. Decisions: the acdp-rs guides are pinned to the 2026-10-08 refresh commit
(424253b), not the 0.14.5 tag; the spec pin stays (the newer spec commit is non-normative); publish's 200 with no Location
against RFC-ACDP-0003 §4 is disclosed, not changed (U-526 stays the owner's call); no stale-pin guard was built (STATUS.md: no new
guards without a trigger), MAINTAINING.md tells the maintainer to check the pin after an acdp bump instead. Rejected: bumping
.spec-pin to read the spec's refreshed docs/version-matrix.md.
FEAT-06 item 3 — a shared /auth/* limiter, built ahead of need (#344)
[rate_limit] could only count per process. With more than one replica the per-IP budget and the
global ceiling were each multiplied by the replica count, and the docs' answer was "front the
deployment with a shared limiter". [rate_limit] backend = "postgres" now counts both ceilings once
across replicas, in the registry's own database.
Built ahead of need, on purpose, and recorded as such. Every deployment recipe in this repo runs
exactly one instance (docker/docker-compose.yml cannot bind a second; docker/RAILWAY.md has no
replica story; there are no Kubernetes manifests), so nothing deployed today benefits. The plan's last
review recommended shipping only the migration-runner rollback fix and deferring the rest; the work
was done anyway at the maintainer's explicit request. Nothing here is urgent, and a single-replica
operator should leave backend = "memory".
What was built, in order. A narrow SharedRateLimitBackend seam (two /auth/* scopes only; the
per-agent publish and challenge budgets stay per-process) with the in-memory limiter as the first
implementation. A PgRateLimitBackend over the existing pool: one atomic
INSERT … ON CONFLICT DO UPDATE per check, the database as the clock, an UNLOGGED table named
rate_limit_windows. A LayeredRateLimiter putting the in-memory limiter in front. Config keys,
startup validation, wiring and a pruner. Then a two-routers-one-database proof.
Decisions worth keeping.
- The window roll is monotonic. The naive
window_start = EXCLUDED.window_starthas a real bug: a statement whosenow()was fixed before it queued on the hot row's lock can roll the window backwards and hand the next request a fresh budget.GREATESTplus anEXCLUDED > btest fixes it;a_stale_window_never_rolls_the_counter_backwardsgoes red under the naive form. - The in-memory limiter is a permanent pre-filter, not an optimisation. Asked first, with the same
limits, it makes the database's write rate a function of the configured limits instead of attacker
volume (at most
2 × global_per_minutestatements per in-memory limiter window per replica) and means enabling the shared backend can only tighten enforcement. That bound needs the global budget on, so startup refusesbackend = "postgres"withglobal_per_minute = 0— a condition the first draft of the claim omitted and a review caught. - Fail open, config-flippable. An unreachable database degrades to per-process limiting, because
/auth/*limiting is a resource bound with no brute-forceable secret behind it, and refusing token issuance on a database blip is the worse failure.backend_unavailable = "deny"flips it. UNLOGGED. Removes the commit fsync from the hot-row lock hold. The cost is one window's budget resetting after a crash or a standby promotion.- Rejected: an L2-only limiter (database load becomes attacker-controlled); a Redis dependency
(none exists in the repo; Postgres already carries the BUG-06 challenge and revocation stores);
sharding the global counter (breaks single-statement exactness; not warranted below tens of
thousands of requests a minute across several replicas); an index on
window_start(defeats HOT updates).
Measured, with caveats. The auth_global hot row was first estimated at roughly 500–2000
updates/s. A pgbench run of the exact upsert against one row (PostgreSQL 18 in Docker on a laptop,
no network hop) gave ~34k updates/s at 8 clients and ~26k/s at 32 on the UNLOGGED table, against
~3.3k/s and ~2.6k/s for the same table LOGGED — about 10x, which also confirms the UNLOGGED
choice. The default load is ~100 updates/s. A real deployment adds a network round trip and shares the
database with publish traffic, so these are upper bounds; acdp_registry_rate_limit_shared_seconds
exists to check the real figure in production.
U-501 addendum — the did:web gap closes, and a plan's acceptance criterion doesn't survive contact with the shipped API
#242 closed two of four publish branches by hand in September: publish_identity_proven_offline
(a hand-rolled hash+signature duplicate) let did:key charge a late failure, and
enforce_pinned_signature did the same for the playground-pinned branch. The did:web production
branch was the one gap left — establishing identity from the registry side would have meant a
second DID-document resolution per publish, a second SSRF surface, and a cache that could disagree
with the SDK's. The design this repo wanted from the SDK instead is written down in
plans/cross-repo/acdp-rs-publish-charge-seam.md and was filed upstream.
acdp-rs shipped it in v0.14.0: Proven<'a> / prove_publish_identity /
prove_publish_identity_did_key / prove_publish_identity_pinned / commit_proven (acdp-rs#273).
Issue #336 tracked adopting it. The did:web branch now proves identity via the SDK's real DID
resolution, arms the charge, then commits — no new I/O, matching the design ask exactly. did:key
and pinned were rewired onto the same prove/commit shape, which deletes the hand-rolled did:key
duplicate: the SDK's own publish_verified_did_key_in_tenant is now defined as
prove_publish_identity_did_key + commit_proven, so there is nothing left for a separate
in-repo check to duplicate.
The part worth recording is what happened to one line in the adoption plan, not the adoption itself. The plan — written and reviewed before implementation — specified, as its most heavily flagged acceptance criterion, that a failed did:key/pinned proof must NOT be rejected: it had to reproduce the deleted oracle's "false = don't charge, never reject" contract, staying an accepted, uncharged publish. Implementing that literally turned out to require falling back to committing genuinely unverified content on a failed proof — the only 0.14.0 path that could satisfy the letter of the criterion admits exactly and only forged, signature-invalid did:key publishes. That is not preserving behavior; it is a new hole.
The premise turned out to already be false one version earlier than the plan assumed. The "real"
SDK call the old oracle's false used to fall through to ran a strict superset of the oracle's own
checks even in acdp-server 0.13.1 — so nothing the oracle ever rejected was genuinely being
accepted downstream. Two tests already in this repo's suite had been pinning rejection, not
acceptance, for these exact cases all along
(naming_a_victim_does_not_spend_their_budget, a_replayed_envelope_over_a_different_body_does_not_spend_the_budget)
— the record contradicted the plan's premise before the plan was ever read against it.
A written, human-reviewed design document was wrong about a security-relevant behavior, and the
way that surfaced was a fresh agent re-deriving the claim from source rather than trusting the
document's framing — the same discipline /reconcile applies to this repo's own UNCONFIRMED
entries, run here against another repo's plan instead. The fix: reject on a failed proof for
did:key/pinned too, matching did:web. DECISIONS.md's "U-501 addendum" entry carries the full
analysis; this file just marks that a plan being reviewed once does not make it correct forever,
and does not exempt an adoption from checking it against the code that actually shipped.
One smaller, real side effect rides along: because the SDK's prove functions bundle schema validation into the identity proof (the deleted oracle didn't), the charge-arm point for did:key/ pinned moves slightly later. A validly-signed but schema-invalid publish — which the old oracle deliberately charged, on the theory that a noisy-but-signed producer should still pay — is now an uncharged rejection instead. Judged benign (every such rejection now fails before the one expensive step, the signature verify) and recorded rather than left for a future reader to rediscover as a mystery.
U-561 — ASSUMPTIONS.md's open entries, and what counting them cost
U-507 reconciled this file's open entries in September. U-561 asked a narrower question: of the declarations still open, which name an event that has since happened? An entry like that is stale in the worst way — it reads as an open question that was in fact answered, so the record asserts more doubt than the evidence supports.
Answer: one of seventeen. U-556's NEEDS-CHANGE ("child stdout is not drained during the
probe") specified a structural fix — file-backed stdio for the spawned child — and that fix landed
in U-560 (#327). The resolution was already appended to the file; the status line 110 lines above
it still read NEEDS-CHANGE with nothing pointing forward. Settled, with the evidence.
The other sixteen are correctly open. Their defects are citation decay and missing triggers, not wrong status — which is a different unit's worth of work from what the backlog implied.
The expensive part was the count, and it is the part worth recording. Four numbers were produced for "how many open declarations are there", and the first three were all wrong:
| count | method | verdict |
|---|---|---|
| 37 | grep -c UNCONFIRMED | 28 of the 37 are prose |
| 9 | anchored ^- \*\*Status:\*\* UNCONFIRMED | 2.9x undercount |
| 26 | first corrected extractor | overcount — a 400-char window bled into the next line, and **UNCONFIRMED → CONFIRMED.** resolution headings read as open |
| 17 | transition-aware extractor, self-tested against 9 known-closed and 17 known-open fixtures before its number was quoted | holds; all 31 unflagged token-bearing lines then read individually |
The 9 failed for a reason this repository had already written down: U-507's own census records
that statuses here appear in six shapes, including thirteen times as a bullet or heading label with
no Status word at all. The pattern that produced 9 was derived from the entries its author had
written personally — which is precisely the shape that misses what somebody else wrote. Search the
record before writing the sweep; it may already contain the taxonomy, better.
Two near-misses would each have closed an entry wrongly, and both were caught only by refusing to take a grep's word:
- "there is no
ETaganywhere" —git grep -il etagdoes return a file. It is a comment reading "the 404 carries no ETag", which corroborates the entry it appears to refute. - "the conformance fixtures do not cover the RFC-ACDP-0014 §4 reject path at all" — conformance
now cites "RFC-ACDP-0014 §4/§5" in several places, but
rev-001is a single-vector ACCEPT golden for §5 step 2. Same section label, different path.
One entry named nothing that could ever close it — no Settled by:, no owner, no condition. An
open status with no trigger is open by construction rather than by evidence, which is the same
defect as a fired trigger pointed the other way. It now carries a falsifiable one.
Citation decay, including this file's own
Line numbers into a moving file decay; a line number citing one named commit's diff does not, because that diff is immutable. Cite a symbol or a quoted string for live code.
The section above titled "Two references this unit made stale, outside its path grant" — a unit recording references it had invalidated — now has that exact problem itself:
ASSUMPTIONS.md:2872("entry 9,mutants.ymlderives the spec pin") → the heading is at:2881;:2872is unrelated prose about JSON whitespace.ASSUMPTIONS.md:315(bump-spec-ref.ymlrequestingpermission-workflows) → the claim is at:313-314;:315lands in the right entry on the wrong sentence.
Both corrected by appending, not by editing another unit's lines. Two more were found inside
ASSUMPTIONS.md itself and are corrected there.
What this unit does not fix
Not an exhaustive list. W2-U3 asserts Status: CONFIRMED and, on the next line, "PARTIALLY
narrowed, still UNCONFIRMED" — both its own words. Left for that entry's owner to resolve rather
than settled from outside, and recorded so it is not mistaken for closed. Eleven entries remain
gated on a human ruling, an operator observation, a coordinator decision, or the scheduling of
another unit; one of those waits on a settings decision that is itself blocked on a credential
nobody has granted, so it cannot fire.
U-560 — asserting the resolved property, not the string that requests it
Two independent defects in crates/acdp-registry-server/tests/tls_startup.rs, shipped
together because they are the same mistake in two registers: a check that reads a
request and reports it as a result.
1. rustls_is_a_normal_dependency scanned a manifest and claimed a feature set.
It section-scanned this crate's Cargo.toml, asserted the rustls line contains
"ring" and does not contain aws-lc-rs, under the message "requesting both providers
is the original defect: rustls cannot choose and panics at startup". It passed — while
the crate resolved both providers:
rustls v0.23.45 [aws-lc-rs,aws_lc_rs,default,log,logging,
prefer-post-quantum,ring,std,tls12]A manifest line cannot show a resolved feature set. aws_lc_rs has two independent
enablers and ring has five; exactly one of those seven edges appears on the line the
scan reads, and the edge that decides the question arrives as an absence — the
missing default-features = false. No amount of care with the string fixes that, because
the string is not where the answer lives.
The replacement asserts the consequence this process can observe: with rustls unable to
select a provider from crate features, ClientConfig::builder() panics, which is exactly
why install_crypto_provider() (main.rs:99-108) exists. It pins the panic message,
not merely is_err(), because builder has two documented panics and an is_err() check
would pass on the wrong one.
What that panic proves is stated no wider than it is. from_crate_features() returns
None for three feature states (rustls-0.23.45/src/crypto/mod.rs:259-263): two
providers, no providers, or custom-provider. So the asserted invariant is the one all
three share — rustls cannot pick a provider unaided, so main must install one. A
compile-time reference to rustls::crypto::ring::default_provider rules out the
"no providers" reading; custom-provider is left unruled-out and the comment says so
rather than implying coverage.
The manifest scan was kept — but not, as a first draft of this entry said, because
normal-vs-dev-only is otherwise unobservable. That is wrong: since U-530 main.rs:100
names rustls:: in the bin, and Cargo does not expose dev-dependencies to bins, so
moving the line fails the binary's own compile with E0433 and produces zero test
results. The dev-only branch of the scan can never print.
What earns those lines is the separate ring assertion, which is live and which no
compile gate reaches: drop features = ["ring"] and the bin still compiles, because ring
resolves anyway through reqwest/hyper-rustls/tokio-rustls/sqlx-core unification —
while this test goes red. The crate must keep making its own direct request for its
recorded provider choice rather than inheriting it by accident of the graph.
This assertion is an invariant rather than a snapshot because the two-provider state is
now permanent by a human decision of 2026-09-19 — recorded on the lane board as U-563,
WONTFIX — that both providers stay, so install_default stays load-bearing. That id
resolves nowhere in this repository, and by this unit's own rule (a self-referential
citation is not a citation) it cannot be the record. This paragraph is therefore the
record: the change that would leave one provider is switching axum-server to
tls-rustls-no-provider in the root Cargo.toml and adding default-features = false
to this crate's rustls line — both, since each enabler is independently sufficient — and
the decision was not to.
Why the decision went that way, since tls_startup.rs:16 points here for the
reasoning rather than re-deriving it. This crate wants ring specifically
(crates/acdp-registry-server/Cargo.toml:41-43 — aws-lc-rs drags aws-lc-sys and
prebuilt-nasm, a C/asm build dependency, into the shipped binary's TLS path). But rustls
declares prefer-post-quantum = ["aws_lc_rs"], so a ring-only build gives up
post-quantum hybrid key exchange. Post-quantum won, and both providers stay. Note what is
not part of that trade: TLS 1.2 is provider-independent (tls12 = []), and an
aws_lc_rs-only build would be both single-provider and post-quantum — the constraint is
that this crate wants ring, not that no single-provider form keeps post-quantum. Both
were asserted the other way earlier in this unit and corrected against the manifests.
2. The larger half: the diagnostic a reader actually meets carried nothing.
The child's stdout/stderr were captured only on the early-exit branch. Every other failure — the common case — reported a probe error with none of the registry's own output.
Those two paths are not the alternatives they appear to be, and the control that proved
it also explains why nobody noticed. The retry loop breaks on a non-retryable probe
error, and a timeout is non-retryable (ProbeError::io:
retryable: stage.io_failure_may_be_transient() && !timed_out). So when another process
already holds the port without accepting, the first probe times out in the handshake,
the loop breaks before its next try_wait, and the early-exit branch never runs even
though the child has already exited. Measured with a listener that binds 18443 without
accepting: the child exits in 0.06 s with Address already in use, and the test lands on
the probe assertion, three runs of three.
The qualifier is load-bearing and a first draft of this entry omitted it. A holder that
accepts and closes produces UnexpectedEof, which is retryable, so the loop
survives to its next try_wait and the early-exit branch fires normally — verified with
the same harness in accept-close mode. So the branch that is skipped turns on whether
the holder accepts, not on "port in use" as a cause; the likelier real holder is a server,
which accepts. The narrower true statement is still what justifies the change: a probe
failure can arrive with the child already dead and its output unread.
The child now writes to files in the tempdir the test already owns, and the probe-failure
assertion reads them. Output entering a panic message is capped at the first 64 KiB
per stream with the cut and both byte counts disclosed — the head, because this is a
startup diagnostic and the signal is the first thing the binary said. There is no
"full log at <path>" pointer: the tempdir is deleted when the test returns, so it would
dangle by the time anyone followed it.
A thing the control showed in passing, still unfixed. The child logs
{"message":"listening","addr":"127.0.0.1:18443"} and then fails to bind with
Address already in use. The log line asserts something that is not true yet. The
install/listening ordering this module's docstring describes is still unasserted by any
test.
3. The measurements, and which are ours.
Inherited from U-556 and not re-verified here: the ~40 KB-at-default-filter figure and the ~20 KB-per-probe figure — and the second of those is superseded for this test's own client by the ~11.1 KB below, rather than merely carried forward.
Measured for this unit on 2026-09-19 (debug binary, TLS on, sqlite, RUST_LOG=trace):
successful TLS startup ~35.6 KB; startup plus one probe by this test's own rustls
client 46,708 / 46,710 / 46,743 B; per-request cost constant to within two bytes
across eight requests; a forced early exit ~200 B, all of it stderr — indicative
only, because that line embeds the tempdir path and moves with its length (196 B and
237 B measured for a short and a long path), so it is not a constant to rely on.
The 64 KiB pipe ceiling belongs in neither list as first written. U-556 measured it (65,531 / 65,536 B) and this unit re-measured it independently, observing the unread pipe stop at exactly 65,536 B. An earlier version of this section listed it as inherited- and-not-re-verified four lines above quoting this unit's own fresh measurement of the same quantity — a contradiction in the paragraph whose entire job is attribution.
So the pipe arrangement this unit replaced had room, after startup, for two probes by
this test's client, and the third wedges — the child stops responding without exiting
and the caller hits its 5 s read timeout. The test issues one request, because only
Connect/Handshake probe failures retry and neither issues HTTP. The margin was one
spare request: a real bound, held in place by a matches! arm in a different function
that told nobody relaxing it what it cost. Files remove the dependence rather than
document it.
Any request-count figure of this kind must name the client it describes. The count is
a property of the client, not of the server. A heavier OpenSSL client costs ~16.1 KB per
request against the real probe's ~11.1 KB and wedges one request earlier. Three
instruments in this unit gave three different answers — three, two, and one — before that
was noticed; each had measured a different client, and none of the claims had named one.
An earlier draft of this entry's source material also asserted the child had written
96,573 B "by the moment a single probe returned", past the ceiling, and asked why a
pipe-bound child keeps serving past it. That figure was never a one-probe figure: one
probe leaves the child at ~46.7 KB, under the ceiling. The question had no referent
and is withdrawn; the retraction is recorded in ASSUMPTIONS.md.
The superseded "~16 KiB pipe buffer" premise belongs to ASSUMPTIONS.md, not to this
log — U-556's entry here never made that claim — and it had already been self-corrected
in place by U-556's own reconcile.
A note for two other entries in this file. This unit rewrote tls_startup.rs heavily,
moving let dir = tempfile::tempdir() from :145 to :316. The U-542 entry below and
DECISIONS.md:3628 both cite tls_startup.rs:145 as the model for the owned-TempDir
pattern; both are still right in substance and now want :316. Recorded here because this
file is append-only and those lines cannot be corrected in place.
4. What this unit does not fix. Not an exhaustive list — in a unit about completeness claims, a closed numbered list under this heading would be one. These are the ones known at merge:
- The install/
listeningordering remains unasserted, as above. - Port
18443is still hard-coded, and is now load-bearing for two falsifications rather than one. A parallel run against a busy 18443 fails in a way that now, at least, explains itself. - Both crypto providers remain, by the human decision recorded above, so
install_crypto_provider()stays load-bearing and the phase-1 assertion stays true. - The branches this unit added have no in-suite test.
ChildStream::Truncated,::Unreadableand the cap arithmetic were each falsified out-of-tree and the edits reverted, so nothing in CI executes them. That widens the standing limit U-556's own entry records ("several error branches have no test") rather than closing it.
U-556 — tls_startup asserted TLS and observed TCP
tls_startup_installs_a_provider_and_serves spawns the real binary with
registry.tls.enabled = true. It is the only executable guard on the crypto-provider
defect its own module docstring describes. Its readiness loop broke on
TcpStream::connect(("127.0.0.1", port)).await.is_ok() while its assertion read
"the registry never accepted a TLS connection". The observation was TCP; the claim was
TLS. Anything that bound the port satisfied it — so a rustls handshake regression, of
exactly the class the 0.23.41 → 0.23.45 bump for RUSTSEC-2026-0285 had just made, would
have left it green.
This was a wrong-assertion bug as much as a missing-capability one. Adding a handshake without correcting the message would have left the same false claim in place with a longer test behind it, so an acceptance criterion was written for each half — and that mattered, because the first draft of the criteria all passed with the false message untouched.
The probe replaces the TCP connect inside the poll loop, and the placement is
load-bearing. The first design put the handshake after the loop; that is unsatisfiable.
The handshake must run before child.kill(), while the assertion that reports a
never-ready server runs after it — no position after the loop satisfies both. Guarding it
with if served { … } would have introduced the runtime skip branch the docstring already
forbids. Inside the loop all three constraints hold at once, the early-exit / exit-101
diagnostics stay byte-identical and live during the TLS attempts, and the loop's success
condition finally means what the assertion's message says.
The self-signed certificate is pinned, not waved through. rcgen's
generate_simple_self_signed emits IsCa::NoCa and therefore no basicConstraints
extension at all, so webpki accepts the leaf as its own trust anchor with full chain and
hostname verification on.
Upstream documents this exact usage as unsupported, and that is a standing limit, not a
footnote. rustls-webpki-0.103.15/src/trust_anchor.rs:21-23 documents
anchor_from_trusted_cert — the function RootCertStore::add calls — as one that "should
not be used to treat an end-entity certificate as a TrustAnchor in an effort to validate
the same end-entity certificate during path building. Webpki has no support for self-signed
certificates." That is this case, named. It nevertheless works, because IsCa::NoCa emits
no basicConstraints and so never reaches the CaUsedAsEndEntity arm — settled by running it
rather than by believing the comment or its absence. But the pinning therefore rests on
behaviour upstream explicitly declines to promise: a webpki change could break this test
without breaking the registry, and the fix then is an rcgen CA+leaf chain with the CA
pinned, as tests/didweb/mod.rs:62-100 already builds.
A correction worth recording, because the first draft of this entry got it backwards.
That draft claimed the pinning makes a stale registry child squatting on the hard-coded port
18443 "fail closed because it serves a different certificate". Measured — openssl s_server
on 18443 with an unrelated cert, then the test — the run does fail closed, but at
tls_startup.rs:224: the pre-existing, byte-identical early-exit branch, reporting
exit status: 1, which is NOT the 101 this test exists for, with Address already in use (os error 48) on the child's stderr. (That branch names config, storage
init and port-in-use as candidate causes; it does not diagnose which, and that
non-commitment is deliberate — it is the lesson recorded at tls_startup.rs:209-214.)
The certificate never indicts anything. What U-556 actually changes is that the
handshake fails retryably, so the loop no longer breaks early on a false positive and the
already-existing try_wait branch gets another iteration to catch the child's real exit —
where the old bare TCP connect set served = true on iteration 1 and went green. The
conclusion held; the mechanism was invented. It is recorded here because a plausible
mechanism attached to a true conclusion is the hardest kind of wrong claim to notice.
ClientConfig::builder() cannot be used here, and the reason is the defect itself.
This binary's graph enables both ring and aws_lc_rs — via two independent direct edges,
not one: crates/acdp-registry-server/Cargo.toml requests features = ["ring"] without
default-features = false while rustls' own default includes aws_lc_rs, and
axum-server's tls-rustls enables rustls/aws-lc-rs. So the convenience constructor
panics with the very "could not automatically determine the process-level CryptoProvider"
message this test file exists because of. builder_with_provider is mandatory.
Every timeout is on the std socket inside the blocking closure, and tokio::time::timeout
is not a substitute. It abandons a blocking task rather than cancelling it, and dropping
the test's runtime waits for blocking tasks that already started. Without socket-level
timeouts a listener that accepts and never speaks TLS would hang the test forever —
the loop stalling on iteration 1, never reaching child.kill(), leaking the child, and
burning the tests job's 45-minute cap with no diagnostic. Note the direction of that: the
naive fix would have converted a silent false pass into a silent 45-minute hang. The
bound was verified by standing up a listener that accepts then sleeps 600s — 5.003s,
correctly classified non-retryable.
Falsification. A test that cannot be made to fail has been lengthened, not widened. Six breaks were applied one at a time, each isolating a single assertion, because an earlier assertion that fails first masks every later one. Each was applied as a temporary local edit, run, and reverted at phase 1's verification gate and re-run independently by that gate — so nothing in the tree evidences them, which is exactly why they are transcribed here:
| Break | Result | Assertion proved live |
|---|---|---|
| Pin an unrelated self-signed cert | [handshake] invalid peer certificate: BadSignature (kind: InvalidData) | certificate pinning |
| Client restricted to TLS 1.2 | handshake succeeds, Some(TLSv1_2) vs Some(TLSv1_3) | the TLS-1.3 assertion |
| Request a 404 path | 404 vs 200 | the status assertion |
| Request a 200 path returning other JSON | Null vs "ok" | the body-content assertion |
Offer only h2 in ALPN | Some("h2") vs Some("http/1.1") | the ALPN assertion |
Force a config-stage failure | [config] "not a valid name" is not a valid server name: invalid dns name | the message blames the test, not the registry |
The TLS-1.2 break is the one that carries the weight: the handshake succeeds, so it is the only break that exercises the version assertion at all. A falsification that merely severed the connection would have gone red without ever touching it — a probe that reads nothing you varied is decorative.
The one shipped property with no in-tree probe, now measured. The no-drain decision rests
entirely on Exchange-stage failures being non-retryable — retrying the whole exchange would
re-issue a real HTTP request every 100ms for up to 50 iterations. Nothing in the suite exercises
that. Probed by forcing the response parse to fail: the run reports [exchange] no header/body separator … and finishes in 0.93s, against 5.32s for the retryable handshake break in
the table above, which burns all 50 iterations. The ~5x gap is the discriminating evidence; a
retryable Exchange would have matched the slow number. Still not an in-tree test — the
branches listed under the limits below remain unprobed.
The failure message was, briefly, this unit's own defect. The first cut read "the
registry never completed a TLS handshake" — while firing for config, exchange and
task failures too, i.e. blaming the registry for client-side bugs in the test. That is a
fixed explanation pre-diagnosing every other failure, which is exactly what the existing
exit-101 branch at the top of the same loop was written to avoid, and which the plan had
warned about in as many words. It was caught by the phase verifier, not by its author. The
shipped message names the failing stage and says which stages indict which party.
What this does NOT now cover — read this before assuming the file is settled.
- Ordering is still unasserted. Neither test asserts that the provider install happens
before the
listeninglog. That limit and its reasoning are unchanged by this unit;main.rs:1049still logslisteningahead of either bind branch. A reader seeing "TLS is really tested now" must not infer the ordering property came with it. rustls_is_a_normal_dependencyis hollow, and is not fixed here. It asserts the manifest'srustlsline does not containaws-lc-rs, under the message "requesting both providers is the original defect" — and passes, while the crate resolves both providers anyway through default features, as measured above. It greps a literal string; the property it claims to guard is the resolved feature set, which that string cannot see. It is the same defect class as the one this entry is about, in the same file, and it needs its own unit and its own falsification rather than being folded in here.- Port
18443remains hard-coded, a latent hazard under any future CI parallelism — and per the correction above, the diagnostic a reader gets when it collides is the port-in-use branch, not anything about TLS. - The module docstring at
tls_startup.rs:6-9still carries the superseded account of why two providers are enabled (it namesaxum-server+reqwest; the measured pair iscrates/acdp-registry-server/Cargo.toml:47's inherited default features +axum-server). Both edges named there are real, but they are not the same pair, so a reader who opens the file gets the older story. Left unedited not for scope reasons — that docstring is in phase 1's only in-scope file — but because the unit pre-registered a criterion confining its changed hunks to three pre-image ranges, and the docstring is at lines 6-9. Editing it would have broken that criterion and shifted the line-absolutesedrange guarding the early-exit block. Correcting it is a one-line follow-up for whoever takes the sibling-test unit. (This bullet first gave "path scope" as the reason, which was simply wrong — the same right-conclusion-invented-reason shape corrected five paragraphs above, committed twice in one entry.) - Several error branches in the new probe have no test and no falsification: the 64 KiB
overflow guard (unreachable in practice —
/livezis ~50 bytes), theJoinError/panic arm, the missing-separator and unparseable-status-line arms, and the timeout-to-non-retryable classification (measured out of tree at 5.003s, not in the suite). Their failure mode is a confusing message, never a false pass, which is why they were accepted unprobed — but they are unprobed. - A handshake failure carries no output from the child process. stdout/stderr are captured
only on the early-exit branch (
tls_startup.rs:206-208), which is disjoint from the handshake path. The new message's stage tag says which step failed; it cannot say what the registry was logging while it failed. (Fixed by U-560 — see its entry above. The probe-failure message now carries both streams. U-560 also withdrew the reading that the stage tag identifies a culprit: under a foreign process holding the port the stage ishandshakeand the registry is innocent.)
U-557 — the two yanked crates, cleared; yanked flipped to deny
deny.toml had carried yanked = "warn" with a comment explaining that this was
not laziness: the stricter setting had been measured, and it failed, because the
workspace depended on two yanked crates. That comment also pre-specified the fix and
its ordering — "Flip this to deny in the same change that clears spin and wnaf,
not before — flipping first would just redden the build for everyone." This entry
records executing exactly that, and the two things that turned out to be more
interesting than the version numbers.
The clearing was the easy half. Both crates had non-yanked in-range successors, so
cargo update --package spin (0.9.8 → 0.9.9) and --package wnaf (0.14.0 → 0.14.1)
sufficed. No manifest constraint widened; no transitive dependency pinned either crate
to the yanked version. Each was run separately so its lockfile hunk stays attributable.
spin's yank was not a security event, and saying so matters. Every yanked version —
all 16 of them — falls inside the band 0.7.0 to 0.12.1, which holds 21 versions. The
five live ones inside that band (0.7.2, 0.8.1, 0.9.9, 0.10.1, 0.11.1) are
replacements published out of it, and a sixth, 0.12.2, was published just above it the
same day. All six landed on 2026-07-13, from the same owner (zesterer); a seventh,
0.12.3, followed on 2026-08-17. Everything at or below 0.6.0 is untouched and still live.
Six release lines republished in a single day is a maintainer-wide re-release. No RUSTSEC advisory applies to 0.9.9
(RUSTSEC-2023-0031 is patched = [">= 0.9.8"], RUSTSEC-2019-0031 is withdrawn,
RUSTSEC-2019-0013 covers < 0.5.2). Recorded because "two yanked crates" reads as two
vulnerabilities, and one of them was a publishing decision.
wnaf 0.14.1 is not a cosmetic patch, which is the part worth remembering.
It takes a new non-optional dependency on primefield. So the lockfile diff is not
the two version = lines plus checksums it looks like it should be — it also gains a
"primefield", line inside wnaf's own dependencies list. No new [[package]] block
appears, because primefield 0.14.0 was already locked, already pulled in by
primeorder; that is why cargo reported "Locking 1 package" rather than two. License
and advisory surface are unchanged, but real code moved, and it moved on the ECDSA P-256
scalar-multiplication path (wnaf ← primeorder ← p256 ← acdp-crypto) — which is
why this change was gated on the workspace test run rather than on the lockfile diff
looking small.
This was caught by the plan's own review round, not by implementation. The acceptance
criterion as first drafted asserted the diff "touches exactly two version = lines plus
their checksum = lines" — a correct implementation would have failed it. A criterion
precise enough to be falsifiable is also precise enough to be falsely specified, and the
review round is what separates the two.
The verification trap, and the general shape of it. cargo deny check exits 0 and
prints advisories ok, bans ok, licenses ok, sources ok on the un-bumped tree under
yanked = "warn" — character for character what it prints on the fixed tree. Pasting
that summary as proof of the flip would have proved nothing: a reviewer holding only the
diff and that output could not tell a post-flip run from a pre-flip one. The
discriminating assertion is the absence of the diagnostic, not the presence of the
summary: grep -c 'yanked' over the captured run, which was 4 before (two
warning[yanked] diagnostics, each with a yanked version annotation line) and is
0 after. A summary line that is identical in the passing and failing cases is not
evidence, however green it looks.
What this now costs, and it is deliberately a cost. On 2026-09-16 cargo-deny
became a required status check on main. Before that, this flip would have made a
job fail without letting it block — a true signal with no teeth, which is the gap the
previous entry on this subject was really describing. Now both halves are installed, and
the consequence is that an upstream maintainer yanking any crate in the graph will block
every merge in this repo, on a commit that changed nothing. That is the intended ratchet.
The rewritten comment block in deny.toml is written for whoever meets it in that state:
it names the one-line fix, and it argues against the shortcut of adding an ignore entry
to get unblocked — which is now the path of least resistance, and which would convert a
solvable lockfile problem into a permanent silent exemption.
The superseded entry below (yanked stays warn "because deny fails today") is left
standing. It was true when written, it named the condition for its own retirement, and
that condition has now been met — editing it would destroy the record of a decision that
was made correctly with the information available.
U-542 — 89.3 GiB of orphaned SQLite sidecars, and the shape that caused it
tempfile::NamedTempFile deletes exactly the path it owns. SQLite creates -wal and -shm
beside that path, so every file-backed test orphaned two files. One $TMPDIR held 312,898
acdp-* sqlite files / 89.3 GiB: 143,466 -wal + 143,428 -shm against 14 surviving bare
.sqlite. Fourteen parents against 286,894 orphans is the whole diagnosis — the guard was working
on one of the three files it needed to.
Fix: own the directory. Harness.db is now a tempfile::TempDir containing registry.sqlite,
so the drop removes everything SQLite put in the tree — including whatever a future SQLite version
decides to add. tls_startup.rs:145 already had this shape; it was the model.
How it was found, which is the transferable part. The disk decline that led here was chased for
hours across three sessions and every candidate was eliminated correctly — Colima's images (flat,
by allocated size and mtime), swap (no new swapfile), the lane worktrees. The consumer was in
/private/var/folders, and every search had been rooted at $HOME or the workspace, neither of
which contains it. Three empty results in a row read as "we have looked everywhere" when they meant
"we have looked in the same place three times". An empty search result is only as wide as its root;
publish the roots beside the negative.
Two measurement notes worth keeping.
dureported 117,162 MB for that directory and was distrusted on the strength of a real prior incident where it over-reported a 208k-entry directory by 20x. An independentstat -f '%b'sum over all 347,827 loose files returned 114,355 MB against a children-sum of 3,211 MB —duwas right and the suspicion was wrong. A prior that survives contact with a control is worth more than one quietly dropped.- Prefix counting needs care:
find -name 'acdp-pin-*'reports 8,294 because it substring-matchesacdp-pin-cell-; the true figure is 7,924. Deriving the prefix list from the filesystem rather than from a hand-written table is also what surfacedacdp-rev-e2e-, which the hand list missed.
The guard, and why it is not a $TMPDIR count. tests/tmpdir_hygiene.rs builds a file-backed
harness, drops it, and asserts == 0 of {db}, {db}-wal, {db}-shm remain — an equality, since
a <= bound would be satisfied by the very leak it exists to catch. It is scoped to one harness's
own paths because several test binaries share one $TMPDIR on a developer machine, and a
process-wide count would fail randomly, which is how a guard gets ignored.
It was demonstrated in both directions on the same binary, which is the acceptance evidence:
RED before the fix, naming acdp-test-lI6bJ5.sqlite-{wal,shm}; GREEN after. The whole-$TMPDIR
criterion was additionally satisfied as a measurement — 260 tests produced a delta of 0, against
a pre-fix control of 1 test producing +2. A delta of zero means nothing without that control:
it is also what you would see if no file-backed test had run.
Reclaiming the 89.3 GiB was deliberately left out of scope. /private/var/folders is shared
machine state, a live test may hold an open WAL, and a 287k-file removal is an explicit human
decision. Fixing first is what makes reclaiming worth doing at all — space returned to a suite that
recreates it buys hours, not a solution.
The latent class. Any NamedTempFile whose path is handed to something that may write siblings
— SQLite, a lockfile, a .journal — has this defect waiting. Owning the directory is the general
answer.
U-507 — ASSUMPTIONS.md's open entries, reconciled against the tree
ASSUMPTIONS.md is tracked, cumulative, and had grown past 3000 lines. Its open entries were being
counted three different ways, none of them right, and several had been silently resolved by work that
shipped weeks or hours earlier. This unit censused them, resolved what the tree could settle, and left
the rest open with an owner.
The count was the first problem, and every available number was wrong.
| figure | what it actually counted |
|---|---|
| 50 | grep -c UNCONFIRMED — includes prose narrating a past status |
| 28 | an ^-anchored **Status:** pattern — misses five other live shapes |
| 35 | a careful hand count, scoped to "deferred items", predating 8 later entries |
| 37 items / 46 declarations | every declaration, classified |
The 28 is the interesting failure: it undercounts by the same mechanism that makes 50 overcount, one
boundary over. This file writes statuses mid-prose-line, with a parenthetical between key and colon,
with the token wrapped onto the following line, and — 13 times — as a bullet or heading label
with no Status word at all (- **UNCONFIRMED — awaiting human ruling:**). A line-anchored pattern
cannot see any of those, and returns a confident number rather than an error.
The census tool ships, at docs/assumptions-status-census.py. A figure whose predicate lives in a
scratch file is not reproducible, and the leader could not verify 32/32 precisely because no grep
censuses this file — lowercase status words collide with ordinary English here ("cannot be confirmed
after the fact"). The tool asserts its own partition is total, so an unseen sixth shape fails the run
instead of quietly lowering the count. It independently reproduces the earlier hand count of 35,
decomposed identically — two methods from opposite directions, same partition.
Result.
open items at unit start 37 fully resolved 24 deliberately still open (AC4/AC5) 13 open declarations 46 -> 16 (30 flipped to a resolved token) status lines rewritten / deletions 51 verified AFTER the final merge
The equality caught two items the unit had walked past — one a second declaration inside an
entry whose first was already resolved. A floor (>= 20 resolved) would have passed with both still
open, which is the argument for stating an equality rather than a threshold.
Three entries had already closed themselves. The shared playground validator's placement (#192,
#193), "CI never exercised the shipped stack" (#270), and /metrics' cache posture (#218) were all
settled by other work landing, with nobody going back to flip them. That is the failure mode this unit
exists to correct, and it will recur unless entries are re-read when related work merges.
The pattern worth copying: entries that specify their own closure signal. /metrics' said
"deleting that line is how the fix announces itself"; A2's said it must not be called closed until a
named test was deliberately deleted. Both signals had already fired — the test was deleted in
f8a866d, the same commit that landed the predicate, established with git log -S on the test name
rather than from a changelog. An entry that says how its own resolution will be detectable is worth
more than one that merely records a status.
What the unit declined to do. Nothing was closed by inertia: two entries stay open because "nobody
objected" is not evidence, and one of them forbids that reasoning in its own text. Four entries that
belong to the human or the leader kept their open token and gained Settled by and Owner — split
rather than lumped, because this file has used "escalated" for both. One out-of-grant defect is
reported rather than repaired: crates/acdp-registry-core/src/handlers/context.rs:1262-1277 cites a
deleted test as machine-checking a residue and still calls landed work a requirement.
A method note, recorded because it recurred six times in one session. Six sweeps in this unit
returned a plausible number from the wrong input rather than an error: a body searched without its
heading, a docker-compose.yml grepped at the repo root when the file is under docker/, and
INSERT INTO contexts matching contexts_fts as a prefix. Two of those would have produced false
findings against another lane's work. The pattern was correct every time; the input or the boundary
was not, and nothing failed loudly.
Fixed
-
CI's feature-configuration steps type-checked but never linked, and five configurations were never built at all.
cargo clippyandcargo checkdo not run codegen or the linker, so a failure that appears only at those stages passed every one of them. Proven directly rather than argued from documentation: withtarget/debug/acdp-registryremoved, a greencargo clippy --all-targetsfor a configuration leaves no binary at all, whilecargo buildfor the same configuration produces one (61M). Reproduced on a second configuration. A green clippy cannot surface a link error because it never links. -
The count in #265 was four; the enumeration is nine, and two of the corrections matter.
ci.yml'sclippyjob runs nine feature-configuration checks — lane-1's four are the subset W3-U10 added for #200. Cross-referencing against every step that actually links:storage-pg,playground,storage-memory,playground, no-backend andplayground-alone were linked by nothing; the other four were linked only incidentally, bycargo teststeps whose feature lists happen to match, which any edit to those steps could have removed in silence — the same fragility this file already documents for #221. Andstorage-pg, the configuration that ships, never had its binary linked inci.ymleither: its test step passes--test pg_integration, andacdp-registry-serveris bin-only, so that links a test binary rather than the server. It was covered solely bydocker.yml's image build (STORAGE_FEATURE=storage-pg) — a different workflow, incidentally, which a path filter there would have silently removed. -
Five
cargo build --locked … --all-targetssteps, each paired with its clippy step and carrying a byte-identical feature list, asserted programmatically rather than by eye. Additions, not replacements: clippy is stronger on lints, build on codegen and linking, and neither subsumes the other. Each shipped command line was extracted from the YAML and executed verbatim, so what was tested is what runs.
Changed
-
The build steps live inside the existing
clippyjob, deliberately. That job is one of the four contexts in branch protection'srequired_status_checks, so this coverage is merge-blocking the day it lands. A new job would have produced a check that is not required and therefore could not prevent a merge — the limitation U-508'slintgate hit, which is still awaiting a decision. This is not the mislabelling U-508 refused when it declined to put shell linting insiderustfmt: there the concern differed from the job's name, whereas building a feature configuration is the same concern this job already served nine times. The distinction is concern identity, not convenience. The job must not be renamed — those four names are a contract with branch protection, and a required context that stops reporting leaves every PR waiting on a check that never arrives. -
The
msrvjob's twocargo checksteps staycheck, by decision. Their purpose is that 1.88 accepts the language and API surface, and both of their configurations are now linked at stable. Codegen divergence between 1.88 and stable for identical source is a materially narrower risk than an unlinked configuration. Recorded rather than silently skipped.
Added
-
Cost, measured in CI rather than extrapolated from a laptop — and the laptop was wrong by 5x. Locally, warm, the five builds totalled 21s and each was usually cheaper than the clippy step beside it, because clippy runs extra lint passes over the same graph. In CI the
clippyjob went from 39s to 2m27s: +108s, not +21s. The local figure was optimistic because that machine had already built every feature combination during the measurement pass, whereas the runner's cargo cache holds no artifacts for combinations this repo had never built. CORRECTED BY U-513 — the sentence that stood here quoted a margin, and a margin was never supportable. It said "testsis 2m45s, so PR latency is still set bytests— with 18 seconds of headroom", and paired that with a trigger: "if theclippyjob ever exceedstests, the split should be reconsidered." Both are withdrawn. The 18s figure was one sample of each job, and the trigger had already fired before the sentence was written — run 34766261172 had clippy at 98s against tests at 86s. The falsifying datum was in hand. Measured properly, from GitHub-hosted runners withSwatinem/rust-cacheand no local timings:n range median spread clippy, after this change5 98-176s 144s 78s tests, same runs5 86-165s 149s 79s clippy, before7 28-54s 33s 26s tests, before7 142-155s 150s 13s Per-run margins were
+12, -18, -27, +26, -4seconds (positive = clippy leads). Every one is far smaller than either spread, and a margin smaller than the run-to-run spread is not a margin — so no headroom figure belongs here in either direction. What the data does support is categorical rather than numeric: before this changeclippywas never near the critical path (its slowest run, 54s, was under tests' fastest, 142s); after it the two distributions overlap andclippyled in 2 of 5 runs, one of them onmain. So the change movedclippyfrom never the critical path to sometimes it. Derived expected cost, as the mean ofmax(0, clippy - tests)across those 5 runs: ~8s, against a ~150s critical path. The trigger fired and was weighed; it did not go unnoticed. The per-PR/scheduled split is still declined, but on re-derived grounds, since the original reason ("it relieves a job that is not the bottleneck") is false in 2 of 5 runs. The two objections that do survive: a scheduled job makes the feature lists two sources of truth — this file already records that a written-out list goes stale silently and that it already did once — and it delays breakage detection by up to a day. A strictly better third option exists and is recorded rather than taken: move the five build steps to a separate job running in parallel instead of on a schedule.clippyreturns to ~33s, the builds occupy ~111s of their own job, both sit under tests' median, and added latency is zero rather than ~8s — and it moves the feature lists rather than copying them, so neither surviving objection applies. It is not taken because a new job is a new check name, and branch protection's required contexts are enumerated, so the builds would stop blocking merges — the exact tradelint.ymlis stuck in. Trading the blocking property for ~8s is the wrong way round. That makes one pending settings decision the unblocker for two improvements. -
A finding that narrows #265's own risk claim, worth recording because it is easy to overstate the fix. The classic undefined-symbol link failure is unreachable from this repo's source:
Cargo.tomlsetsunsafe_code = "forbid", andforbidcannot be overridden by#[allow], so noextern "C"declaration can exist here. Two falsification attempts died on this and on a related point, and both failures are more informative than a success would have been: aRUSTFLAGSlink-argument probe is not a valid discriminator, becauseRUSTFLAGSalso reaches build scripts and proc-macros, which are linked — so clippy fails too. What the new steps therefore close is real but bounded: dependency and native link failures under a particular feature combination, environment-level link failures — this repo has an observed instance, theSDKROOT/ld-1267failure, which passes clippy and fails only at link — and post-monomorphization codegen errors in safe code.
Added
-
shellcheckandactionlintnow run on every pull request, in.github/workflows/lint.yml. Neither had ever run in this repo, which mattered from the moment U-503 added its first shell script — 222 lines in the container publish path. The cost of the gap is on the record: that script's first draft silently dropped the last token of its input (while readreturns false on a line with no trailing newline) and therefore passed the release run it was written to reject. It was caught by falsifying against real incident data, which is not a mechanism that repeats itself reliably.shellcheckcatches that class mechanically. -
A separate workflow rather than jobs in
ci.yml, for a reason that is not tidiness. The linters need no Rust toolchain and no cargo cache, so they share nothing with that file's matrix;ci.ymlis ~500 lines that several people edit concurrently; and a distinct check name means a shell finding is reported aslintrather than arriving disguised asrustfmt. That last point is the same mislabelling argument U-503 used to justify keeping the release rebuild — a check, like an image, should not carry a name that misdescribes its contents. -
Both linters are pinned, and neither is fetched blindly.
shellcheckcomes from thetaiki-e/install-actionSHA already trusted inci.yml, at 0.11.0, rather than from the runner image, which ships 0.9.0 — a linter whose version floats with the image can redden a commit that touched nothing, and can pass locally while failing in CI.install-actionhas noactionlintmanifest (checked: 103 manifests,actionlintis not among them), soactionlintis fetched from its own release, pinned by version and verified against the checksum published with that release. A job whose entire purpose is raising the floor on shell quality does not get to pipe an unverified remote script into a shell. -
The job asserts both binaries exist before linting anything.
actionlintshells out toshellcheckforrun:blocks; withshellcheckmissing it would lint workflow syntax, say nothing at all about the embedded shell, and still exit 0. That is a gate quietly covering less than it claims, and it is invisible from the outside — so presence is asserted and both versions are printed.
Fixed
-
Four findings, three fixed properly and one suppressed narrowly.
ci.yml:357anddocker.yml:170each hadfor i in $(seq 1 30)withinever used (SC2034); they are "repeat N times" loops, sofor _ inmakes the warning go away by the code being correct.ci.yml's coverage summary made four separate>>appends to$GITHUB_STEP_SUMMARY(SC2129), now one grouped append. The one suppression is SC2020 ontr ', \t' '\n\n\n\n'indocker/assert-image-tags.sh: that maps three separator characters each to a newline, which is whattris for, while SC2020 warns against expectingtrto replace words. Equalising the set lengths does not silence it, and the alternatives are worse — unquoted parameter expansion trades SC2020 for SC2086 plus glob exposure needingset -f, andseddiverges between BSD and GNU on\nin the replacement. Rewriting tested code in the publish path to quiet a note is the wrong trade. One directive, one code, with the reason written out, at function scope because a#between backslash-continued lines is part of the command rather than a comment. -
--self-testindocker.ymlstays, and is not redundant withshellcheck. They prove different things:shellcheckproves the script is well-formed shell, the self-test proves its logic still rejects the real pre-fix tag sets from runs 34734871991 and 34725795501. Neither implies the other, and the proof is the incident itself — the last-token bug was perfectly well-formed shell that a linter would have passed while the guard silently accepted the release it existed to reject.
Changed
-
What this gate does not do, recorded here because a reader will otherwise assume it. Branch protection on
mainenumerates its required contexts —rustfmt,clippy,tests,conformance (spec fixtures)— andlintis not among them. A redlintmakes a pull request visibly red; it does not prevent a merge.docker/buildandcoverageare already non-blocking in exactly this way, so this is the repo's existing pattern rather than something introduced here. Making it blocking is a protected-branch settings change, not a change to any file in the tree; the exact call is inlint.yml's header, including the warning thatcontextsis replaced wholesale, so omitting an existing entry silently un-requires it. -
A trap named while it is cheap: those four job names are an external contract with branch protection. Renaming one without updating protection in the same change removes a required context, and every subsequent PR waits forever on a check that will never report.
-
actionlint's own workflow checks found nothing. Worth stating plainly, because the unit was scoped expecting latent bugs of thev*-tag-that-never-matched kind this repo has already hit once: there are none. All three workflow findings wereshellcheckon embeddedrun:blocks. The value delivered here is the standing gate, not this batch of four fixes, and three style nits should not be dressed up as a vindication of the search.
Fixed
-
sha-<short>was a mutable tag, and the release that exposed it moved one 19m44s after it was published..github/workflows/docker.ymlpublishes on a push tomainand on anacdp-registry-server/v*tag, and release-plz tags the commit it merges (release-plz.toml'sgit_tag_name), so a release gives one commit two publishing runs.type=shasat in the sharedtags:rules, ungated, so both runs computed and pushedsha-<short>. Measured on v0.1.3 (f8b6d9e): run 34734028111 publishedsha-f8b6d9e->sha256:b9315cc84f08at 02:52:16Z, and run 34734871991 re-pointed it tosha256:cf2f85068eb6at 03:12:00Z. GHCR confirms the move by state rather than by inference —b9315cc8now carries only[latest, main], having lost thesha-tag it was published with. Asha--prefixed tag reads as content-addressed and was not: anyone who pinned it inside that window is running different bytes than they pinned.concurrency: docker-${{ github.ref }}does not help, because the two runs are different refs — the file already says so about a related case. -
The fix is one gate, not a new mechanism.
type=shanow carriesenable={{is_default_branch}}— the same gate thelatestrule already used — so the three main-line tags (main,latest,sha-<short>) share one gate and one writer: the default-branch push. The release run publishes only its version tags and has nothing left with which to re-point asha-tag. This is a single-writer guarantee, not registry-level immutability. GHCR tags stay mutable, and re-running amainbuild by hand will rebuild that commit and move itssha-tag. Closing that needs a pre-push existence check whose fail-closed behaviour would block a legitimate re-run after an infrastructure flake; it was priced and deliberately not taken, and the docs are worded so they stay true without it. -
The same commit is still built twice, and that is correct — this is the half of the reported defect that was declined, with reasons. The two digests for one commit are not flakiness and not a reproducibility failure; they differ deterministically. Read off both runs'
buildxcommand lines: metadata-action stampsorg.opencontainers.image.versionasmainon the main-push build and as0.1.3on the release build,image.createddiffers, and buildx attaches--attest type=provenance,mode=max,builder-id=…/runs/<run-id>. Labels and provenance live in the config blob, so the manifests cannot agree. The obvious way to collapse the builds — promoting the main digest withbuildx imagetools create— would therefore publish a release image whose own OCIversionlabel readsmainand whose provenance names the main run. That trades a cosmetic problem for a mislabelling one, so the release rebuild earns its keep: it is what stamps release identity into the release artifact.
Added
-
A guard that rejects the exact bytes of the incident, rather than a lint that would have passed on them.
docker/assert-image-tags.shasserts the invariant as an iff — asha-tag is present if and only if this is a push to the default branch — anddocker.ymlruns it aftermetadata-actionand beforebuild + push, so a bad tag set blocks publication instead of being discovered in the registry afterwards. The iff form is the point: a pull request is not the default branch, so if the gate is ever deleted, the PR that deletes it computes asha-tag and the guard fails there, before merge, rather than staying silent until the next release. The reverse direction catches a gate that over-fires and quietly stops publishing a documented tag. -
The guard's falsification is wired in rather than claimed.
--self-testruns on every workflow event over a ten-case table; five cases assert that the guard rejects, and two of those replay the real pre-fix tag sets from runs 34734871991 and 34725795501. Writing it this way paid for itself immediately: the first draft silently dropped the last tag in its input (while readreturns false on a final line with no trailing newline), so it passed the release run it was written to reject — and two other cases passed anyway for the wrong reason, so no single green case would have localised it. This is also the repository's first shell script, into a tree with noshellcheck; the self-test step is what stands in for the absent linter.
Changed
docker/RAILWAY.mdnow tells an operator which tag to deploy and why two digests for one source is expected. The tag list saidsha-<7-hex> # every push, which was never true of pull requests and is now not true of release tags either. The deploy recipe led with:latestwhile the file's own warning three paragraphs earlier said:latestmoves on every merge; it now leads with a version tag and keeps that warning intact. Added the fact an operator would otherwise have to discover from the registry: a release tag and:latestare different digests of identical source, by design, andorg.opencontainers.image.revisionis what confirms two tags came from one commit.
Changed
-
The store's disclosure parameter is renamed
anonymous_public_reads->public_arm_open, at every trait method, implementation and call site. No behaviour change. The old name described one caller's situation rather than what the predicate reads: the public arm ispublic_arm_open || requester.is_some(), andhandlers/admin.rspassestruefor a caller who is authenticated but unnamed. Under the old name that read as a bypass, and three readers in a row — an audit, the coordinating session, and another lane — took it for one. -
Both struct fields keep their names, and that is the load-bearing part of the diff.
AuthConfig.anonymous_public_readsis a config-file key, andCapabilitiesDocument.anonymous_public_readsis canonical per RFC-ACDP-0007 §3.3 and lives in the externalacdpcrate.server/src/main.rsis the translation point between them and is untouched. The field names are the vocabulary the spec and deployed configs fixed; only the parameter was free to change.
Fixed
-
The config key had no guard, and this unit proved it the hard way. The mechanical rename renamed the serde field. It compiled,
clippy --all-targets -D warningspassed on six feature configurations, and 28 test suites stayed green — because every other use in the repo sets the field programmatically and moved with it. Nothing in the codebase observed the contract with deployedregistry.tomlfiles.anonymous_public_reads_is_a_stable_config_keynow deserialises the literal key, which is the only way to observe the name serde matches on, and it fails against exactly that rename. -
A correction to the hazard as it was described. The risk was stated as
#[serde(default)]causing an old key to be ignored, so a registry would boot atfalsewhile the operator believed otherwise — silent.AuthConfigalso carries#[serde(deny_unknown_fields)], so the old key instead fails to parse and the registry refuses to start. The conclusion is unchanged, the mechanism is not, and a doc that gets the mechanism wrong is the harder defect to find later. -
A stale doc citation in
tests/common/mod.rsnamed a test another lane had renamed. Fixed — and only that one. The three other occurrences, inASSUMPTIONS.mdand this file, are dated records that correctly name the test as it stood on that date; editing them would falsify the record rather than update it.
Changed
-
GET /contexts/searchnow returnstotal_estimateto a tenant-scoped caller, and the number is that tenant's count. Additive and wire-visible: a response that previously omitted the key for any request asserting a tenant now carries it. No field changed meaning for an un-scoped caller, and nothing was removed. -
Why it was withheld, and why that reason expired. The count came from the store, which counted §4.5-visible rows before the tenant predicate ran — the filter lived in the handler, post-query — so the number described rows across every tenant and handed a tenant-scoped caller a population size for data it could not see.
search_in_tenantmoved the predicate into the same statement as the keyset and the count, soCOUNT(*) OVER ()rides a scan that only ever sees the caller's own rows. There is no cross-tenant number left to withhold, and withholding one would hide a figure the caller is entitled to.
Fixed
-
docs/HTTP-API.mdstated the opposite, and its stated REASON was false independently of its claim. It explained the omission as "the count is taken in the store before the tenant predicate is applied in the handler", which stopped being true when the predicate moved. Both halves are rewritten rather than just the conclusion — a correct claim resting on a false mechanism is the harder defect to notice later. -
The assertion that guarded the old behaviour was inverted, not deleted.
search_omits_total_estimate_for_tenant_scoped_callerwas a tripwire naming its own removal condition, and this is the commit that makes it false, so it changes here and nowhere else. It is nowsearch_reports_a_tenant_scoped_total_estimate, and it asserts the value: presence alone would pass against a handler reporting the registry-wide count and against one reportingmatches.len(). The fixture makes those three numbers distinct (2, 5, 1) and each wrong value was produced by a real mutation.search_still_reports_total_estimate_without_tenantwas left untouched and kept passing throughout, which is what shows the change did not go too wide.
Fixed
-
Security (cross-tenant disclosure): a tenant-scoped
/contexts/searchcould hand back anext_cursoranchored on another tenant's row, and that is now closed.next_cursoris unsigned plaintext base64 of{mint_ms}:{anchor_ms}:{ctx_id}anchored on the last row the store scanned, not the last row returned. While tenant narrowing was a handler-side post-filter the scan saw other tenants' rows, so the retain dropped the row while the anchor had already been computed from it — a tenant-scoped caller could walk cursors to recover foreignctx_ids and their ordering. No post-filter could have fixed it: dropping a row after the fact does not un-scan it.A tenant-scoped request is now served by
ExtendedRegistryStore::search_in_tenant, which carries the predicate in the same statement as the keyset and the count, so the scan never sees another tenant's rows and the anchor can only be one of the caller's own.Nothing was built for this.
search_in_tenantshipped with H-H and sat merged-and-dormant because no caller existed — the same shape asvisible_ctx_idsbefore P9. The docs said "closing it requires the tenant predicate in the store's search SQL", which was accurate only while the wiring was missing, so that sentence is corrected in the same change. -
search_cursor_oracle_remains_open_for_tenant_scoped_calleris deleted, as its own comment instructed: "If you are reading this because it just failed: that is the good outcome. Confirm the cursor now only anchors on the caller's own rows, then delete this test and say so in the changelog." This is that note.Deleted, not relaxed, and confirmed causal before removal. A test that asserts a defect still exists is the acceptance criterion for its fix, written by whoever could still reproduce it — so it was worth more than a guard written afterwards, and it was checked properly rather than assumed to be the reason: with the predicate in place it failed with its own designed message ("EXPECTED the residual leak and did not observe it") while its setup demonstrably still ran (three foreign rows created); changing only the tenant argument from
Some(tenant)toNonemade it pass again. That isolates the failure to the single value that closes the oracle rather than to anything else that moved onmain.
Changed
-
total_estimatestays omitted for a tenant-scoped request, but the reason changed. It was omitted because it was wrong — the store counted before the handler applied the tenant predicate.search_innerputsAND tenant_id = ?in the same statement asCOUNT(*) OVER (), so the count is now tenant-scoped and honest. The key is withheld conservatively rather than necessarily; re-enabling it is wire-visible and ships as its own reviewable unit rather than buried in a wiring change. -
One gate was carried by hand, deliberately.
RegistryServer::searchis not a thin wrapper: it rejects an anonymous search with 403not_authorizedwhencaps.anonymous_public_readsis false before delegating to the store (RFC-ACDP-0008 §6.3, fixturevis-009) — not an empty200, which would still confirm the registry exists and that the query ran.search_in_tenantis a store entry point with no such gate, so routing through it means carrying the gate or silently downgrading a normative 403. The untenanted path is left onserver.searchuntouched, and the gate is replicated on the tenant path only, reading the flag fromcapabilities()— the same fieldserver.searchreads, so the two agree by construction rather than while config and caps happen to match.
Fixed
-
A route deleted from the core router while its
NON_DATA_ROUTESrow remained was invisible to every assertion. The classification test ranmounted ⊆ classifiedin one direction only, so it noticed a route added without a cache posture and was blind to the opposite. A row matching no route is not inert: it reads as coverage, and it silently widens what a later route reusing that path would inherit.DATA_PLANE_ROUTESnever had this gap — assertion 1 compares it to thedatagroup withassert_eq!on two sets, which fails both ways — so this closes the one table that had no partner. Deliberately not a count:len() == mounted - tabledwould pass if one row went stale while another was added, which is precisely what a rename does. -
The equality assertion reddened on a non-literal route path but would not say which one. It reported "26 != 27" and left the reader to diff two lists by hand. It now names the form it could not interpret, e.g.
.route(DEBUG_PATH, ...).
Changed
- Recorded because the bug was in this phase's own new code. The first draft of the scanner
that names non-literal forms worked line by line and reported seven false positives:
lib.rsregisters seven routes with.route(at the end of one line and the path on the next, and a per-line scan sees an empty argument. That is the same line-wrap failure that defeated a grep earlier in this unit, written again hours later in a different medium — knowing the failure mode did not prevent repeating it. The scanner is now the exact inverse ofmounted_route_paths: same scan, same whitespace test, opposite branch, so the two cannot disagree about what a literal is instead of agreeing by inspection.
Changed
-
The test harness gained one call that cannot set half of the
anonymous_public_readsinvariant.RegistryServergates that flag off theCapabilitiesDocumentbaked in attry_new, not offRegistryConfig, so a test that flips the config value and observes a 200 has measured the harness's caps/config split and nothing about the binary — the invalid "wire probe" that let a shipping anonymous-disclosure bug reach review (#255). The config value is not dead either: it is what the binary'sbuild_capabilitiesderives the caps value from, so a test that sets only caps is asserting against a state no deployment can reach.common::with_anonymous_public_readstakes one input and returns both, so divergence is unrepresentable through it.Three of the four premises behind this unit were refuted by measurement, and the refutation is most of the result.
tests/common/mod.rsalready parameterised caps completely — every constructor takes it, and there is noanonymous_public_readsliteral in that file's code at all.conformance.rsalready overrode caps in three independent paths, not zero: the Shape D driver,vis009_anonymous_public_reads_gates_anonymous_not_authenticated, andseeded_harness_rebuild_changes_router_behavior_and_preserves_seeded_state, which sets caps false and asserts the same anonymous search is now refused. And acaps=falsevisibility test already existed and had already been falsified, in #255. So only the fourth premise — that nothing makes the two-knob divergence loud — survived, and that is all this ships.Enforcement was deliberately not shipped. A
debug_assertin the shared wiring would have reddenedadmin_list_returns_rows_under_the_shipped_disclosure_defaultin a file another lane holds, so the invariant is constructive here and the divergent site is reported instead.
Fixed
- Recorded, not fixed — a third instance of the "probe that cannot fail" class, in this same
area.
admin_list_returns_rows_under_the_shipped_disclosure_defaultis framed as testing "the SHIPPED DEFAULTanonymous_public_reads = false", andadmin_listreads that flag from neither config nor caps:handlers/admin.rshardcodeslet admin_sees_public_arm = true;. The test varies a value its code path never consults, so it cannot fail for the reason it was written. Its actual assertion — an admin bearer still sees public rows — remains meaningful; only the framing overstates what is exercised. Both files are outside this lane's grant, so this is reported rather than edited.
Fixed
- Security (anonymous disclosure):
/log/entrieswould have echoed every publicleafto an unauthenticated caller on the shipped default configuration. Caught by this repo's own verification gate before merge, not in production — recorded because the way it survived three tracked files is the part worth keeping. The batched rewrite below passed a hardcodedanonymous_public_reads: true, justified by a "wire probe" that flippedcfg.auth.anonymous_public_readsand observed a 200. That probe measured nothing.RegistryServer::retrievegates onself.caps, not onRegistryConfig— this repo already documents that split as GAP 3 incrates/acdp-registry-server/tests/common/mod.rs— and the default test harness hardcodescaps.anonymous_public_reads: true. So the 200 came from the caps value the probe never touched. SinceAuthConfig::default()shipsanonymous_public_reads: falseandauth.enabled: false, on the default config every caller is anonymous and every public leaf would have been disclosed. The flag is now read fromstate.server.capabilities()— the same fieldretrievereads — so the batched call equals the per-record retrieve by construction rather than only while config and caps agree.log_entries_honours_anonymous_public_reads_from_capsoverrides the caps, which is the only thing that moves the real predicate, and it fails on the hardcoded value.
Changed
-
GET /log/entriesanswers a whole page with one visibility query. It resolvedleafvisibility one record at a time: a blockingRegistryServer::retrievedispatch per entry, plus — under anX-Tenant-Idheader — atenant_of_ctxper entry. On a full 256-record page (RFC-ACDP-0012 §8.3 RECOMMENDS a cap of at least 256) that is 256 blocking-pool dispatches and up to 512 store round-trips to answer one request. It is now a singleExtendedRegistryStore::visible_ctx_idscall, which both SQL backends override with one query. That method has been merged and dormant since #246; the plan for this phase predates it and prescribed a three-part workaround on the explicit grounds that a batched store method was out of scope. That premise expired, so the workaround was not built. -
Responses are byte-identical on the SUCCESS path only, for a given
anonymous_public_reads— two things change, and one of them changes a status code. The earlier version of this sentence claimed byte-identity flatly and hedged with "but this is not a pure refactor", which narrows the wrong property: it qualifies refactor-ness while leaving byte-identity standing, so a reader who notices the hedge still comes away believing something false.ASSUMPTIONS.mdhad the four words that do the work — "on the success path only" — and this entry had dropped them. First, the flag above: get it wrong and the bytes differ enormously, which is the whole of the security entry. Second, which store errors can reach the caller — and the change is one-directional:No longer able to surface: everything
RegistryStore::getdoes. The old path ranserver.retrieve— and therefore a fullget, includingevents_for_ctx,reconcile_retractionandbody_jsondeserialization — on every record, because that call was the visibility check. The batched query isSELECT ctx_id FROM contexts WHERE …, so a decode failure or an events-table error on any row of the page used to 500 the request and now cannot.Scope of that claim. It is a property of the two SQL overrides, which is every backend that can serve this endpoint today —
MemoryStoredoes not overridelog_entries, so/log/entriesisNotImplementedthere before this code is reached. It is not a property ofvisible_ctx_idsas a trait method: the default impl still runs a fullgetper id, so an external implementor that overrideslog_entriesbut notvisible_ctx_idswould see an unchanged error surface, not a smaller one.Nothing is newly able to surface. Two earlier drafts of this entry got this wrong in opposite ways, so the reasoning is spelled out rather than asserted. The first said a store error could reach the caller "only for rows that were already visible", which is false —
getran on every row. The second corrected that but claimed the change ran in both directions, withtenant_of_ctxnewly reachable on hidden rows. Also false, in every implementation: the SQLite and Postgres overrides never calltenant_of_ctxat all (the predicate isAND tenant_id = ?inside the same statement), and the default trait impl still gates it behindif !retrieve_visible(…) { continue; }— the identical gate the old handler had. So the honest net is one-directional: strictly fewer classes of store error can surface than before, and the earlier "strictly more honest" framing had it backwards. -
The guard is the deliverable. The improvement is invisible in the response, so nothing in the suite could have noticed a revert. A
CountingStoretest wrapper countsget,visible_ctx_ids,tenant_of_ctxandtenants_of_ctxs; the tests pin one batched query and zero per-record reads with and without a tenant header, pin that no separate tenant lookup is paid for either, and pin that the reserveddefaultsentinel is refused before any row is read. Every assertion was falsified individually against a mutation violating it alone, rather than as a group — the first failing assertion masks every one below it.
Fixed
-
Security (cross-tenant disclosure, partial): a tenant-scoped search reported a population count for rows across every tenant.
total_estimateis produced by the store, which counts §4.5-visible rows before the tenant predicate is applied — tenant narrowing happens afterwards, in the handler, as a post-query filter. So a caller assertingX-Tenant-Idreceived the number of matches across the whole registry while seeing only their own rows: an O(1) population count for data they cannot read.The key is now omitted entirely for a tenant-scoped request. Omitted rather than recomputed, because an honest tenant-scoped count needs the predicate in the store's SQL, which is a different change in a different crate.
Option<u64>withskip_serializing_ifmeans the key is absent rather thannullor0, so a client cannot read "withheld" as "none found". An un-scoped caller still receives it — removing it for everyone would have passed a naive "tenant caller sees no count" test while breaking conformance, which is whysearch_still_reports_total_estimate_without_tenantexists.This is partial and is not described as closed.
next_cursoris unsigned plaintext base64 of{mint_ms}:{anchor_ms}:{ctx_id}anchored on the last row the store scanned, which may belong to another tenant, so foreignctx_ids and their ordering remain recoverable by paging. The fix moves that from one request to one request per row; it does not remove it. Closing it requires the tenant predicate in the store's search SQL.search_cursor_oracle_remains_open_for_tenant_scoped_callerasserts the residue, so "partial" is machine-checked rather than a sentence someone has to re-read, and it fails deliberately when the underlying fix lands.Two claims that were overstated have been corrected rather than carried forward. The code comment described the cursor as "a low-grade ordering/existence oracle" where "no context DATA leaks" — a
ctx_idis a durable identifier, not low-grade, and "no data" was true only of bodies. Anddocs/HTTP-API.mdstatedtotal_estimatewas "the count of §4.5-visible matches for the caller", which was exactly the falsified claim.
Fixed
-
Retry-Afterreported a range, never a value. Three separate copies ofWINDOW.saturating_sub(elapsed).as_secs().max(1)exist inrate_limit.rs(check_global_at,peek_at,check_at). Replacing the subtraction withelapsed.saturating_sub(WINDOW)at all three pins everyRetry-Afterto exactly 1 second and left all 24 tests in the module green, plus the HTTP integration assertion. A client throttled for a full minute would be told to come back in one second, retrying ~60× more often than intended — under precisely the load the limiter exists to shed.Three tests looked like they covered this and none did.
peek_retry_after_matches_checkpinspeek == check, which is internal consistency between two paths and silent when both share one wrong arithmetic.allows_up_to_limit_then_rejectsand the integration test both assert(1..=60).contains(&retry)— a range spanning every value the function can return. Andretry_after_never_reports_zerodoes assert an exact value, but at 59.5s in, where the expected answer is the clamp floor: the module's one exact assertion sat at the single point where a wrong mechanism produces the right number.retry_after_reports_the_seconds_actually_remainingasserts the remaining seconds at five points across the window, on all three paths, with the expectation derived from the documented 60s contract rather than fromWINDOW(computing both sides from one constant lets a change move them together). Falsified per site: mutating each of the three arithmetic copies individually fails it at a different assertion line, so no one path's coverage is masking another's.Partly refuting the audit that raised it: the 1s floor is not an unasserted comment.
retry_after_never_reports_zeropins it exactly. What was missing was every other point in the window. -
The constant-time compare had a test named for non-short-circuiting that could not detect short-circuiting.
ct_eq_does_not_short_circuit_on_positionasserted only return values, and a short-circuiting comparison returns identical values for every input. Measured: replacingct_eq's body witha == b— the exact comparison the helper exists to avoid — left all three tests green. The claim that "every input reaches the fold" was stated in the test's own doc comment and asserted nowhere.The property is work performed, so it cannot be asserted on a result. The fold now lives in a private
ct_foldgeneric over an iterator of byte pairs, which letsct_fold_consumes_every_pairpass a counting iterator and assert every pair was consumed even when the first pair already differs — deterministic, where a wall-clock timing test on a shared runner is a flake that eventually gets deleted rather than fixed. It also asserts the fold is still correct, so a loop that consumes everything and computes nothing fails too.ct_eqisct_fold's only caller, so a rewrite that stops folding makes it dead code. Verified rather than assumed: CI'scargo clippy --locked --workspace --all-targets -- -D warningsexits 101 withfunction ct_fold is never used.--all-targetsis what makes that work, by compiling the lib target separately from the test target — the test module's use ofct_folddoes not keep it alive in the lib's own compilation.
Fixed
-
The JWT issuer was validated and never asserted, and the gap was measured rather than argued: commenting out
v.set_issuer(&[&self.issuer])injwt.rs::validateleft all 619 workspace tests passing. Nothing in this repo could distinguish a working issuer check from an absent one, so the guard against a federated token from a trusted-but-wrong issuer rested on a line no test was watching.rejects_token_from_a_different_issuernow closes that. The audience sibling (rejects_token_for_a_different_audience) already existed; this is the missing half.The fixture is built so it can actually reach the issuer check — the failure mode for a JWT test is that signature, expiry or audience validation short-circuits first and the test passes having proved nothing. Every other property of the token is deliberately correct: signed by the same signer, unexpired, matching
aud, matchingacdp.registry. And the assertion namesInvalidIssuerrather than accepting any error, because "rejected" and "rejected for the reason under test" are different claims.Three mutations, each RED at a different assertion, which is what makes them three guards rather than one: deleting the check and adding the attacker's issuer to the trusted list both redden the primary assertion; setting the trusted issuer to a value nothing matches reddens the control — proving the control is a live guard and not decoration. The widened trust list is the realistic defect and it is now caught.
Fixed
-
The
415from a missing or wrongContent-Typenow answers an RFC-ACDP-0007 §5 envelope, closing the one case the extractor-rejection work shipped without. It carries"code": "unsupported_media_type"at the unchanged415status.That code is not in the canonical §5 registry, and minting it was a deliberate decision by the project owner rather than an oversight.
AcdpError::from_wire_errorrecognises 25 codes and none describes a media-type failure, whileWireErrorBody::codeis a required field — so a registry answering415must put something there. The nearest canonical option,schema_violation, is documented as "malformed body, missing field, schema mismatch", and on a415the body was never parsed at all; it would have stated something false, made415indistinguishable from400at the code level, and become unfixable once clients coded againstAcdpError::SchemaViolation. Unrecognised codes route toAcdpError::Registry(wire)"for forward compatibility", so existing clients keep the status and the message and lose only the typed variant.The divergence has a closing path rather than being permanent: acdp-rs#268 asks the canon to adopt the code. Decision 16 in
DECISIONS.mdrecords the standing precedent — this repo may mint a wire code when the canon lacks an honest one, provided the name follows the canon's idiom and an upstream issue is filed.
Fixed
-
A revoked token was accepted again for up to 30 seconds after its own expiry.
JwtSigner::validateaccepts a token untilexp + leeway(default 30s) — that is what leeway is for — but every revocation backend judged the tombstone against a barenow, so it went cold atexp. In the window(exp, exp + leeway]the token still decoded and the revocation check had already gone false. Revocation un-revoked itself.Not an authentication bypass: a control lapsing inside a window, which is narrower and is what the evidence supports. One helper,
tombstone_cutoff(now, leeway), now owns the rule, and both halves of the lifecycle take it — the check and the eviction. Fixing only the check would have left eviction deleting the row atexpires_at, defeating revocation through the other door while a check-only test stayed green. The evictor reads the leeway from the signer rather than re-reading config, because a validator and an evictor disagreeing about the window is precisely the defect being fixed. -
The revocation poller followed redirects. Feeds were fetched with a bare
reqwest::Client, which follows up to ten redirects, so a hostile or compromised peer could bounce the poller — with the admin bearer token it uses to read the feed — at an internal address. It now usesacdp::safe_http::safe_client, as webhook delivery already did.Operator-visible consequence:
safe_clientinstalls a DNS resolver that refuses hosts resolving into private, loopback or link-local ranges, so a peer registry reachable only on an internal hostname will now be refused rather than polled. Deliberate and consistent with webhook delivery. Note also whatsafe_clientdoes not do: it consults its policy only for DNS, soallow_httpandreject_ip_literalsare unenforced — anhttp://or IP-literal feed URL still works, and an IP literal bypasses the resolver check entirely. -
The challenge nonce was logged at INFO. The event stays and now carries
agent_id, which is what an operator correlates on. Severity is hygiene rather than exploitability — the nonce is the value the agent must sign, so disclosure alone forges nothing without the agent's key — but it is a short-TTL credential-shaped value and logs are routinely shipped off-box.
Added
-
Opt-in replay protection for webhooks — offered, not enforced.
X-ACDP-Signaturecovers the body alone, so a captured delivery can be replayed forever, and becauseX-ACDP-Event-Idis deliberately stable across retries a replay is indistinguishable from a retry. Deliveries now also carryX-ACDP-TimestampandX-ACDP-Signature-Timestamped, the latter covering"<timestamp>." + body.X-ACDP-Signatureis unchanged and is now pinned by a test, because it is a documented public contract every deployed receiver verifies; widening it would break all of them. An unsigned timestamp would be rewritable and therefore worthless, which is why there is a second signature rather than just a header.This does not close the replay hole on its own. A receiver that ignores the new headers is exactly as exposed as before — the registry cannot make a receiver check freshness. Adoption means verifying the second signature and bounding the timestamp. Documented that way in
WEBHOOKS.mddeliberately: "the registry now has replay protection" would be false on the day it shipped.
Added
-
A batched retrieval-visibility check,
ExtendedRegistryStore::visible_ctx_ids.GET /log/entriesgates everyctx_idit echoes on the fullGET /contexts/{ctx_id}rule, and it did so one context at a time — a blockingRegistryServer::retrieveper entry, up to the 256-entry page cap. One query now answers a whole page. Measured on SQLite over a full 256-entry page: 991µs batched against 21.9ms for the per-id path, ≈22×. The test asserts only that the two paths agree; the timing is printed rather than asserted, because a timing assertion on shared CI is a flake waiting to happen.This ships dormant. The fan-out is still live on the deployed path. Nothing calls
visible_ctx_idsuntil unit H-I-w wireshandlers/log.rs, which is in a crate this change must not touch. Partial by design, not an oversight.It reproduces
retrievesemantics, NOTsearchsemantics — and that distinction is the whole unit rather than a detail. Under RFC-ACDP-0008 §4.5 a named audience member may retrieve aprivatecontext, while search deliberately requires ownership forprivateand excludes the audience (conformancevis-004, the "private/audience retrieval asymmetry"; a listed contributor who is not in the audience is refused, because contributors are provenance, not authorization). Reusing a search predicate here would therefore silently under-disclose — hiding audit entries a caller is entitled to, with no error. The two predicates are correct for their own surfaces and must not be harmonized. Confirmed three independent ways before a line was written: the conformance fixture, the shape of the existing LIST predicate, and the upstreamcan_retrieverule itself.Two further consequences that read as hardening and are actually defects, both guarded by tests that fail if someone "fixes" them: status is absent from §4.5, so a retracted context remains retrievable and must not be filtered; and the tenant gate is half the contract, so batching only the visibility half is a cross-tenant disclosure.
The §4.5 rule is now expressed three times in this workspace — SQLite's predicate, Postgres's, and
retrieve_visiblein Rust. That is forced rather than chosen: the authoritativecan_retrieveispub(crate)in the upstreamacdpcrate andRegistryStoreexposes only a rawget. The duplication is named where it lives and contained by a three-way differential test that makes all three answer for the same rows.No new index and no migration, measured rather than assumed:
ctx_idis the primary key in both backends, so the id lookup is a PK index scan (contexts_pkey, bitmap index scan on Postgres) with visibility and tenant applied as a filter over at most N rows. Adding an index here would be redundant.
Changed
- Postgres's retrieval-visibility predicate is now the named constant
LIST_VISIBILITY_PG. It had been written inline insidelist_contexts, so the batched method above would have made a fourth copy of the §4.5 rule. Extracting it and pointing both methods at the one expression keeps the count at three, and matches the structure SQLite already had.
Fixed
-
Extractor rejections answered
Content-Type: application/acdp+jsonover a plain-text body with noerror.code. A malformed body, a wrongContent-Type, a schema mismatch or an unparseable query value were all rejected by axum before any handler ran, and the outermost media-type backstop then stamped the ACDP media type onto axum's prose. So the wire promised an ACDP error envelope and delivered a parser message — and that message leaked serde type paths, internal struct field names, and byte offsets (Failed to deserialize the JSON body into the target type: agent_id: invalid type: integer 123, expected a string at line 1 column 15).All four now answer a real RFC-ACDP-0007 §5 envelope at their original status:
400malformed body,422schema mismatch,400bad query value, and413for an oversized body that reaches the extractor. Messages are now this registry's own, stable, and describe the request rather than the parser — axum's and serde's text is theirs to change, so echoing it grows an accidental wire contract that breaks on a dependency bump.Statuses are preserved, not collapsed. Mapping these onto the internal error type would have forced
415and422down to400, because that type has no 415-bearing variant. That would have been an artifact of the mechanism rather than a decision, so the mechanism was changed instead: a local rejection type carrying the rejection's own status. The catch-all arm carriesrej.status()for the same reason —JsonRejectionis#[non_exhaustive]and itsBytesRejectionvariant is a 413, so a hard-coded 400 would have downgraded an oversized body on/auth/*while every other test stayed green.Still outstanding: the
415from a missing or wrongContent-Typeis not yet enveloped. Enveloping it requires choosing a §5code; the canonical 25-code registry has none for a media-type failure, and this repo has never emitted a code outside that registry. The question is policy, not engineering, and it is with the project owner. The status is415either way.marker_the_415_rejection_is_not_yet_enveloped_pending_a_rulingfails the moment the ruling is applied, so the gap cannot quietly outlive the question.
Changed
-
acdp_registry_rate_limit_rejections_totalnow distinguishes a global challenge-ceiling rejection from a per-agent one. This changes the meaning of an existing label. A/auth/challengerejection caused by the process-global ceiling was previously recorded asscope="challenge_per_agent"— the same label as one noisy agent. It is nowscope="challenge_global".If you alert on
challenge_per_agent, that series will drop by whatever share of its volume was actually global-ceiling rejections; the missing volume reappears underchallenge_global. Splitting them is the point: the two mean opposite things. A per-agent rejection is one caller to throttle. A global rejection is a flood rotatingagent_idto defeat the per-key limit — the precise attack the global ceiling was added for (#24), which the collapsed label rendered invisible by making it look like ordinary per-agent noise.The
scopevalues are now generated from a single list rather than written out at each call site, so the emitted set, theALLconstant and the documented set cannot drift apart. This taxonomy had already drifted twice:lifecycle_per_agentwas emitted but undocumented, while themetrics.rsdocstring advertised achallenge_globalthat nothing emitted.
Fixed
-
Security: the per-agent publish budget was charged against an UNVERIFIED
agent_id. The publish limiter rancheck— which tests and charges in one step — before the verify/persist pipeline, at a point wherereq.agent_idis whatever the caller wrote in the body. Two consequences, both reachable without any credential: an unauthenticated caller could spend another agent's budget by naming them, and could grow the limiter's bucket map without bound by naming a fresh id each request, because the map key was attacker-controlled.checkis now split.peekruns in the same place, answers the only question that position needs — "is this agent already over budget?" — and never writes: no insert, and no window rollover, so a caller who never earns a charge leaves no trace.recordcharges on the success path, where theagent_idhas been verified. The 429, itsRetry-After, and thepublish_per_agentmetric are unchanged.Two consequences are stated rather than left to be discovered. The enforced bound is now
limit + concurrent in-flight publishes for that agent, notlimit + 1, because peek and charge are separated by the whole pipeline; reserving to close that was rejected because a reservation must live somewhere, and that somewhere is the attacker-keyed map entry this change exists to remove. And a publish that fails late is no longer throttled at all — disclosed and tracked as #242 rather than papered over, because both available fixes are worse than the gap (see the issue).
Fixed
-
Security (cross-tenant disclosure narrowing): a tenant-scoped search could hand back a pagination cursor anchored on another tenant's row. The store layer's
searchcarried no tenant predicate in either backend, so tenancy was applied in Rust to the result set. The foreign rows were removed from the page — no context data was ever served — butacdp::paginationanchorsnext_cursoron the last row the scan touched, not the last row served, deliberately, so that a page emptied by post-SQL filters cannot halt pagination early. The surviving cursor therefore encoded a foreign row's(created_at, ctx_id): an ordering and existence oracle over rows the caller must not know exist, walkable one page at a time rather than a single-shot leak.ExtendedRegistryStore::search_in_tenantis new and putstenant_id = ?in theWHEREclause on both SQLite and Postgres, which fixes the anchor by construction — every row the scan touches already belongs to the caller — and makestotal_estimatetenant-correct on the same scan, with no extra query (measured: 100 of 2000 rows on SQLite, 2 of 506 on Postgres). Both backends run it through a single shared query builder rather than a second copy of the ~260-line filter chain, and a cross-backend assertion inacdp_registry_store::paritydecodes the returned cursor and asserts the anchored row's tenant, so a future divergence fails both backends' suites.Scope, stated plainly: this is not yet reachable, and the underlying leak is not yet closed. No caller invokes
search_in_tenant— the HTTP search handler still uses the protocol-levelRegistryStore::searchplus a post-query filter, so the cursor oracle remains live on the deployed path until the handler is wired (a separate change, in a crate this one deliberately does not touch). What ships here is the storage-layer half plus the guard that proves it: a partial fix, named as such.cursor.rs's module docs previously claimed a cursor holds only "an identifier the requester was already shown"; that claim was false under tenant narrowing and is now replaced with a per-dimension account of what the anchor can name. Thanks to lane-1 for finding that while working on a neighbouring unit and flagging it rather than silently rewording it.No migration: the tenant predicate is already index-assisted in both backends (
idx_ctx_tenanton a selective tenant; Postgres correctly prefersidx_ctx_createdwhen the tenant is not selective), so a new index would be write cost for no measured gain.
Documentation
-
Nine documentation claims that the code contradicted, and seven guards so the next nine cannot hide (unit H-D). The unit's method was that a corrected sentence is worth less than a checkable one: every fix here ships with a command that fails when the claim goes stale, and every guard was falsified — the defect reintroduced, the named test confirmed RED with its expected message, reverted, confirmed GREEN — per assertion rather than per test.
/livezwas undocumented and/healthzwas described as "Storage liveness". That wording is the push toward wiring a storage-gated probe to a KuberneteslivenessProbe, which restart-loops healthy pods through a database outage and discards the in-memory webhook queue on each cycle. Corrected to readiness-vs-liveness, with the missing row and a quick-startcurlwhose body shape was read off the handler rather than assumed.docs/AUTHENTICATION.mdanchored thirteen claims to line numbers, and two had already slipped.context.rs:1350-1367forcaller_from_headers(really 1362; 1350 is the tail of an unrelated handler) andlib.rs:154-156for the/metricsmount (really ~110 lines away — that range is theauthsubrouter). The prose around both was correct. A pin that reads as precise and points at the wrong code is worse than no pin, so all thirteen became symbol citations, and a guard now rejects any.rs:<digits>pin reappearing in that file.docs/MULTI-TENANCY.mdattached a paging hazard to an endpoint that cannot page. It said search and lineage post-filter the tenant binding "with a bounded refill loop … so a page may come back shorter thanlimit".run_search_with_refillhas exactly one caller.lineagedoes post-filter in the handler, but returns the whole lineage in one unpaginated array — no loop, no cursor, no short page to misread — and/lineages/{id}/current404sno current versionon a foreign tenant, deliberately indistinguishable from "no such lineage". All three are now documented separately with the consequence attached only where it applies, and the refill cap is pinned to the constant.The root
CHANGELOG.mdclaimed nothing in it had shipped. 3,596 lines under a single## [Unreleased]heading across three released versions. Retired to a pointer; this file is where it went. Reasoning, and why splitting by version was rejected as ill-defined rather than merely expensive, inDECISIONS.mddecision 15.Operator gaps. The section titled "Backup and restore" documented backup only. It now carries a restore procedure (restore before first boot, since migrations run before the listener binds; verify with
/healthz, not/livez, which answers 200 against a dead database), a capacity section, a transparency-log-inconsistency runbook, and rotation for the two credentials that had none — admin tokens, which rotate through the list without a refusal window, and the webhook secret, which has no overlap window at all and must therefore be rotated from the receiving side first. TwoACDP_REGISTRY_*__*_JSONescape hatches were documented nowhere despite being the only way to configureauth.tenant_agentsandplayground.pinned_keyson a deployment without a config file.The finding that outlived the unit. The first route scanner keyed on the literal
.route("and so missed every rustfmt-wrapped mount: it found 19 of 27 routes, and a>= 15count floor passed it, reporting six never-checked routes as covered. A floor cannot detect under-counting, which is precisely what a broken scanner produces. It was replaced with an equality against the number of.route(calls — which then immediately caught a second miss, this file's own prose being counted as a mount. Two of the audit's own claims also failed verification, both flattering the audit: receipt-key rotation was already documented, and the "four undocumented features" were all documented as TOML keys.
Fixed
-
Cache posture:
/metricsand thedid.json404 arm were uncacheable in principle and unlabelled in practice;/healthzwas doing two jobs under one name. Three fixes.GET /metricsnow answersCache-Control: no-storeon every arm — 200, 401 and 405 — closing #218. The 401 is the arm that mattered: a cached 401 is what a shared cache would hand to an authorized scraper. The 405 is the arm that fixes the mechanism in place: the router emits it before any handler runs, so a handler-set header could not reach it. The directive is attached to that route alone, not to the group it shares with the/.well-known/*documents — applying it group-wide was tried and demonstrated to clobberjwks.json'spublic, max-age=300.GET /.well-known/did.json's 404 arm now answersno-store. That 404 means "no receipt signing key is configured" and flips to 200 on an operator action, so a heuristically cached 404 masks the newly available document from every resolver that saw the miss, with no way for the operator to observe or flush it.no-storerather than a shortmax-agebecause the 404 carries noETagto revalidate against.New
GET /livez— always 200, never touches storage,no-store./healthzreports storage readiness and 503s during a database outage, which is right for a load balancer and catastrophic for a KuberneteslivenessProbe: it restart-loops a process that is alive and cannot fix the database by restarting, discarding the in-memory webhook queue each cycle. Nothing in this repo wired/healthzthat way, so this was a latent trap rather than a live bug — butdocker/RAILWAY.mdtold operators to point Railway's healthcheck at/healthzwithout mentioning the 503 arm, and that half is fixed too.Guards falsified, not merely added. The
did.jsonassertion previously pinned the defect as intended behaviour ("the 404 arm sets no Cache-Control"); it is now inverted. The/livezguard is a wire test against a closed storage pool, because the route-classification test is a source scan that fails on routes present-but-unclassified and never on the reverse — deleting/livezentirely leaves it green, which was confirmed by deleting it. The/metricsclassification row was replaced, not deleted: the prior comment claimed deleting it was "how the fix announces itself", and deleting it was shown to fail the build instead. -
Observability: the registry minted
x-request-idvalues that reached no response, and middleware-generated413s carried no error envelope. Two defects in the middleware stack, both inbuild_router.SetRequestIdLayerwas applied beforePropagateRequestIdLayer, andRouter::layermakes the later call the outer one — the inverse oftower::ServiceBuilder, whose doc example the stack was written against. SoPropagateRequestIdran first and read a request header thatSetRequestIdhad not written yet, and the generated UUID reached no response at all — not merely the middleware-generated ones, but plain200s too. A client-suppliedx-request-idechoed back correctly, which is what hid this: any test or manual check that sent its own id saw the header and concluded the feature worked. The pair is now inverted and hoisted outside the body limit, the timeout and CORS, so a413, a408and a CORS preflight all carry an id.Separately, both
413paths violated RFC-ACDP-0007 §5. WithContent-Lengthset,RequestBodyLimitLayershort-circuits before calling inner and hard-setsContent-Type: text/plain, which the outermostif_not_presentmedia-type layer cannot correct. Streamed (noContent-Length), the response reached the per-route layer and so advertisedapplication/acdp+json— over a plain-text body. A response-rewriting layer now gives both thepayload_too_largeenvelope; it keys on whether the body actually parses as an envelope rather than on the media type, because the streamed path already claimed the right type while carrying the wrong payload.The existing regression test asserted status and
Content-Typeonly, and passed against a non-JSON body via a code path its own comment misidentified. It is split into_with_content_lengthand_chunked, both assertingerror.code. The Content-Length variant was run red before the fix.408is deliberately not covered. RFC-ACDP-0007 §5 has no wire code for a timeout, so the envelope would have to sayinternal_error— worse than silence, because it misattributes a client-side timeout to a server fault. Minting arequest_timeoutcode is a change to the shared §5 registry and is tracked separately. -
SQLite swallowed a corrupt
contributorscolumn and skipped an admission check while reporting success. The predecessor'scontributorswas decoded with.ok().and_then(...).unwrap_or_default(), so an unreadable value became an empty list. That is not cosmetic:contributorsfeeds the RFC-ACDP-0014 §4 predecessor-admission check, so a legitimate contributor was refused withSupersededTarget::NotFoundwhile the check itself reported success. Postgres already errored here (TEXT[]decoded with?), so this was a backend divergence as well as a silent wrong answer. SQLite now fails loudly.The finding that surfaced this claimed it contradicted the existing test
admission_is_not_skipped_when_the_predecessor_body_is_undecodable. It does not — that test corruptsbody_json, a different field. It establishes the principle rather than covering the case, which is why this ships with its own test. -
SQLite relied on sqlx's implicit busy timeout.
commit_publishholdsBEGIN IMMEDIATEacross the receipt-minter callback, so a writer can legitimately hold the write lock for as long as that callback plus an fsync takes; a concurrent writer waiting less than that surfacedSQLITE_BUSYas a 500 with no retry — a spurious failure under ordinary contention. The timeout is now set explicitly and named. Mapping busy to a retryable 503 is a separate change in a different crate and is not included. -
Postgres narrowed
versiontoi32, where SQLite usedi64.PublishRequest.versionis a client-suppliedu32; Postgres bound itas i32(wrapping above 2^31-1, silently, since Rust'sastruncates rather than panicking) with anINTEGERcolumn to match.contexts.versionis nowBIGINTand the casts arei64::from, so the two backends agree by construction rather than by both being narrow.Scope, stated precisely: this is a parity fix and defence in depth, not a live exploit closed. The finding called it unreachable "because
put()has no production callers", which was wrong — the casts were incommit_publishand the row INSERT, both on the live publish path. It is nonetheless unreachable, for a different and measured reason: the request builder requiresversion == 1for a first publish andprev + 1for a supersession, so a publish carrying 3_000_000_000 is refused before the store sees it on both backends. Reaching 2^31 would take ~2 billion sequential supersessions.
Added
-
Indexes on
contexts(domain)andcontexts(expires_at)in both backends. Both columns are used byGET /contexts/searchfilters and neither was indexed, so both filters were full scans. Theexpires_atindex is partial (WHERE expires_at IS NOT NULL), matching the predicate the query actually writes. Thedata_periodfilters remain scans deliberately — they read out ofbody_jsonthrough a conversion, so indexing them needs an expression or generated column, which is a schema decision with no measured volume behind it yet. -
Retrieval could serve a context as
activewhile also serving itsretractedevent.get()andlineage()read the context row and its lifecycle events as two separate queries with no shared snapshot, on both backends. A retraction committing between the two reads produced a response carryingregistry_state.status: "active"alongside aretractedlifecycle event — contradicting the RFC-ACDP-0013 §7.2 precedence (retracted > superseded > expired > active) that both backends'row_to_contextis documented to guarantee. A consumer trustingstatuswould act on withdrawn data.Unlike the search bugs above, this one was present on both SQLite and Postgres; it is not a divergence but a shared defect.
Fixed by reconciling the row-derived status against the events actually loaded, in one shared helper (
acdp_registry_store::lifecycle::reconcile_retraction) applied at all four call sites. Retraction wins from either source, so the served pair is self-consistent by construction — a future read path that forgets to take a snapshot cannot reintroduce the contradiction. Failing closed is deliberate and asymmetric: being briefly stale about a republish is safe, servingactivefor retracted data is not.A transaction-per-read would also have fixed it and was the first design; reconciliation was chosen because it fixes the shape rather than the one call site. Two preconditions were checked against the code before relying on the cheaper fix —
lifecycle_eventshas noDELETEin either backend (append-only), and the event and the denormalized flag are written in the same transaction — so the event log can never be missing a retraction the flag knows about. -
Search:
q=returned different results on SQLite and Postgres, and a code comment claimed it did not. SQLite indexed with FTS5's defaultunicode61tokenizer — no stemmer, no stopwords — while Postgres usedplainto_tsquery('english', …)over anenglishtsvector, which stems and drops stopwords. Measured on both engines:query sqlite (before) pg q=runningagainst "run report"0 rows 1 row q=theagainst "the quarterly figures"1 row 0 rows Postgres's semantics win, and SQLite was raised to them. Postgres is the production backend and stemming is better search behaviour, so degrading it to reach agreement would have been a product regression rather than a fix. The cost lands on SQLite's FTS index, which is derived data rebuilt from
contexts— reversible, and no context data is touched.Two mechanisms, matched in two places: migration
013_fts5_porter.sqlswitchescontexts_ftstotokenize = 'porter unicode61'(stemming), andfts5_escapedrops stopwords query-side because porter does not (measured). This changes user-visible search results on SQLite: inflected queries now match, and stopword-only queries now match nothing.Three of FTS5's four operator keywords (
NOT,AND,OR) are themselves English stopwords, so they are now dropped before quoting rather than quoted — which is exactly what Postgres does (plainto_tsquery('english', 'NOT hack')→'hack'). That is strictly safer: a caller cannot obtain operator semantics either way, and withORremoved they cannot widen a query to a disjunction.NEARis the one keyword Postgres keeps, so it is still quoted, and it now carries the operator-neutralization property in the tests.The honest limit: FTS5
porterand Postgres's snowballenglishare different implementations and will not agree on every word in the language. The parity suite pins the mechanisms — a stemmed match happens, stopwords are dropped, dropping them does not empty the rest of the query, terms are AND-ed, case folds — rather than claiming stemmer identity, which would be the same kind of overclaim as the comment this removes.The stopword list is PostgreSQL 16's own
tsearch_data/english.stop(127 entries) and is verified against Postgres at test time rather than trusted as a hand-copied table: the pg suite asserts every entry is still a stopword according to the server. A hand-maintained list whose staleness nobody notices was the failure mode worth designing out. -
Search:
data_period_start_after/data_period_end_beforereturned wrong results on SQLite. The two predicates compared RFC 3339 timestamps lexicographically as TEXT, because they read out ofbody_json(chrono serde output —Zsuffix, 0/3/6/9 fractional digits) while the bound was bound asto_rfc3339()(+00:00suffix). Two different serializers for the same instant, and since'+'(0x2B)< '.'(0x2E)<digits< 'Z'(0x5A), a stored whole-second value sorted after a bound naming that very same instant. Measured directly in sqlite3:'2026-01-01T00:00:00Z' <= '2026-01-01T00:00:00+00:00' -> 0So an inclusive upper bound excluded a context whose period ended exactly on it, and the mirror case wrongly included one that started before a lower bound. Postgres was always correct — it casts to
timestamptz. This changes user-visible search results on SQLite: queries that silently returned the wrong set now return the right one.Fixed by comparing numerically via
unixepoch(…, 'subsec')on both sides, which normalizes suffix and fractional width together. Binding aZ-normalized string instead would not have been enough —'…00Z'still sorts after'…00.500Z'. The fix is query-side only: no migration, andbody_jsonis untouched because its exact bytes are thecontent_hashpreimage.created_at/expires_atfilters were checked and are not affected — both their stored and bound sides go throughto_rfc3339(), so their lexical order does hold. That was measured rather than assumed by analogy.
Added
-
A cross-backend parity suite (
acdp_registry_store::parity), so storage divergence fails a test instead of shipping. The bug above survived a green CI because the conformance suite is SQLite-only and neither backend's contract suite exercised a single search filter — 114 stored rows across the pg suite carried zerodata_periodvalues. Assertions now live once, generic overExtendedRegistryStore, and each backend contributes a thintests/parity.rsthat runs them; a divergence fails both suites rather than hiding in whichever one nobody duplicated it into. Demonstrated by reverting the fix: the shared test goes red on SQLite and stays green on Postgres. -
CI: 23 Postgres contract tests reported success when no Postgres was present. Every test in
crates/acdp-registry-pg/tests/store_contract.rsopens withlet Some(url) = pg_url_or_skip() else { return };, and an earlyreturnfrom a#[tokio::test]is a pass — so an absent database was indistinguishable from a passing one in CI output. Measured on the same binary, both exiting 0 and both printing23 passed: 0.01s withACDP_REGISTRY_TEST_PG_URLunset versus 0.62s with it set. A 62x gap behind an identical green summary, and nothing in the log to tell them apart. Deleting or mis-spelling the URL inci.ymlwould have taken the requiredtestsjob permanently and invisibly green on zero Postgres assertions.Fixed by
ACDP_REQUIRE_PG, mirroring theACDP_REQUIRE_CONFORMANCEgate this repo already built for the same bug class and never applied here: when set, a missingACDP_REGISTRY_TEST_PG_URLpanics instead of skipping. It is set on the two CI steps that provide a Postgres service. The gate is deliberately opt-in rather than unconditional, becausecargo test --workspaceruns this suite with no database on purpose — an unconditional panic would break a step that is correct as written.Currently gates the 23 tests in
acdp-registry-pg; the 11 inacdp-registry-server/tests/pg_integration.rshold a second copy of the helper and are covered by the same CI env var once that copy is updated. -
Two CI guards that were never guarding, plus three claims that were false. Unit H-C. Every finding below was reproduced by experiment before being fixed — the audit that produced them could not compile or run anything, and running them refuted part of what it reported.
The receipt key the registry published was never checked against the key it signs with. RFC-ACDP-0010 §8 has a consumer verify a receipt against the key it resolves from
/.well-known/did.json. Settingreceipt.rs:100to&[0u8; 32]— publishing an all-zero key while still signing with the real one, so no consumer could verify any receipt — left the workspace suite at 524 passed / 0 failed, byte-identical to baseline. The only existing read ofpublicKeyMultibaseassertedstarts_with('z'); the HTTP test asserted fragment ids; and the receipt-verifying test used a key derived from its own seed rather than the served document. Two guards added, each falsified against that mutation; the end-to-end one takes its verification key only from the served bytes.A wrongdir
ACDP_SPEC_DIRsilently disabled four conformance ratchets even under require-mode.spec_families()returnedNoneon a missingregistries/profiles.jsonwithout consultingrequire_conformance(), unlike its neighbourspec_fixtures(). It now asserts.Narrower claim than it first appears, and the correction matters: this does not make CI catch a wrongdir spec directory — CI already caught it. The run was failed by
registry_advertisable_profiles_matches_spec_derived_set, which gates onspec_root()alone, proceeds, and dies inread_json. The last tripwire was one test being inconsistent with its four neighbours, so the obvious tidy-up — making all five skip uniformly — would have produced exactly the fully-green failure the audit imagined. That trap is now documented at the test itself, which keeps an independent require-mode check rather than sharing one.Also fixed there: a bare-fixtures
ACDP_SPEC_DIRin default mode used to fail hard via that same panic, despite being a layoutresolve_fixture_direxplicitly supports. Andreplays_spec_fixtures_when_presentwas not skipping but degrading — bucketing fixtures by filename heuristic instead of spec-declared families while reporting ok. The silently-affected set was six tests, not five.
Changed
-
The published Docker image now builds from the committed lockfile.
--lockedoncargo chef cookandcargo build --releaseindocker/Dockerfile.Cargo.lockwas tracked and in the build context but neither build step used it, so the ghcr image could compile dependency versions no CI run ever tested. The pre-existing--lockedoncargo install cargo-chefpins the tool, not this repo's build — it reads as though the build were already locked, which is how this stayed open, and it is now annotated to say so. Verified in a real build: current lockfile compiles, a deliberately stale one fails withthe lock file /app/Cargo.lock needs to be updated but --locked was passed to prevent this. Completes the half of the CI-lockfile change (#227) that was reported not-done rather than closed. -
CI: supply-chain and reproducibility gates that actually assert something.
[sources] unknown-registry/unknown-gitflipped todeny(free today: zero git-sourced and zero non-workspace path dependencies).--lockedadded to all 18 CI cargo invocations that resolve dependencies, so a build can no longer silently use versions the lockfile never pinned.Two settings were deliberately not flipped, each for a measured reason.
yankedstayswarnbecausedenyfails today — this workspace already depends on two yanked crates (spin,wnaf) and nobody noticed, which is the finding rather than a reason to shrug; clearing them needs aCargo.lockupdate and should land in the same change that flips the setting.[bans] multiple-versionsstayswarnbecause duplicate versions exist today and are normal in a Rust graph. -
CI: the memory test step was renamed, not fixed, and the name now says so.
cargo test (memory)→cargo test (memory build; storage backend NOT covered). Under those flags all four integration files are cfg-gated away and report 0 tests each; the 71 that run are config validation and workspace scans, none touchingMemoryStore. The fix the finding proposed cannot work:acdp-registry-serveris bin-only,MemoryStorelives in a module of that binary, and integration tests link only against a lib target — a probe importing it fails witherror[E0433]: unresolved module or unlinked crate. CoveringMemoryStorerequires a test module insidememory_ext.rsor a crate restructure; neither is in this change. AMemoryStore::getthat always returnedOk(None)still passes this step. It is now labelled accurately rather than left overclaiming. -
CI: coverage gained a floor (
--fail-under-lines 75), where before it rendered a summary and uploaded lcov while asserting nothing and could only ratchet down invisibly. 75 is deliberately conservative: the local baseline is 79.91% lines, but that run had no Postgres, so CI's figure will be higher than the number the floor was derived from. This fails the coverage job; it does not block a merge, becausecoverageis not a required check. -
CI:
acdp-registry-typesis now built and tested atfeat=[]deliberately (closes #221), with a correction #221 does not carry — that configuration was already compiled there incidentally, bycargo test -p acdp-registry-pg, which declares the dependencydefault-features = false. A stale comment inci.ymlclaiming otherwise has been corrected. The incidental coverage depends on that step keeping its-pform, since--workspaceunifies features. -
Security (availability):
GET /contexts/search?limit=could abort the registry process from an unauthenticated request. The handler sized its accumulator withVec::with_capacitydirectly from the caller-suppliedlimit, using.max(1)— a floor, with no upper bound.limitis au32off the query string and the route is reachable without anAuthorizationheader, so?limit=4294967295requested 893 GB in one allocation and the process aborted with SIGABRT. The store-side.min(100)did not help: it runs after the accumulator is allocated.limitis now clamped in the handler to the same cap the stores enforce. Regression test landed deliberately red first, so the PR's own CI history shows it catching the live defect. -
Wire behaviour: the registry now emits a cache posture on requester-relative responses (#205, the wire half of #190). Additive response headers — no client parses their absence and nothing about request handling changes — but it is a wire change, which is why #205 was split out of #190 rather than shipped with the docs fix.
Requester-relative routes (
GET /contexts/{ctx_id},/contexts/{ctx_id}/body,GET /contexts/search,GET /lineages/{lineage_id},/lineages/{lineage_id}/current,GET /log/checkpoint,GET /log/proof,GET /log/entries, and the publish/retract/republishPOSTs) now answerCache-Control: privateandVary: authorization, x-tenant-id./auth/*,/admin/*andGET /healthzanswerCache-Control: no-store. The three/.well-known/*documents are unchanged atpublic, max-age=300, and a test pins all three.GET /metricsis knowingly outside the posture and is tracked as #218.No live cache-poisoning bug existed — every header the registry emitted was already on a requester-invariant document. This closes a hardening gap: requester-relative routes carried no directive at all, which is safe only for as long as nobody puts a CDN with a default TTL in front of the registry.
privaterather thanno-storeon the data plane: the threat is shared caches, whichprivateexcludes precisely, whereasno-storeonly adds protection against caches that ignore directives anyway while forbidding legitimate same-requester client caching.Varynames both axes becausex-tenant-idis the only tenant signal whenauth.enabled = false.
-
rcpt,lhrandlogmove fromDEFERREDtoEXCUSED, closing #130; the coverage tally is now 21COVERED/ 8EXCUSED/ 0DEFERRED(29 families). Tests only — no runtime, API or wire-format change.The three families were never uncovered in the sense
DEFERREDmeans. Each splits in two: a producer/emission half this registry implements, whose spec goldens are recomputed here (rcpt-001,lhr-001,log-001,log-003), and a consumer-role verification half. Only the verification residue was deferred, and it is not coverable by this harness at all: those fixtures name profilesHARNESS_PROFILESdoes not advertise and carry noendpointand novectorsarray, so a "direct pass" over them would exerciseacdp-types/acdp-cryptorather than this registry. Verification is the consumer's obligation under RFC-ACDP-0010 §8 (receipts), RFC-ACDP-0011 (lineage-head receipts) and RFC-ACDP-0012 (transparency logs).DEFERRED's contract is that every entry cites an open tracking issue, so closing #130 forced the choice: reclassify, or leave the ratchet citing a closed issue. They are excused on the obligation-ownership ground — the strong kind, the same onerotrests on, where no harness configuration change could make this registry responsible — and explicitly not on the weaker current-profile groundlcrests on.EXCUSED's doc comment now names those two grades and requires every entry to say which it claims.This extends the REG-11 Phase 14
lcruling (a user decision) to three further families, with the maintainer's explicit approval. The extension is stated in the code, at each moved entry and in the block comment above them, rather than swept in silently.Rule 1 (
no_excused_family_is_required_by_our_profile) still holds: none of the three appears inacdp-registry-core'srequired_fixturesorconditional_fixturesat spec pind1f06d0, and none is inCORE_INEXCUSABLE_FAMILIES. -
DEFERRED_PARTIAL_DIRECTis nowPARTIAL_DIRECT, and its invariant widened fromDEFERREDtoDEFERRED∪EXCUSED. The old name and invariant coupled four good golden-test pins to one bucket, so a reclassification that changes nothing about the pinned coverage would have reddened all four. The half that carries the weight is unchanged and still enforced: aCOVEREDfamily must not appear there, because its tests belong inCOVERED's ownDirect(...)list. Both enforcement tests were renamed and generalized to scanEXCUSEDreasons alongsideDEFERREDones:deferred_partial_direct_test_functions_are_present→partial_direct_test_functions_are_present, anddeferred_reasons_naming_golden_tests_are_pinned_by_partial_direct→classification_reasons_naming_golden_tests_are_pinned_by_partial_direct. -
Anti-vacuity work forced by
DEFERREDbecoming empty. The prose/pin tie iteratedDEFERREDalone and would have silently become a no-op. It now scans both buckets and checks the forward direction — every testPARTIAL_DIRECTpins must be named by its own family's reason prose — so a pin and the sentence justifying it can no longer be deleted independently.PARTIAL_DIRECTis also asserted non-empty. The partition test itself is unaffected: its real property (everyKNOWN_FAMILIESfamily classified exactly once) is proven by its loop overKNOWN_FAMILIES, not overDEFERRED.Both mutations were verified red before being relied on: renaming
rcpt001_registry_receipt_golden_recomputed_and_remintablefailspartial_direct_test_functions_are_present, and deleting its name fromrcpt's reason prose failsclassification_reasons_naming_golden_tests_are_pinned_by_partial_direct. -
The mutation-oracle thread moved from #130 to #216. The file's own anti-vacuity guards are substring oracles over
include_str!that catch wholesale deletion and gutting but notassert!(true)-grade hollowing; the real fix iscargo-mutantsor a fault-injection harness. #130 had been the de-facto anchor for that separate concern, so it was split out before #130 closed. Update (U-502): that fix has landed for a bounded scope —.cargo/mutants.tomland the scheduled.github/workflows/mutants.yml, baseline 74 mutants / 48 viable / 46 caught / 2 survivors. See the U-502 entry at the end of this file.#216remains open for the rest. Left standing rather than rewritten: it was true when written. -
Corrected two false security claims in the Railway recipe, and disclosed its read posture (#208).
docker/RAILWAY.mdclaimed theJWT_SECRET"is never validated" with auth off, and that thechangemerejection "is gated on auth being enabled". Both have been false since W3-U5: thechangemeand base64/≥32-byte checks run at startup whenever the secret is non-empty on HS256, regardless ofauth.enabled— only the empty-secret check is auth-gated (validate_config,crates/acdp-registry-server/src/main.rs). No boot outcome changes; the doc now matches the binary.The recipe also now states its read posture plainly, which it never did: with auth off and
auth.anonymous_public_readsat its defaultfalse, no context is readable by anyone —publicincluded — because the retrieval predicate isanonymous_public_reads || requester.is_some()and auth-off makes every caller anonymous. Publishing still works. The recipe documents both opt-in flags and what each one opens, including that enabling auth lets any valid token holder read everypubliccontext: token issuance gates on the challenge binding, expiry, algorithm and DID method (default["did:web"]), with no agent allowlist, so the bar is control of any domain serving adid.json.The recipe keeps auth OFF. The held W2-U3 patch that would have enabled it is superseded and must not land: its prose predates W3-U5 and misstates the secret-validation gating, and enabling auth would have widened
publicreads through a flag named for something else.
Added
-
CI now builds all eight valid feature configurations, not four (#200). The
clippyjob gained four steps:storage-pg,playground,storage-memory,playground, no-backend (--no-default-features), andplaygroundwith no backend. The space is 4 backend states (sqlite | pg | memory | none) x playground on/off; the four new ones were valid, buildable and never exercised, so a change could break one and land green.No wire, API or runtime behaviour changes. The two backend-less configurations were warning-dirty and would have failed under CI's
RUSTFLAGS: "-D warnings", socrates/acdp-registry-server/src/main.rsgained acfg_attr-scopedallow(dead_code)that applies only when no storage backend is enabled — the configuration in whichrun()is deliberately inert and the entire serve path is unreachable by construction. Every configuration that can actually serve still treatsdead_codeas a hard error.Two things #200 did not know, recorded because they cost time to find. The issue lists three missing configurations; there are four —
playgroundwith no backend was missed, and it was broken in exactly the same way. And the issue's suggested fix, applied literally, would have turned CI red on the first run: the backend-less builds emit 7 dead-code warnings in the bin target and 2 in the test target, which-D warningspromotes to errors.--all-featuresremains impossible here by design and is not the fix — it enables all three backends at once and trips thecompile_error!atmain.rs:45-54, which rejects any pair. That is why coverage is enumerated, and theci.ymlcomment above the new steps is now the single index of the space, with the arithmetic that generates the count.Scope: eight is this binary's feature space, not the workspace's.
acdp-registry-typesbuilds without its defaultaxumfeature — a supported consumer scenario per its own manifest comment — and no CI job builds it. #200 assumed that one was covered incidentally; it is not. Filed as #221 rather than widened into this change.
-
linandcapsmove fromDEFERREDtoCOVERED(REG-11Phase 7,#115): two new direct-vector tests incrates/acdp-registry-server/tests/conformance.rs.lin_vectors_reproduce_lineage_derivationreuses the existingassert_lineage_vectorhelper (previously exercised only via can-001) againstlin-001-lineage-derivation-golden's 3 vectors.lin-001carriesapplies_to_profiles: ["acdp-registry-core", "acdp-consumer"], but this is a pure-function check ofderive_lineage_idwith no HTTP leg, so it deliberately bypasses the runtime profile gate rather than adding one.caps_vectors_validate_capabilities_documentruns all 7 caps-* fixtures'input.response_bodythroughacdp::validation::validate_capabilities, plus caps-007's 3reject_variants(hand-applied against the single dotted path they override,limits.max_publish_per_minute). Measured against the fixtures directly: caps-001/006/007(base) accept and caps-002/003/004/005 plus caps-007's 3 variants reject — 4 rejecting base fixtures, not 6. caps-006 in particular is an accept case: theCapabilitiesDocumentschema tolerates unknown top-level fields (only itslimitssub-object is closed). Rejection is accepted from either serde deserialization orvalidate_capabilitiesitself, since both areschema_violationand indistinguishable to a real consumer. No HTTP leg here either:acdp-registry-serveris bin-only, so this crate cannot import its ownbuild_capabilitiesfor an HTTP-level comparison.
lc(the third family originally filed under#115) remainsDEFERRED— it is profile-gated, not closeable by a direct-vector pass.MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected: both new tests are non-HTTP vector passes.
-
GET /healthznow reports the running build (REG-11Phase 9,#117): the body carries a top-levelversionalongsidestatusandstorage, on both the200and the503/degraded response — build identity matters most when the service is unhealthy, which is also the precedentacdp-control-planesets in its own health tests.The frozen contract is one field, not a body shape:
GET /healthzMUST include a top-level"version": a non-empty, human-readable identifier of the running build. It SHOULD begin with the package's SemVer and MAY carry SemVer build metadata (+g<shortsha>). Consumers MUST treat it as opaque — display or equality at most, never parsing.The value is composed as
CARGO_PKG_VERSIONplus an optional+g<shortsha>, because every workspace crate still carries a placeholder0.1.0and the package version alone would not distinguish two builds. CI injects the commit through a newACDP_BUILD_SHAbuild ARG indocker/Dockerfile, fed from.github/workflows/docker.yml. Outsidedocker.yml— a localcargo run, or any other image build —ACDP_BUILD_SHAis unset andversiondegrades to the bare0.1.0, which does not uniquely identify a build. The unique-identification property holds for images built bydocker.yml, not universally. No new dependency and nobuild.rs:.dockerignoreexcludes.git/, so agit describeat build time could not work.The ARG is declared after the
cargo chef cooklayer. Its value changes every commit, so declaring it earlier would invalidate the dependency cache the two-stage build exists for; the final layer rebuilds each commit regardless, so it costs nothing there.docker.yml's smoke step now asserts the running image'sversioncarries the commit it was built from, so the injection path is covered by CI rather than assumed. -
GET /admin/statusgains abuildgroup (REG-11Phase 9,#117):version(the same string/healthzserves),commit, andstorage_impl. Coarse identity stays public; the finer detail is disclosed only behind the existing admin bearer. The endpoint's other groups are unchanged and it remains bearer-gated.commitis omitted entirely whenACDP_BUILD_SHAwas unset. That absence is meaningful rather than an error: it is how an operator tells adocker.yml-built image from any other build without reading docs.storage_implreports the compiled-in store type (fromstd::any::type_name), not astorage-*Cargo feature name. It is an opaque diagnostic identifier:type_nameoutput carries no stability guarantee and may change across compiler versions, so it must be displayed rather than parsed or branched on. This deviates from the REG-11 plan, which specified astorage_featurestring — those features are declared onacdp-registry-server, not onacdp-registry-corewhere the handler lives, socfg!(feature = "storage-sqlite")there does not merely evaluate false, it fails to compile under the-D unexpected-cfgsimplied by CI's-D warnings.acdp-registry-coreis generic overS: ExtendedRegistryStoreprecisely so it need not know about storage backends.Closing
#117unblocksacdp-ui-console#64; it does not by itself make that console display a live registry version, which needs its own change there.
-
metaanddata-refmove fromDEFERREDtoCOVERED(REG-11Phase 10,#130): two new direct-splice tests incrates/acdp-registry-server/tests/conformance.rs, following the fragment-splice-into-a-self-signed-publish techniqueanc001_well_formed_anchor_is_accepted_and_round_trips/anc002_malformed_anchor_content_hash_is_rejectedestablished — neither family is reachable through the generic replayer (both carry only aninput.*_under_testfragment, no top-levelrequest, and meta-003 expects a positive publish outcome the replayer's Shape A refuses by design).meta001_003_metadata_depth_and_size_caps_enforcedcovers all 3meta-*fixtures (RFC-ACDP-0002 §3.3/§5.2): meta-001's concrete depth-9 metadata and meta-003's depth-8 boundary are used verbatim; meta-002 carries no concrete payload at spec pind1f06d0(only a described construction), so this test builds the described "100 keys, each ~700 bytes" shape itself and proves — viaacdp::crypto::canonicalize_value— that it clears the fixture's own 65536-byte JCS boundary before sending it. meta-001/002 splice their metadata onto a metadata-free base request's struct literal (the anc-002 technique:RequestBuilder::build()would otherwise reject them client-side); meta-003 builds normally and round-trips (served body + recomputedcontent_hash), mirroring anc-001.data_ref001_007_publish_path_rejections_enforcedcovers data-ref-001..007 (RFC-ACDP-0002 §6) — the 7 DataRef publish-path rejections inacdp-registry-core'srequired_fixtures, verified directly againstregistries/profiles.jsonat this pin.data-ref-008-external-data-ref-hash-mismatchisapplies_to_profiles: [acdp-consumer]only (a consumer fetch-time check) and is correctly excluded, not merely omitted. Each fixture'sdata_ref_under_testfragment deserializes directly into the realDataReftype and is spliced into a data_refs-empty base request the same way. Two fixtures needed adaptation, not straight splicing:- data-ref-005's own JSON carries a placeholder string ("<a base64
string whose decoded byte length is 65537>", not valid base64) by
design; this test builds a real 65537-byte payload and proves via a
decode round-trip (same
STANDARDbase64 engineacdp_validationitself decodes with) that it clears the cap. - data-ref-007 exposed a genuine spec/dependency divergence: spec pin
d1f06d0'sacdp-data-ref.schema.jsonnestscontent_hashinsideembedded(citing this exact fixture in its own description), but theacdp0.9.1dependency this registry runs has not caught up — itsEmbeddedContenttype has nocontent_hashfield at all (#[serde(deny_unknown_fields)]onencoding/contentonly), andacdp_validation::verify_embedded_hashreads the DataRef-levelcontent_hash, never a nested one. Splicing the fixture's JSON verbatim would fail at deserialization with an "unknown field"schema_violation— the right HTTP status by accident, for the wrong reason, not thedata_ref_hash_mismatchthe fixture pins. The test instead moves the fixture's own wrong-hash and content values to the DataRef-levelcontent_hashfield the dependency's validator actually reads, proving the intended RFC-ACDP-0002 §6.6 check 8 semantic holds here rather than silently masking the divergence.
- data-ref-005's own JSON carries a placeholder string ("<a base64
string whose decoded byte length is 65537>", not valid base64) by
design; this test builds a real 65537-byte payload and proves via a
decode round-trip (same
MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected — measured:conformance: replayed 30 exchange(s); failures=0, identical before and after this change; both new tests use a dedicated harness outside the generic replay loop, same asanc-*. Both self-skip cleanly (measured) whenACDP_SPEC_DIRis unset. Mutation-tested (measured) against a scratch copy of the spec tree outside this repo and outside the pinned spec worktree: perturbing meta-003's metadata to depth 9 and data-ref-001'sexpected.error_codeeach independently turned their respective test red, then were reverted (the scratch copy was discarded, not the pinned worktree).
-
bodyandstatusmove fromDEFERREDtoCOVERED(REG-11Phase 11,#130): two new direct tests incrates/acdp-registry-server/tests/conformance.rs. Neither family carries arequest/publish shape at all — both are pure response-body assertions (body-*'sinput.body_fields_under_test,status-*'sinput.response_body/response_body_excerpt) — and both fields are registry-ASSIGNED (origin_registry) or registry-DERIVED (registry_state.status), so noPublishRequestfield lets a caller drive either through this registry's own HTTP surface. Both tests prove the "registry never emits" contrapositive instead: publish a context,GETit back, and assert the SERVED shape.body001_002_origin_registry_hostname_never_did_formcovers body-001/002 (RFC-ACDP-0002 §3.1): the servedorigin_registryequals this harness's configured authority, matches ctx_id's own authority component, contains no:, and passesacdp::validation::validate_body(body-001's positive shape); and body-002's owndid:web:registry.example.comliteral, spliced onto a clone of the served body, is confirmed rejected by that same validator asschema_violation— the fixture's negative value gets real teeth even though this registry can never produce it itself.status001_004_served_status_matches_open_enum_patterncovers status-001..004 (RFC-ACDP-0004 §4.1): the servedregistry_state.statusround-trips throughacdp::types::primitives::Status::parse(status-001's forward-compat positive shape), and each of status-002/003/004's own rejected literals (uppercase, embedded space, empty) is confirmed to fail that same open-enum parser and never equal the live served value.
MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected — measured:conformance: replayed 30 exchange(s); failures=0, identical before and after this change; both new tests use the sharedharness()outside the generic replay loop, same asmeta/data-ref. Both self-skip cleanly (measured) whenACDP_SPEC_DIRis unset. Mutation-tested (measured) against a scratch copy of the spec tree outside this repo and outside the pinned spec worktree: perturbing body-002'sorigin_registryto a bare hostname (no longer a DID) and status-002'sstatusto"active"(no longer malformed) each independently turned their respective test red, then the scratch copy was discarded (not the pinned worktree) and both tests were reconfirmed green.
-
schemamoves fromDEFERREDtoCOVERED(REG-11Phase 12,#130): one new test,schema_vectors_openness_and_absent_vs_null_enforced, incrates/acdp-registry-server/tests/conformance.rs, covering the 8schema-*ids inacdp-registry-core'srequired_fixturesat spec pind1f06d0(verified directly againstregistries/profiles.json; the backlog plan's claim of 8 was correct, but only as the required-for-core subset — 14schema-*.jsonfixtures exist on disk, 001..014; the other 6 targetacdp-consumeronly and are correctly out of scope): schema-002/003/008/009/010/011/012/014.- schema-002 is the "registry never emits" half of RFC-ACDP-0007
§3.3.1's openness map applied to
PublishResponse— a real publish through the harness, asserting the served response both parses asPublishResponseand carries nocontent_hashkey, not merely that the fixture's own malformed body fails to deserialize. - schema-003/008/009/011/012 are publish-path rejections, each isolating
one closed sub-object (
EmbeddedContent,Signature,DataPeriod) or non-nullable optional (DataRef.format/.location) by splicing the fixture's own malformed JSON fragment into an otherwise-valid signed body's rawValueand POSTing it directly — a new helper,anc_publish_raw, since the violation in each case is a shape the typedPublishRequest/DataRefbuilders cannot produce at all (deny_unknown_fieldshas no Rust field to carry an extra key;Optionfields withskip_serializing_ifnever serialize a literalnull). - schema-010 surfaced a genuine wrong-reason trap, the same shape as
Phase 10's data-ref-007: its own
input.response_body_excerptis a bare{"limits": {...}}, not a fullCapabilitiesDocument, so deserializing it verbatim fails on missing top-level fields beforeLimits'sdeny_unknown_fields(confirmed live atacdp-types-0.9.1/src/capabilities.rs:96-98, not the stale0.8.4line the plan cited) ever rejects the excerpt'slimits.extrakey. Worse, replacing this registry's own realcaps()document'slimitswholesale (rather than merging) stripslimits.idempotency_key_ttl_seconds, whichacdp_validation::validate_capabilitiesrequires wheneversupports_idempotency_keyistrue(ascaps()'s is) — a second, unintended rejection reason caught by mutation-testing this test itself before landing it. The final version merges the fixture'slimitskeys onto a clone ofcaps()'s own (self-checked "accept")limitsobject, isolating exactly the field the fixture exists to exercise. - schema-014's
input.response_bodyis already a full, otherwise-valid document (onlylimits.idempotency_key_ttl_secondsisnull), so it is used verbatim — no splicing, no trap.
MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected — measured:conformance: replayed 30 exchange(s); failures=0, identical before and after this change (48 -> 49 passed inconformance.rs;conformance_gate.rsunchanged at 1 passed). The new test self-skips cleanly (measured) whenACDP_SPEC_DIRis unset. Mutation-tested (measured) against a scratch copy of the spec tree outside this repo and outside the pinned spec worktree: independently perturbing each of schema-002/003/008/009/010/011/012/014's fixture data or expectations, and deleting a fixture outright, turned the test red every time (the schema-010 case surfaced the wrong-reason bug above, fixed before the mutation suite passed clean); the scratch copy was discarded afterward, not the pinned worktree, which diffs identical to upstream both before and after.Repeated-fact updates alongside the
COVERED/DEFERREDmove: theCORE_INEXCUSABLE_FAMILIESdoc comment's "alreadyCOVERED" / "stillDEFERRED" family lists, and both "the remaining N cite #130" counts (module header and theDEFERREDdoc comment). TheCORE_INEXCUSABLE_ FAMILIEScomment also carried a pre-existing staleness predating this phase —capsandlinwere still listed as "currentlyDEFERRED" there despite closing toCOVEREDin Phase 7, the same shape of gap Phase 10 found and fixed for a different repeated fact — corrected here while touching the same sentence to removeschema. - schema-002 is the "registry never emits" half of RFC-ACDP-0007
§3.3.1's openness map applied to
-
sig,rev, anddkmove fromDEFERREDtoCOVERED(REG-11Phase 13,#130): five new direct tests plus one existing test newly registered, incrates/acdp-registry-server/tests/conformance.rs. All three families are golden-signature/did:key vectors the generic replayer cannot reach (no top-levelrequest, and the positive vectors carry real cryptographic material a synthetic replay can't verify), so — likeanc/can— they get DIRECT, in-process coverage beside the replayer, which still (correctly) reports them non-HTTP-replayable.- Real fixture counts, corrected against the plan:
sigcarries 3 fixtures at spec pind1f06d0, not the 1 the plan named —sig-001(Ed25519, unconditionally required),sig-002(ECDSA-P256, 2 vectors, conditional onsupported_signature_algorithmsincludingecdsa-p256, which this file's sharedcaps()already advertises — a LIVE obligation, not a hypothetical one), andsig-003(did:key, conditional on advertisingdid:key).revcarries 2 fixtures (rev-001,rev-002), but onlyrev-001applies toacdp-registry-core—rev-002isacdp-consumer-only and stays out of scope (HARNESS_PROFILESadvertises onlyacdp-registry-core).dkcarries 4 fixtures (dk-001..dk-004), all bundled into the sameconditional_fixturesentry assig-003(supported_did_methodsincludesdid:key) exceptdk-003, gated the opposite way (did:keyNOT advertised atacdp_version >= 0.2.0). - New mechanism:
playground.pinned_keys. This file's sharedcaps()/harness()cannot cryptographically verify adid:websignature (no live DID resolver in-process), sosig-001/sig-002/rev-001(alldid:webproducers) useacdp-registry-core's existingplayground.pinned_keys+pinned_onlymechanism (playground.rs, FEAT-Phase5) instead: a dedicated per-test harness pins the fixture's own public key for its producer DID, so the publish path runs REAL Ed25519/ECDSA-P256 verification (acdp::crypto::verify) against a golden vector, not the unpinned playground branch that — per its own doc comment — accepts "any agent_id+signature pair". sig001_ed25519_golden_verified_offline_and_accepted_via_pinned_publishrecomputescanonical_form/content_hashfromsig-001'sproducer_contentviaacdp::crypto::canonical_preimage, verifies the golden signature offline viaacdp::crypto::verify::verify_ed25519, then POSTs the fixture's ownpublish_request_bodyverbatim to a pinned-key harness and asserts HTTP 200.sig001_signature_byte_perturbation_is_rejectedis the accompanying mutation proof the task explicitly asked for: flips one base64 character of the golden signature and asserts the SAME pinned harness now rejects itinvalid_signature/400 — proving the pinned path is real verification, not a rubber stamp.sig002_ecdsa_p256_golden_accepted_and_der_signature_rejectedcovers both ofsig-002's vectors: the RFC-6979-deterministic P-256 accept vector, and — sameproducer_content, same mathematical signature, re-encoded as 70-byte ASN.1 DER instead of the required 64-byte IEEE 1363 r‖s — the registry MUST reject the DER encoding asinvalid_signatureperregistries/signature-algorithms.md's NORMATIVE prohibition.acdp::crypto::verify::verify_ecdsa_p256rejects on decoded length before any cryptographic operation, so an implementation that DER-decodes first and verifies second would wrongly accept it; this test proves this repo's dependency doesn't.rev001_key_revocation_context_golden_accepted_and_self_signed_rejectedcoversrev-001(RFC-ACDP-0014 §4/§5) two ways: the fixture's own accept vector (a producer-signed key-revocation context, signed by K2, revoking K1 —KeyRevocation::check_not_self_signedpasses sincefingerprint(K2) != revoked_key_fingerprint), plus a hand-built negative (sameproducer_content, sincecontent_hashexcludessignature/key_id, re-signed and re-pinned as K1 — the very key being revoked). The negative isrev-001's ownverification_steps[4]made concrete viaProducer, not a separate fixture id (none exists in the pinned spec): the registry MUST reject itkey_not_authorized/403 (RFC-ACDP-0014 §5 step 2). The plan's own text flaggedrev's priorDEFERREDreason as wrong — confirmed: that reason described "before/after compromise-boundary semantics", which isrev-002's content (out of scope), notrev-001's.rev-001is a plain accept golden, structurally identical tosig-001/003.dk001_002_004_did_key_resolution_negatives_hit_schema_layer_not_resolvercoversdk-001(wrong multicodec prefix),dk-002(3 malformed-multibase cases), anddk-004(fragment mismatch) — and documents a genuine wrong-reason trap found while writing it, of the same shape as Phase 10'sdata-ref-007, but more severe: empirically verified (against this exactacdp = "0.9.1"dependency), this repo's own upstream schema-layer validation (acdp_validation::validate_agent_did/validate_did_key_key_id_form) calls the SAME did:key resolver functions the RFC-ACDP-0001 §5.11.1 resolver path would, but wraps every failure asAcdpError::SchemaViolation— soverify_publish_ request_signature_offline(the code path that would emitkey_resolution_failed) is never reached for any of these three fixtures. Unlikedata-ref-007, there is no alternative request shape that reaches the intended path — this is architectural, not fixable by restructuring the test's input.dk-002's own fixture text explicitly sanctionsschema_violationas an alternative outcome ("Registries MAY reject case-by-case at schema validation... if their did pattern catches it first") — a genuine pass.dk-001anddk-004carry no such carve-out and DO diverge from their pinnedkey_resolution_failed— a real conformance gap in theacdpv0.9.1 dependency this phase cannot fix (out of scope: onlyconformance.rs/CHANGELOG.mdmay be touched). This test asserts the OBSERVEDschema_violation/400 rather than the fixture's literal code for those two, with the divergence stated in both the test's doc comment and here — an always-red test pinned to a code this dependency will never produce is not a usable CI ratchet. Flagged for follow-up: a cross-repo issue against theacdpcrate (mirroring howdata-ref-007was filed asagentcontextdistributionprotocol#60) has not been opened by this phase — recorded here for a human to action, not filed unilaterally.did_key_golden_vector_accepted_and_gated(pre-existing, REG-11 Phase prior to this one) already exercisedsig-003's accept path anddk-003's capability-gate rejection end-to-end; it simply had never been registered inCOVERED. This phase registers it under bothsiganddk.
MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected — measured:conformance: replayed 30 exchange(s); failures=0, identical before and after this change; every new test uses a dedicated pinned-key harness outside the generic replay loop, same asanc-*/meta/data-ref. All new tests self-skip cleanly (measured) whenACDP_SPEC_DIRis unset. Mutation-tested (measured) against a scratch copy of the spec tree outside this repo and outside the pinned spec worktree: perturbingsig-001'scontent_hash, perturbing one base64 character ofsig-001's golden signature, perturbingrev-001'scontent_hash, and shrinkingdk-002's case count each independently turned their respective test red, then were reverted (the scratch copies were discarded, not the pinned worktree).This phase also corrected one pre-existing, unrelated staleness bug found while editing the same doc comment
sig/rev/dklive in: theCORE_INEXCUSABLE_FAMILIESdoc comment still listedcapsandlinasDEFERRED(Phase 7 moved both toCOVEREDand missed updating this prose). Corrected here alongside the three-family move this phase makes; the two "remaining N cite #130" counters elsewhere (accurate before this phase) are updated from 13 to 10 to match. - Real fixture counts, corrected against the plan:
-
did-ssrf,err, andratemove fromDEFERREDtoCOVERED— the last threeCORE_INEXCUSABLE_FAMILIESstragglers — andlcmoves fromDEFERREDtoEXCUSED(REG-11Phase 14,#115/#130): three new direct tests incrates/acdp-registry-server/tests/conformance.rs, plus one ratchet declaration requiring no new test.- Real fixture counts, verified against
registries/profiles.jsonat spec pind1f06d0:did-ssrfcarries exactly 5 fixtures (did-ssrf-001..005), all unconditionally inacdp-registry-core.required_fixtures.errandratecarry exactly 1 fixture each (err-001,rate-001), also both unconditionally required — not derived from the plan's prose, read directly off the pinned spec file and cross-checked against the fixture count on disk. - The prior
did-ssrfDEFERREDreason's claim was verified, not trusted, before building on it (a previous agent overturned a wrong ruling on this exact family once already): re-readingextract_shapesdirectly confirms all 5did-ssrf-*fixtures really do fall out at fixture-shape extraction (targets_unadvertised_profilepasses them —applies_to_profilesincludesacdp-registry-core— but theirinput.endpoint/input.bodyshape satisfies none of Shapes A/B/C/D), landing in the same"non-HTTP fixture (vectors / schema / informative)"bucket ascan/sig. Measured directly in this run's own skip tally:did-ssrf: 5 (non-HTTP fixture (vectors / schema / informative)),err: 1 (...),rate: 1 (...). The seam claim held up too:acdp::did::WebResolver(re-exported by theacdpfacade this crate already depends on) appliesSsrfPolicy::default()unconditionally, andacdp::safe_httpexposesreject_if_any_forbiddenandSsrfPolicy::classify_redirectfor the mixed-answer and same-authority-redirect rules — all reachable from this dev-dependency test binary with zero new Cargo dependencies (theclient/test-transportfeatures were already unified on transitively viaacdp-client's own unconditionalacdp-did/clientrequirement and this crate's existingacdp = { features = ["test-transport"] }dev-dependency). did_ssrf001_005_producer_did_resolution_refuses_forbidden_targets:did-ssrf-001/002/003(IP-literal cases only — hostname-basedadditional_test_casesneeding real DNS, e.g.metadata.example.com, are explicitly excluded and documented, not silently dropped) drive the real, asyncWebResolver::resolveend-to-end — offline and deterministic, since the SSRF policy refuses before any socket activity — and cross-check the resultingAcdpError::KeyResolutionagainst this repo's ownRegistryError::wire_code/http_status(key_resolution_failed/400, matching the fixtures exactly).did-ssrf-004/005drivereject_if_any_forbidden/classify_redirectdirectly with the fixtures' own DNS-answer and redirect literals — one layer beneath the fullWebResolver/reqwest pipeline (no live TLS/DNS mock stood up), so the test does NOT claim a final wire status for those two, only that the pure enforcement point itself rejects (plus, by reading — not running —classify_reqwest_error, documents why that should still resolve tokey_resolution_failed/400 end-to-end: the"SSRF policy"substring match). This distinction (measured vs. derived) is spelled out in the test's own doc comment so it is never mistaken for a full-pipeline proof.err001_internal_error_envelope_matches_pinned_shape_and_leaks_nothing:err-001's own text calls its trigger "implementation-defined... fixtures cannot reproduce" — no black-box request can deterministically force a persistence-layer throw. This test instead drives the REAL productionRegistryError -> HTTP responseprojection (theIntoResponseimpl every handler'sResult<_, RegistryError>return type goes through) for all five variants that legitimately project tointernal_errorin this codebase, each seeded with a deliberately sensitive-looking detail string, asserting none of it reaches the wire — mirroringacdp-registry-types::error's owninternal_errors_do_not_leak_detailunit test, now also proven from this repo's conformance file against the fixture's pinned status/code/content-type. The fixture's illustrative message text ("An unexpected error occurred.") is deliberately NOT asserted verbatim — this repo's real implementation emits a different string ("internal error"), and nothing inerr-001's own text pins the wording, only the non-leak property.rate001_publish_rate_limit_trips_429_with_retry_after: not a missing seam —limits.publish_rate_per_minuteis a live config knob enforced by the in-processAgentRateLimiter, already proven end-to-end for the sibling/auth/challengelimiter byhttp_integration.rs'schallenge_endpoint_is_rate_limited. This test exercises the SAME limiter on the publish path for real: a harness configured withpublish_rate_per_minute = 1, one publish that succeeds, a second (different content, same producer) that trips the limiter, asserting the REAL HTTP response (429,application/acdp+json,error.code == "rate_limited", a presentRetry-Afterheader) against the fixture's own pinned status/code.lcmovesDEFERRED->EXCUSED(a user decision, not re-derived here):lc's 3 fixtures each carryapplies_to_profiles: ["acdp-registry-lifecycle"], disjoint from this harness's advertisedHARNESS_PROFILES = ["acdp-registry-core"]— verified directly againstregistries/profiles.jsonat this pin, both for each fixture'sapplies_to_profilesand forlc's absence fromacdp-registry-core'srequired_fixtures/conditional_fixtures.no_excused_family_is_required_by_our_profilestill passes (spec-gated guard confirming no excused family is actually owed). This needed no new test — only a written excuse inEXCUSED— since the runtime profile gate already skips all 3 fixtures; what was missing was the declaration, not a capability. #115 now has zeroDEFERREDmembers.- Corrected a stale claim found while editing the same module doc
comment this phase's changes live in (same discipline as Phase 13's
unrelated
CORE_INEXCUSABLE_FAMILIESfix): the "Required-checks decision" paragraph saidconformance (spec fixtures)was NOT among this repo's required branch-protection contexts and that adding it was "deliberately left undone." Re-verified directly against branch protection:required_status_checks.contextsis now["rustfmt", "clippy", "tests", "conformance (spec fixtures)"]— a repo admin actioned the Phase 11 recommendation on 2026-09-01 (seeASSUMPTIONS.md's "Executed" entry for that decision). The paragraph is rewritten to state the current, verified status rather than the stale one; the underlyingReplayed-vs-Directsplit it was explaining is left in place since it is still accurate. (The equivalent stale sentence in this Phase 13 changelog entry above is left as a historical record of what was true when it was written, not rewritten.)
MIN_REPLAYED_EXCHANGES(30) and the exchange replay count are unaffected — measured:conformance: replayed 30 exchange(s); failures=0, identical before and after this change; all three new tests sit outside the generic replay loop, same asanc-*/sig/rev/dk. All three self-skip cleanly (measured) whenACDP_SPEC_DIRis unset — no panics. Test counts: 56 -> 59 passed inconformance.rs(+3), 1 passed inconformance_gate.rs(unchanged) — 60 total.cargo fmt --checkandcargo clippy --all-targets --features storage-sqlite,playground -- -D warningsboth clean.Mutation-tested (measured) against a scratch copy of the spec tree under
/tmp, outside this repo and outside the pinned spec worktree — each mutation applied, run in isolation, confirmed red, then reverted before the next; the scratch copy was deleted afterward and a final run against the real pinned worktree reconfirmed green (60/60, replayed 30, failures=0):err-001:expected.error_code-> a wrong string -- RED (wire_code self-check).err-001:expected.http_status->599-- RED (http_status self-check).rate-001:expected.response_body.error.code-> a wrong string -- RED (wire error.code mismatch).rate-001:expected.http_status->599-- RED (real HTTP status mismatch after tripping the real limiter).did-ssrf-001:expected.error_code-> a wrong string -- RED (fixture self-check).did-ssrf-004:dns_mock.answerschanged to two PUBLIC addresses (removing the forbidden one) -- RED (reject_if_any_forbiddengenuinely stopped rejecting; the mixed-answer enforcement is really exercised, not assumed).did-ssrf-005:redirect_tochanged to keep the same port (:443, matchingredirect_from) -- RED (classify_redirectgenuinely started accepting the redirect).did-ssrf-005: the fixture's own positive-controladditional_test_cases[0].authority_matchflippedtrue->false-- RED (the test's independently-computedclassify_redirectresult,true, mismatched the now-wrong fixture expectation,false) -- proves this assertion is a real comparison, not an echo of whatever the fixture says.
- Real fixture counts, verified against
curmoves fromDEFERREDtoCOVERED, andrcpt/lhr/logare narrowed to their consumer-role residue (REG-11Phase 15,#130): five new direct tests incrates/acdp-registry-server/tests/conformance.rs, plus a newDEFERRED_PARTIAL_DIRECTregistry and three new ratchet self-checks.cur001_002_expired_and_malformed_cursors_are_distinguisheddrives bothcur-*fixtures over real HTTP. It publishes three contexts, takes a cursor the registry actually minted, and first proves the un-aged cursor both returns 200 and advances past page 1 — without that control, a registry that silently ignored the cursor entirely would satisfy the test. It then ages only the plaintext mint stamp (CURSOR_TTL_SECS = 3600, so a 3,605,000 ms offset) and asserts status,error.codeand content-type, every expected value read from the fixture rather than hardcoded, pluscursor_expiredandinvalid_cursorremaining distinct.rcpt001_registry_receipt_golden_recomputed_and_remintable,lhr001_lineage_head_receipt_golden_recomputed_and_remintable,log001_leaf_root_and_inclusion_golden_recomputedandlog003_consistency_proof_golden_recomputedrecompute rather than parse: JCS canonicalization, SHA-256 preimage/leaf hashing, RFC 6962 root and consistency verification, offline Ed25519 verification, and an Ed25519 re-mint through this repo's ownacdp_registry_core::receipt::build_signerreproducing the pinned signature byte-for-byte. Each carries a mutation negative. The goldens publishprivate_seed_hexwhileReceiptConfigwants base64, so the seed is hex-decoded and re-encoded; getting that backwards yields a valid-looking signer with the wrong key.- The signer identity is pinned as literals, not split out of the
golden's own
key_id. Deriving authority and fragment from thekey_idthen asserting the re-mintedkey_idmatches is a round-trip tautology —build_signerreassemblesdid:web:{authority}#{fragment}from the very string it was handed, so it passes for any fragment. Confirmed by mutation: with the derived form, rewriting every golden's fragment toBOGUS-FRAGMENTleft all four tests green. rcpt/lhr/logstayDEFERRED, and their reasons now name only what is genuinely uncovered. Each previously claimed two causes, the first of which stopped being true once the goldens landed. Two independent grounds remain, both verified against spec pind1f06d0:rcpt-002/003/004,lhr-002/003/004andlog-002/004declare profiles this harness does not advertise, and advertising one the registry does not implement would make the ratchet lie; andacdp-registry-coreimplements only the producer side of receipts (load_signing_key,build_signer,build_did_document— there is no verify fn), while those fixtures carry noendpointand novectorsarray, so a "direct pass" over them would assert something aboutacdp-types/acdp-cryptorather than about this registry.DEFERRED_PARTIAL_DIRECTcloses the hole that creates: the partition buckets by family, not fixture, so a family legitimately deferred for its residue had no way to pin the golden-half tests — deleting one left every existing check green. Two tests rather than one, because the first was itself falsifiable:deferred_partial_direct_test_functions_are_presentchecks each named test exists, still wears its attribute and still carries its assertion-count ratchet, anddeferred_reasons_naming_golden_tests_are_pinned_by_partial_directties the const's own membership to theDEFERREDprose that claims those tests cannot be deleted.- Seventeen stale
conformance.rs-relative line pins replaced with symbol references, which cannot rot, andno_numeric_self_citationsadded to keep them out. Four stale cross-file pins repaired — three invalidated by theacdp0.10.0 bump, one by spec-pin drift. That test deliberately does not match cross-file pins, since a reference into another file cannot be a symbol this file resolves, so that class stays rot-prone; the limitation is stated in the test's own doc rather than glossed. - The anti-vacuity guards' claims are deliberately conservative.
Three verification rounds established that a substring test over
include_str!cannot distinguish an assertion from the lettersa-s-s-e-r-tin a comment:{ /* assert */ }defeats the generic check and// EXPECTED_ asserteddefeats the stricter-looking one, andassert!(true)survives any tightening. The guards therefore document that they catch wholesale gutting and deletion, and that is all — proving a test asserts something real needs a mutation oracle (cargo-mutantsor fault injection oversrc/), not a text oracle. Recorded on#130rather than overclaimed in the file. Two updates, appended rather than rewritten, since both were true as written: the mutation-oracle thread moved from#130to#216when#130closed; and the oracle now EXISTS for a bounded scope (U-502 —.cargo/mutants.toml, scheduledmutants.yml, 46 of 48 viable mutants caught, 2 survivors). See the U-502 entry at the end of this file. HARNESS_PROFILES,caps(),config(),KNOWN_FAMILIES,CORE_INEXCUSABLE_FAMILIES,EXCUSEDandMIN_REPLAYED_EXCHANGESare byte-identical to their prior contents — the ratchet was closed on real behaviour, never widened to manufacture coverage.#130stays open: the residue above is real and is not owed.
-
A coverage-completeness ratchet closes the gap Phases 7-10 left open (
REG-10Phase 11): the existing fourKNOWN_FAMILIES/EXCUSEDratchet tests fail only on an unclassified family or an illegitimate excuse — never on zero coverage, which is exactly howvis/idemsat uncovered before Phases 8-10, and howcaps/lin/lc(#115) and 15 more families (#130) still do today. Two new consts and two new tests incrates/acdp-registry-server/tests/conformance.rsclose it:COVERED: &[(&str, &[CoverageMechanism])]models the two legitimate coverage mechanisms a family can claim —Replayed(produced >= 1 exchange inreplays_spec_fixtures_when_present's own per-family tally) andDirect(&[fn_names])(named in-process test functions). Modelling both, rather than derivingCOVEREDpurely from replay results as originally preferred, is load-bearing:anc,can,idem, andwitare genuinely covered by direct tests and produce zero replayed exchanges, so a replay-only derivation would have branded all four uncovered — includingcan(Phase 7) andidem(Phase 10), the two families this very effort added.visclaims both mechanisms (it clearsMIN_REPLAYED_EXCHANGES, currently confirmed still 30, AND carries 10 dedicated per-fixture test functions).DEFERRED: &[(&str, &str, u32)]lists the 18 families with no coverage yet, each with a non-empty reason and an open tracking-issue number:caps/lin/lccite #115; the remaining 15 (body/schema/sig/dk/did-ssrf/data-ref/cur/err/meta/rate/status/rcpt/lhr/log/rev) cite #130.known_families_partition_into_covered_excused_or_deferredis the new fifth ratchet test, and the one point of this phase: it assertsKNOWN_FAMILIES == COVERED ∪ EXCUSED ∪ DEFERREDas a set. Unlike the four existing ratchet tests, it is deliberately unconditional — it touches no spec data, so it does not skip whenACDP_SPEC_DIRis unset. The requiredtestsCI job runscargo test --workspacewith no spec configured, so this is what actually blocks a merge; verified directly by temporarily adding an unclassified 30th family and confirming only this test goes red under that exact job configuration.covered_direct_families_have_present_test_functionsis the mutation-proof half forDirect-mechanism families: it scans this file's own compiled-in source (include_str!) for each named test function, confirming it still exists with a test attribute directly above it. This is deliberately an EXISTENCE check, not a correctness check — the honest limit of what a spec-independent, self-inspecting const can verify; documented as such rather than overclaimed, including the two evasions it cannot catch (#[ignore]written above#[test], and the whole function wrapped in a/* ... */block comment). Verified by temporarily stripping a#[test]attribute and observing the expected failure, then reverting.- The
Replayedhalf of the same mutation proof lives insidereplays_spec_fixtures_when_presentitself: every family claimingCoverageMechanism::Replayedmust have produced >= 1 exchange in that very run's tally, checked against the spec at the pinned SHA. - Required-checks decision, recorded but not executed (a repo-admin
action, out of scope for this diff):
conformance (spec fixtures)should joinrustfmt/clippy/testsas a required branch-protection context. Confirmed directly against this repo's branch protection that it is not currently required. Leaving it advisory means only the spec-independent half of this ratchet (the set-equality test and the direct-mechanism scan) can ever block a merge; a regression that silently drops a family's replayed exchanges while itsCOVEREDentry and direct tests stay intact would go unnoticed by required checks alone. Flagged as a follow-up for a human with repo-admin access.
-
idem-001throughidem-005(RFC-ACDP-0003 §6 idempotency-key lifecycle) now have DIRECT, fixture-driven coverage (REG-10Phase 10). These five fixtures don't fit Shape D — their top-level key ispreconditions(an existing idempotency record, never a literalctx_id), notsetup, andidem-005'sinputis a bare array of publish descriptors, notscenarios[]— and none of Shape D's seeding machinery has anything to seed here: the object under test IS the publish response itself. So, same precedent asanc/can/vis-003/vis-007: two direct tests, run beside the generic replayer (which still, correctly, shows all five as unreached — "requires pre-seeded state").idem001_004_publish_idempotency_key_lifecycle_and_restart_durabilityruns the full, mutually-dependentidem-001→idem-002→idem-003→idem-004sequence against one shared, file-backed harness:idem-001(fresh publish), a genuine registry-restart proof (reconnect to the same on-disk SQLite file as a NEWSqliteStore/RegistryServer/Router, proving the idempotency record — not just the context row — survives),idem-002(same key + hash → byte-identical stored response returned, not re-executed),idem-003(same key, different hash → 409duplicate_publish, with a mutation proof thatidem-001's record is unmodified AND that the rejected body was never persisted), andidem-004(new key, same content → a freshctx_idANDlineage_iddespite byte-identical content).idem005_no_support_ignores_idempotency_key_headerruns against a SEPARATE, non-playground harness (a did:key producer through the SDK's verified publish path) that genuinely does not advertisesupports_idempotency_key, proven by reading it back offGET /.well-known/acdp.json, and asserts two independent publishes with the same key both succeed with DIFFERENTctx_ids. This repo'sPOST /contextsreturns HTTP 200 on success, not the fixtures' own literal201, and never sets aLocationheader at all — both deviations are recorded in a doc comment following theanc-001precedent, not "fixed".idem-002'sregistry_must_notclause (no re-DID-resolution, no re-signature-verification) is stated honestly as un-observable to a black-box HTTP assertion — the full-response-body equality is the closest indirect evidence available, not a claim of having observed the internals.idem-006(a concurrency-race fixture) andidem-007(a capabilities-document validation check gated onacdp_version >= 0.3.0) are recorded not-owed with their real reasons:idem-006sits in the pinned spec'stolerated_outcomes— a THIRD obligation category this repo's model didn't previously name, alongsiderequired_fixtures/conditional_fixtures— not a strict requirement;idem-007's version gate never fires against this harness's advertised0.1.0.MIN_REPLAYED_EXCHANGESis unchanged at 30 — neither test replays through the generic harness. Finding recorded, not fixed (out of this phase's scope — test file and CHANGELOG only):acdp-registry-core's playground publish branch (crates/acdp-registry-core/src/handlers/context.rs, the manual idempotency lookup/record dance aroundpublish_unverified_for_tests) honors ANYIdempotency-Keyheader whenever one is present, with no check ofsupports_idempotency_keyanywhere in that branch — unlike every other publish path (verified did:web, did:key, pinned-verified), which routes through the upstreamacdp-serverSDK's ownRegistryServer::commit_via_storeand gates correctly. It is unreachable in a deployed registry, though not for the reason one might assume: the playground publish branch carries no#[cfg(feature = "playground")](only the admin router does), so it compiles into a stock build and activates on the runtime toggle alone. What actually makes the divergence unrealizable is thatcrates/acdp-registry-server/src/main.rs:1026hardcodessupports_idempotency_key: truewith no config knob, so the shipped binary can never advertisefalse— the state in which honoring the header would be wrong. It becomes live the moment that field is made config-driven, as every other capability already is. Filed as an issue rather than fixed here; it meantidem-005had to be built against a non-playground, did:key harness instead of the shared playground harnessidem-001..004use — see the doc comment onidem005_no_support_ignores_idempotency_key_headerfor the full write-up. -
vis-008(5 scenarios) — the last parkedvisseed shape,setup.lineages— now replays end-to-end through Shape D (REG-10Phase 9c).MIN_REPLAYED_EXCHANGESrises from 25 to 30. This is the last fixture needing a THIRD substitution table,fixture_lineage_id -> minted_lineage_id, built alongside the existing ctx_id and DID tables (SeedLineage/SeedLineageVersion,parse_seed_lineages) rather than special-cased outside the substitution layer.vis-008seeds two two-version lineages (a1 -> a2, bothrestricted, same audience/owner;b1public->b2private, same owner — the head is private while v1 stays public) through REALProducer::supersede_body()-chained publishes, in ascendingversion-field order (the fixture carries no explicitsupersedeskey), never a direct store write.status(active/superseded) is never a seed input —PublishRequesthas no such field — it is asserted as the registry COMPUTES it from the supersession, cross-checked against the fixture's ownstatusliteral per lineage version; at pin417211fthe registry's computed status matches every one of the fixture's four literals exactly, so noanc-001-style deviation note was needed. Two response shapes no earlier phase needed:GET /lineages/{lineage_id}returns a bare JSON array (scenario 0'sstranger on a fully-restricted lineage gets 200 + [], not 404 — asserted via an EXPLICITbody == []equality check, not inferred frommatches_ctx_idsbeing an empty set, because an unsubstituted or unknownlineage_idalso 200s with an empty array);GET /lineages/{lineage_id}/currentreturns a singleFullContextobject with singularctx_idand a nestedregistry_state.status(scenario 4). A newassert_substitution_soundhelper generalizes the existing raw-and-percent-encoded substitution-occurred proof (previously inline and ctx_id-specific) so it covers the lineage_id table too — closing the one place a wrong-but-200 answer could otherwise read as correct. Mutation-proven:vis008_mutated_lineage_version_order_fails_replayswaps theversionfield between lineage b's two entries (nothing else), which reverses which version publishes first and flips the lineage's real head from private to public — scenario 3 then gets 200 instead of its expected 404, failing the replay.ret-002(alsosetup.lineages) was checked deliberately and does NOT become replayable as a side effect: its lineage versions carry novisibilitykey and one carriesexpires_at, both outsideparse_seed_lineage_version's recognized set, and its first lineage requires an "abnormal state: every version is superseded" that a real publish sequence cannot produce (publishing v2 always makes v2, not v1, the active head) — it remains classifiedrequires pre-seeded registry state, unchanged from before this phase. -
vis-002(4 scenarios),vis-005(4 scenarios), andvis-009(3 scenarios) — multi-context, capability-toggling visibility fixtures — now replay end-to-end through Shape D, andvis-007gets direct in-process coverage (REG-10Phase 9b).MIN_REPLAYED_EXCHANGESrises from 14 to 25 (14 +vis-002's 4 +vis-005's 4 +vis-009's 3). Two Shape D capabilities Phase 8 built and proved only synthetically are exercised against real fixtures for the first time: a per-scenario router rebuild driven byregistry_capabilities_subset.anonymous_public_reads(vis-002scenarios 2/3 toggletrue→falseback-to-back against the identical anonymous requester;vis-009togglesfalse→true→falseacross all three scenarios), and ctx_id substitution reaching QUERY STRINGS in both raw and percent-encoded form (vis-005scenario 2'ssearch?derived_from=<percent-encoded private ctx_id>). The substitution-occurred check insidereplay_shape_dwas strengthened alongside this: previously it only asserted no raw literal ctx_id leaked into the built request path, which would have silently missed a failed query-string substitution (the percent-encoded literal would sit unnoticed in the path); it now also asserts, positively, that whenever a scenario's original path referenced a fixture ctx_id at all, the built path carries the MINTED replacement — catching exactly the "substitution silently failed, empty result reads as a legitimate negative" failure mode the Phase 8/9b plans both flag.expectedparsing gains two new assertable (not merely recognized) keys,total_estimateandmatches_ctx_ids— the latter translated through the fixture's ctx_id substitution map at replay time, so a search that returns the right count but the wrong identity (exactly whatvis-005's two same-did:agent:ownerseeds could produce if the Phase 8did_maptwo-pass fix ever regressed) is caught, not just an off-by-one.vis-005's two seeds sharing one literalagent_idis the exact shape Phase 8's GAP 1 (did_mapoverwrite on a shared literal agent) was fixed for but never exercised by a real fixture until now;vis005_private_audience_search_excluded_via_derived_fromassertsdid_map.len() == 1on it directly. Acrossvis-002(3),vis-005(4),vis-007(1, direct coverage), andvis-009(2), the pinned spec fixtures carry exactly 10expected.total_estimateoccurrences; 9 of the 10 are asserted on their exact value, alongsidematches_count. The tenth —vis-005scenario 2,search?derived_from=<private ctx_id>— is not a conformance divergence: the spec explicitly licenses an approximatetotal_estimate("May be approximate; not guaranteed to be exact",schemas/json/acdp-search-response.schema.json; "SHOULD NOT be relied upon for exact counts",rfcs/RFC-ACDP-0005-discovery.md:219; the spec's ownexamples/search/empty-page-post-filter-response.jsonships the identical shape — an empty post-filtered page with a non-zero estimate). One genuine, pre-existing registry characteristic surfaced while building this:total_estimate(bothacdp-registry-sqliteandacdp-registry-pg,DESIGN-01) is computed from the same SQL scan that applies RFC-ACDP-0008 §4.5 visibility, butderived_from(likestatus/tags) is a documented post-SQL refinement applied afterward in Rust — so it is a pre-refinement upper bound for aderived_from- filtered search, not the post-filter countvis-005scenario 2's fixture happens to pin at0; that0is one of several conformant values, and this registry emits another. Verified live:matchescorrectly scopes to empty (proving both thederived_fromfilter and the ctx_id substitution work), whiletotal_estimatereturns the harmless pre-refinement scan count instead. Exact-value assertion is therefore skipped for that onederived_from-filtered scenario (see the carve-out inparse_scenarios_array, and the corpus-wide tripwirederived_from_carve_out_matches_exactly_one_corpus_scenario, which fails loudly if a second such fixture ever appears); every other scenario across all four fixtures keeps the full exact-value assertion. What the carve-out does not skip: leak-invariance (RFC-ACDP-0005 §2.5.5 Q2's MUST that a registry "avoid leaking their existence via per-requester variance in the estimate") is asserted directly against a live registry response invis005_private_audience_search_excluded_via_derived_from— the audience member and an outsider get the identicaltotal_estimateon the samederived_fromquery, both strictly below the producer's.anonymous_public_reads: falseis NOT an unconditional "403" rule:vis-009scenario 2 sets the flagfalsebut expects a successful search because its requester is authenticated — the flag gates anonymous reads only, and both thevis-002/vis-009dedicated tests assert this directly rather than the naive stricter reading.vis-007cannot reach Shape D at all: its scenario 2 (expected: {outcome: "registry_must_not_emit_this_response", rationale}) carries nostatuswhatsoever, soparse_expectedfails on it and, by Shape D's parse-all-or-nothing rule, the whole fixture stays unparseable there —vis007_search_match_restricted_visibility_dispositionseeds the one restricted context directly and replays scenarios 0 and 1 for real (status/matches_count/total_estimateall asserted), same direct-coverage precedent asvis-003; only the MAY-shapedmatch_visibility_field_disposition/consumer_invariantkeys and scenario 2 wholesale are recorded not-assertable. Mutation proofs (an in-memory-only fixture clone, never written to the spec checkout) on bothvis-002(restricted context flipped topublic) andvis-005(the private seed flipped topublic) fail replay specifically on amatches_count/matches_ctx_idsmismatch. Shapes A, B, and C remain textually unchanged. -
vis-001(5 scenarios) andvis-004(4 scenarios) — single-context restricted/private visibility fixtures — now replay end-to-end through Shape D, andvis-003(search response field-naming) gets direct in-process coverage (REG-10Phase 9a).MIN_REPLAYED_EXCHANGESrises from 5 to 14 (4 pre-existing +vis-006's 1 +vis-001's 5 +vis-004's 4).vis-001(RFC-ACDP-0008 §4.5 existence-leak prevention) seeds onerestrictedcontext and exercises producer / audience-member / outsider / genuinely-nonexistent-ctx_id / non-audience-contributor across five requester identities against the same ctx_id — the first fixture in this file to require the bearer path to actually distinguish requesters (Phase 8's proof fixture,vis-006, is requester-identity-agnostic by construction:did:agent:any-authenticated-or-anonymousagainst a public context behaves the same with auth on or off).vis-004(RFC-ACDP-0008 §4.5 / RFC-ACDP-0002 §7 private/audience retrieval asymmetry) seeds oneprivatecontext with anaudienceand covers the same four-way split. Both fixtures carry a scenario withrequest.context_subset_for_test.contributors— a per-scenario mutation of the seeded row'scontributorslist, not a requester swap, and exactly the key Phase 8's allowlist excluded them on. Shape D is widened to fold it onto the (single) seed at seed time rather than left unsupported: the registry's only write path (POST /contexts) mints a newctx_idper call, so there is no in-place "update contributors on this existing row" endpoint to genuinely mutate mid-replay, and applying it at seed time is observably identical to that framing here sincecontributorsnever affects any other scenario's status/error_code —can_retrieveandcan_surface_in_searchbranch only on visibility,agent_id,audienceandanonymous_public_reads, so contributors carries attribution rather than retrieval authorization — and both fixtures are single-seed and retrieval-only. That scoping is deliberate:contributorsdoes gate authorization on the supersession producer-continuity path, so the same seed-time fold applied to a publish/supersede fixture would change authorization rather than preserve it.parse_shape_dfails closed (returnsNone, routing to the existing skip path) rather than guess which seed a multi-seed fixture'scontext_subset_for_testwould target.vis-001scenario 4's genuinely nonexistent ctx_id (…-000000000000, distinct from the seeded…-000000000001) needed no special-casing — it was never seeded, gains no substitution-table entry, and a dedicated test (vis001_restricted_denied_as_404_replays_via_shape_d) asserts the ctx_id map contains exactly the one context that actually was seeded.vis-003has nosetup(onlybackground) and its scenarios useinput.endpoint/input.received_response, neverrequest.method/request.path, so it matches neither Shape D nor Shape B;vis003_search_response_emits_matches_not_resultsdrives a realGET /contexts/searchand asserts the fixture's ownresponse_body_constraints(matchespresent,resultsand its listed alternates absent) directly against the real response body — same precedent as the existinganc/wit/candirect-coverage tests. Its other two scenarios (expected.consumer_behavior/expected.minimum_diagnostic_content) are consumer-side obligations a registry cannot satisfy or violate by construction; recorded not-applicable, with reasoning, in that test's own doc comment rather than silently dropped.vis-004's own mutation proof (seeded visibility flippedprivate→publicon an in-memory-only fixture clone, never written to the spec checkout) fails replay specifically on the outsider/contributor scenarios' now-wrong 404 expectation, alongside the pre-existingvis-006mutation proof — together demonstrating Shape D exercises the registry's real visibility-scoping logic rather than trivially passing. Shapes A, B, and C remain textually unchanged. -
The conformance replayer gains a fourth shape ("Shape D") that seeds registry state before replaying, and
vis-006(RFC-ACDP-0005 §2.2 public-visibility search disclosure) is now the fifth exchange it proves live (REG-10Phase 8). Previously the replayer's three shapes (conformance.rs'sextract_shapes) only handled self-contained exchanges; every fixture carryingsetup— all ofvis-*,idem-*and friends — was a blanket skip ("requires pre-seeded registry state"), because the registry mints its ownctx_idand the fixtures' literal ones (pub-013proves a producer-suppliedctx_idis rejected) can't be replayed against directly. Shape D closes that gap for the shapes it understands: it seedssetup.context_published/.contexts_publishedthrough the real publish API (never a direct store write), building afixture_ctx_id -> minted_ctx_idsubstitution table and, for any seededagent_idthat isn't alreadydid:web(this registry only advertisesdid:web, andpub-008proves it rejects anything else), adid:agent:* -> did:web:*substitution table for a producer identity the harness holds the key for —audienceentries and requester DIDs route through the same table so an audience check stays consistent with whichever bearersuba scenario presents. It mints a per-scenario bearer fromeffective_requester_did(noAuthorizationheader at all when it'snull). Shape D runs under its ownshape_d_config()withauth.enabled = true: the sharedconfig()Shapes A/B/C use leaves auth off, which is right for them since they need no caller identity, but with auth offcaller_from_headersreturnsNoneunconditionally, so every bearer Shape D minted was being discarded. Without that one line the per-scenario bearer is inert and any identity-sensitive assertion built on it would pass for the wrong reason. When a scenario'sregistry_capabilities_subsetoverridesanonymous_public_reads, the harness reconstructs theRegistryServerwith the new capabilities document rather than only rebuilding the router around it:searchandretrievegate that flag on the server's own baked-incaps, not onRegistryConfig, so rebuilding the router alone left the override silently inert. Seeded state survives becauseSqliteStoreisSqlitePool-backed and the pool is anArc, so the clone shares the same in-memory database. Every Shape D fixture gets its own fresh in-memory store, isolated from the shared store Shapes A/B/C replay against. Dispatched deliberately ahead of Shape B (not after the fallback, as an earlier draft of this phase's plan had it): asetup-carrying fixture'sscenarios[]also satisfies Shape B's own predicate, and Shape B has no seeding step — letting it capture such a fixture first would silently replay it against an empty store and read the resulting 404s as legitimate negative results. Shapes A, B, and C are textually unchanged. A fixture whose seed shape or scenario assertions Shape D doesn't recognize yet (setup.lineages,matches_ctx_ids,total_estimate,context_subset_for_test, …) still falls through to the narrowed — not deleted —unseeded_precondition_reasonskip path rather than being partially replayed; this is what keeps this phase scoped to exactly one fixture (vis-006, the only single-exchangevisfixture) even though the rest ofvis-*structurally satisfies Shape D's dispatch predicate. A dedicated regression test,four_pre_existing_exchanges_still_use_original_shapes, asserts the four exchanges replayed before this phase (pub-004,pub-005,pub-008,ret-001) still extract via their original shapes with identical fields — the gravest failure mode this phase could introduce is Shape D silently over-matching one of them. A second dedicated test proves Shape D end-to-end onvis-006and then, against an in-memory-only mutated copy of the fixture (never written to the spec checkout) whose seeded context's visibility is flipped torestricted, proves the replay now fails — demonstrating the harness exercises the registry's real visibility-scoping logic rather than trivially passing. A failed seed publish panics rather than skips, so a broken substitution can't quietly read as "fixture not applicable". Two further tests cover paths no fixture reaches yet, using synthetic in-test fixtures rather than spec reads:shape_d_seeding_maps_one_shared_literal_agent_to_one_minted_didpins the multi-seedcontexts_publishedpath, where two seeds sharing one literalagent_idmust resolve to a single minted DID — seeding is two-pass for this reason, minting every distinct agent before any publish, so a repeat cannot overwrite an earlier mint and anaudiencenaming a later-seeded agent still resolves; andseeded_harness_rebuild_changes_router_behavior_and_preserves_seeded_statepins both halves of the rebuild — that the anonymous-read posture actually changes, and that the seeded rows survive it. Notevis-006itself does not exercise the bearer path: its requester isdid:agent:any-authenticated-or-anonymousagainst a public context, so it behaves identically with auth on or off. The bearer and rebuild mechanisms are proven by the synthetic tests, not by the replayed fixture.MIN_REPLAYED_EXCHANGESrises from 4 to 5. -
can-*(RFC-ACDP-0001 canonicalization & hashing) moves from zero coverage to direct, fixture-driven coverage of all 35 vectors across all 12 fixtures (REG-10Phase 7). None ofcan-*is HTTP-replayable — the family carries no request/response shape at all — yet all 12 ids sit in the pinned spec'sacdp-registry-core.required_fixtures, which makescanmechanically inexcusable under this file'sEXCUSEDratchet. Two new tests inconformance.rsconsume every fixture's own data directly, same precedent asanc/wit:can_vectors_reproduce_canonical_form_and_hashcovers 30 of the 35 vectors (can-001 through can-006, can-008 through can-012) by drivingacdp::crypto's public JCS surface directly —canonical_preimagefor the Body/content_hash-shaped vectors,canonicalize_valuefor can-011's bare numeric-formatting objects (not ACDP bodies, so the Body-specific exclusion-set path is the wrong tool) and can-001's threecanonical_form-only vectors, andderive_lineage_idfor can-001's threelineage_id-only vectors.can-001alone packs three distinctexpectedshapes into its 7 vectors; a naive hash-equality loop would have silently covered only one of them.can-006's two divergent-precision vectors are additionally asserted to produce differentcanonical_form/hash from each other, not just to each independently match their own pinned value. The second new test,can007_registry_created_at_millisecond_truncation, covers the remaining 5 — can-007 alone carries noinput/hash at all, just aregistry_compliancetable keyed off example timestamps — by drivingacdp::time::trunc_msdirectly, the actual functionacdp-registry-sqlite/acdp-registry-pgcall when mintingcreated_at, proving both that it reproduces the canonical millisecond form and that it floors rather than rounds. An explicitEXPECTED_CAN_HASH_VECTOR_COUNT/EXPECTED_CAN_VECTOR_COUNTpair (30 and 35) guards against the vacuous-pass failure mode where a loop silently iterates zero vectors and passes green; proven by mutation on three fixtures (can-001, can-002, can-011), each of which fails when itssha256_hexis corrupted and passes again once restored. Both new tests' doc comments record the tension with this file's own anc-004 precedent (conformance.rs's module doc-comment already argues against re-testing an upstream crate's golden vectors): most ofcan's vectors do exactly that, but the coverage ratchet makescaninexcusable regardless, and the conformance claim is about this binary, not about which crate owns the tested code. No new dependency:acdp::cryptoalready re-exportsacdp_crypto'scanonicalize_value/canonical_preimage/derive_lineage_id, andacdp::time::trunc_mswas already reachable.KNOWN_FAMILIES's doc comment and the module doc-comment both gain acanparagraph mirroring the existingancone — classification unchanged (still "non-HTTP fixture"), only coverage changed. -
The witness aggregator's reject-then-no-write path is now directly tested (
REG-10Phase 4, GitHub issue #112).witness.rs'sverify_and_storewas split at the point right after DID resolution: it now resolves the witness DID document and tail-calls a new privateverify_and_store_resolved(store, log, witness_did, doc_value, cosig_value) -> bool, a verbatim lift of the verify-then-store half (theverify_cosignature_against_own_logmatch through theupsert_witness_cosignaturecall and the three metric-labeled early returns). This is a no-behavior-change refactor —verify_and_store's signature, visibility, and callers are untouched — that makes the store-writing half callable directly against a realSqliteStorewithout a live witness endpoint. Two new tests exercise it:rejected_cosignature_is_not_persisted_by_the_store_path(a forged-root cosignature is reported unstored, and leaves no row at either the forged or the honest checkpoint tuple) andverified_cosignature_is_persisted_by_the_store_path(the positive control — a genuine cosignature is reported stored and reads back). Previously only the pre-store verification helper had forward-guard assertions; nothing exercised the actual persistence path. -
.github/workflows/bump-spec.yml, a manual and dispatch-driven replacement for hand-written spec-pin bumps (REG-10Phase 3, GitHub issue #110). It delegates to acdp-ci's reusablebump-spec-ref.yml@v1workflow, mirroring the existingbump-acdp.ymlpattern:with: file: .github/workflows/ci.ymlnames the file whose single spec-pin anchor gets rewritten,shapicks the target commit (onworkflow_dispatch, the input — blank meaning spec HEAD; onrepository_dispatch, the event payload's SHA), andsecrets: inheritsuppliesACDP_BOT_APP_IDandACDP_BOT_PRIVATE_KEY, already proven available in this repo vianotify-website.yml. The reusable workflow hard-fails on zero or more than one spec-pin anchor in the named file, rewrites only theref:following that anchor, asserts the rewrite landed, and opens a PR on branchdeps/spec-<sha:0:12>for review — it never auto-merges, and it opens no PR when the target SHA already matches the current pin. Two triggers are wired:workflow_dispatch, runnable from the Actions tab today with an optional explicitshainput; andrepository_dispatchonspec-released, which stays inert until the spec repo'snotify-spec-consumers.ymladds this repo to its consumer matrix (as of this writing that matrix lists onlyacdp-rsandacdp-verifier-py).CONTRIBUTING.md's conformance-pin paragraph now points at this workflow as an alternative to bumping the pin by hand. -
anc-001/anc-002/anc-003move from "skipped as non-HTTP by the generic replayer" to direct, fixture-driven coverage, and require-mode CI is confirmed green at spec pin417211f(REG-3Phase 7 — the closing phase ofplans/reg3-anchors.md). None ofanc-001/002/003is replayable throughcrates/acdp-registry-server/tests/conformance.rs's genericextract_shapesat any pin:anc-001expects a positive (2xx) publish outcome carrying acontent_hash/signatureits owninput.notescalls placeholders that don't recompute over the fixture's own body (Shape A refuses any non-400 publish outcome by design), andanc-002/anc-003carry only aninput.anchor_under_testfragment, not a full body. So, following the same precedent already established forwit-001/wit-004and the did:key golden vector, three new in-process tests —anc001_well_formed_anchor_is_accepted_and_round_trips,anc002_malformed_anchor_content_hash_is_rejected,anc003_empty_anchors_array_is_rejected_with_established_ordering— read each fixture's own data via the existingspec_fixtures()/read_jsonhelpers (resolved by the fixture's ownidfield through a directory scan, not a hardcoded filename), splice it into a freshly-signed body built with the same producer/RequestBuildertechnique REG-3 Phase 5 uses, and publish it against a locally-built capabilities document advertisingacdp_version: "0.5.0"(anc_caps_050/anc_harness_050— the sharedcaps(), which stays"0.1.0"forreplays_spec_fixtures_when_present, is never mutated).anc-001asserts HTTP 200 (this repo's actual publish success code, not the fixture's own literal201) plus both of the fixture's stated post-publish invariants (anchors served byte-identical; recomputedcontent_hashmatches).anc-002asserts 400schema_violationand its doc-comment states plainly that this exercises the upstreamacdp_validation::validate_anchorsshape check inherited from theacdp0.8.2 bump, not this repo's own Phase 3 version gate.anc-003asserts 400schema_violationon both a sub-0.5.0and a0.5.0-advertising registry, and additionally pins the ordering Phase 3 already established: on the sub-0.5.0registry this repo's own §10 version gate fires first (message names §10, not the SDK's "MUST be omitted entirely" wording); on the0.5.0registry the gate passes and the SDK's own empty-array rule fires instead.anc's classification is unchanged by this phase — it was neverEXCUSEDand still isn't (KNOWN_FAMILIES's doc-comment now records the added direct coverage) — and the skip manifest inreplays_spec_fixtures_when_presentstill correctly showsanc: 5 (non-HTTP fixture ...), since the replayer itself still doesn't replay anyanc-*fixture; the three new tests run beside it, not in place of it.MIN_REPLAYED_EXCHANGESstays at exactly 4.anc-004andanc-005are deliberately OUT OF SCOPE:anc-004is a pure hash-computation golden vector (no endpoint, no request) overacdp-crypto's JCS/hash pipeline, which this repo delegates to via theacdpdependency and does not own — Phase 5'sanchors_round_trip_byte_exact_sqlite/pg_anchors_round_trip_byte_exactalready prove that pipeline handles anchors correctly through this repo's own storage, which is what this repo is accountable for.anc-005is consumer-side behavioral (a scheme-unaware verifier tolerating an unknown scheme) — a registry has no verifier role, and the pinned spec places all fiveanc-*fixtures inacdp-consumer'srequired_fixtures, never in anyacdp-registry-*profile's.Confirmed green:
ACDP_REQUIRE_CONFORMANCE=1 ACDP_SPEC_DIR=<pinned 417211f checkout> cargo test -p acdp-registry-server --features storage-sqlite,playground --test conformance --test conformance_gateexits 0, withreplayed 4 exchange(s); failures=0and zeroACDP_SPEC_DIR unsetlines. -
Behavioral and structural proof that
anchors[].uriis never dereferenced (REG-3Phase 6). Proves RFC-ACDP-0016 §6's NORMATIVE rule — stricter than the DataRef SSRF posture — that "there is no code path in core verification that ever readsanchors[].uri". Two tests, because neither alone is sufficient:anchors_uri_never_dereferenced_publish_and_retrieve(crates/acdp-registry-server/tests/http_integration.rs) binds a loopbackTcpListener, publishes a context whoseanchors[0].uritargets it, retrieves the context back, and asserts (after a bounded drain window, not an immediate check) that the listener observed zero connections at every point — while webhook delivery (the one subsystem near the publish path that does make a real outbound call) is deliberately wired live against a second, independent listener, so "zero" is a discriminating claim rather than an artifact of a harness that makes no outbound calls at all. The SSRF guard is configured withSsrfPolicy::allow_test_loopback()throughout so the guard is provably not what keeps the anchor listener silent — the claim under test is "nothing attempts the connection," not "a guard blocked it."crates/acdp-registry-server/tests/anchors_uri_never_dereferenced.rsadds the structural half: it enumerates every outbound-HTTP call site in the wholecrates/tree (scoped to the zero-argument HTTP-client dispatch idiom, which cannot collide with channel.send(msg)calls that always take an argument) and asserts the set is exactly the three audited, legitimate ones (acdp-registry-webhook/src/lib.rs,acdp-registry-auth/src/revocation_poller.rs,acdp-registry-core/src/witness.rs) — failing loudly if a fourth ever appears — and that none of those three files mentions "anchor" in any form. Both live mutation checks were performed and confirmed: temporarily adding a throwaway fetch ofanchors[0].urito the publish path turned the behavioral test red (left: 1, right: 0on the zero-connections assertion); temporarily corrupting the structural test's expected-file set turned both structural assertions red with a clear drift diff. Both mutations were reverted before landing. -
Byte-exact
anchorsround-trip proof, both storage backends (REG-3Phase 5). Proves RFC-ACDP-0016 §5's normative requirement and anc-001's stated post-publish invariant:anchorssurvives publish → store → retrieve byte-exactly, such thatacdp::crypto::compute_content_hashover the retrieved body reproduces the publishedcontent_hash. No test in this repo recomputedcontent_hashfrom a retrieved body at all before this (grep -rn compute_content_hash crates/was zero hits) — this is a first for the repo, not just for anchors.anchors_round_trip_byte_exact_sqlite/anchors_two_entries_preserve_order_sqlite(crates/acdp-registry-server/tests/http_integration.rs) andpg_anchors_round_trip_byte_exact/pg_anchors_two_entries_preserve_order(crates/acdp-registry-server/tests/pg_integration.rs) publish a freshly-signed, self-consistent request through the real router (RequestBuilder::build()computes its owncontent_hash— anc-001's own placeholdercontent_hash/signatureare never replayed; anc-001 is used only as the shape reference for the first anchor'sscheme/content_hash), fetch bothGET /contexts/{ctx_id}andGET /contexts/{ctx_id}/body, and assert against both: the servedanchorsarray is order-sensitive deep-equal (rawserde_json::Value, sinceAnchorEntryhas noEq/Hash) to what was sent, AND the recomputed hash matches. The two-anchor test body carries auri, a flattened extension key holding a plain integer (AnchorEntry.extensions,#[serde(flatten)]) and a second flattened key holding1e-7— a value Postgres'sjsonbtype is known to re-render differently (in text form) fromserde_json's own output, unlike the plain integer, which round-trips through JSONB unchanged either way — on the first entry, and a structurally different second entry whoseschemesorts alphabetically before the first entry's (so a "helpful" ascending sort would visibly reorder the pair rather than being a no-op on the fixture). Array ORDER is therefore genuinely exercised (reordering changes the JCS preimage, and an accidental sort would now be caught), and Postgres's JSONB storage (serde_json::to_value, a normalizing representation — number re-rendering, key dedup/reorder) has real surface to diverge from SQLite'sTEXTstorage (serde_json::to_string) if it were going to. It doesn't: both backends reproduce the publishedcontent_hashbyte-exactly — including across the1e-7re-rendering — run against a realpostgres:16-alpinecontainer — no cross-backend JSONB normalization divergence found. The PRIMARY proof of byte-exactness is the hash-recomputation assertion itself: because JCS canonicalization is sensitive to field drop, field mutation, and array reorder alike, a passing recompute on its own already rules out all three. Each round-trip test additionally runs a narrower, supplementary regression guard (assert_ne!) that simulatesanchorsbeing dropped from the served value and confirms the hash-recompute assertion would go red in that specific case — this guard only exercises the drop case, not reorder or mutation, so it does not by itself establish byte-exactness; it exists to catch a regression where the recompute assertion above stops actually depending onanchors(e.g. a future refactor that readscontent_hashfrom a cached field instead of recomputing it). -
RFC-ACDP-0016 §10/§14 version gate (
REG-3Phase 3). RFC-ACDP-0016 (typed externalanchors) is still Draft, not Final — this repo implements the two MUST-reject rules the spec defines for that field while the rest of the RFC remains a plain-library-type pass-through (see the Phase 2acdp0.8.1 → 0.8.2 bump below). A publish request carryinganchorsis now rejected withschema_violation/ HTTP 400 unless both: (§10) the registry's own advertisedacdp_version— the exact string served atGET /.well-known/acdp.json— is>= 0.5.0, and (§14) the request's own declaredbody.acdp_versionis>= 0.5.0(absent ⇒0.1.0perVERSIONING.md's layers table, so an omitted field is rejected exactly like an explicit"0.1.0"). The check runs inpublish_inner(crates/acdp-registry-core/src/handlers/context.rs) immediately after the request body deserializes and before the per-agent rate limiter, so a version-rejected publish never consumes a producer's publish budget, and it sits above thedid:key/ playground-pinned / test-only / defaultdid:webbranch, covering all four publish paths with one gate. No new error variant or wire code was minted — both predicates reuse the existingRegistryError::Acdp(AcdpError::SchemaViolation(..))→schema_violation/ 400 idiom, with the rejection message naming which of the two predicates failed. A privateversion_at_least(v, major, minor)helper (with its own unit tests, including the numeric-vs-lexical"0.10.0" >= 0.5.0case) is reimplemented incontext.rsrather than reusingacdp-validation's own version of the same name, which is private and not re-exported by theacdpfacade crate; it fails closed on any version string that isn't plainMAJOR.MINOR.PATCH.anchors: []on a sub-0.5.0 registry is caught by this gate before the SDK's own empty-vec rejection ever runs (both produce the same wire outcome, but this gate fires first);anchors: nullcontinues to be rejected at deserialize time, unrelated to this gate. The read path (GET /contexts/{ctx_id},/body) is untouched — §10 gates publish only, and a body stored while the registry advertised0.5.0is served byte-exactly even if the registry's advertised version later changes. This phase does not makeacdp_version: "0.5.0"reachable from any shipped configuration — the gate is exercised only viacrates/acdp-registry-server/tests/http_integration.rs's explicitCapabilitiesDocumentoverride harness (caps_050()); making 0.5.0 actually reachable in production config is a separate, later, one-way-door change. -
JWT revocation (
SEC-01,FEAT-02): newRevocationStoretrait with in-memory, SQLite, and Postgres backends;issued_tokensmigrations (Sqlite 006, Postgres 005);AuthService::issue_tokenrecords every mintedjtisoPOST /auth/token/revokecan authorize ownership;JwtSigner::with_revocationsrejects revoked tokens at validate time. -
Cross-registry resolution (
FEAT-01):retrieveforwardsctx_ids whose authority differs from the local registry throughacdp::client::CrossRegistryResolver. Gated byregistry.cross_registry_resolution = true(default). -
Visibility search filter (
FEAT-07):GET /contexts/search?visibility=public|restricted|private. -
Webhook event correlation (
FEAT-04,FEAT-05):ContextPublishedcarriesX-Run-Idand the publish request'sderived_fromlist. -
Configurable CORS (
SEC-02):[registry.cors] allowed_origins. Empty (default) sends no CORS headers — replaces the priorCorsLayer::permissive(). -
Body-size limit layer (
SEC-06):tower_httpRequestBodyLimitLayerapplieslimits.max_payload_bytesto every route, not just publish. -
SSRF guard on webhook URL (
SEC-03) and non-empty webhook secret (SEC-04): both enforced at startup byWebhookEmitter::try_spawnandmain::validate_config. -
DID-method fast-fail (
SEC-05):AuthService::issue_challengerejectsagent_ids that don't begin withdid:web:before writing to the challenge store. -
Pre-bind config validation (
FEAT-09):main::validate_configdecodesjwt_secret, validates the webhook URL via the SSRF policy, checks TLS materials exist, and refuses the literalchangemeplaceholder. -
Conformance harness (
BUG-07):tests/conformance.rsreplayspub-*andret-*fixtures fromACDP_SPEC_DIRwhen present; status +json_containsassertions with a null-as-wildcard sentinel. -
Playground matrix test (
DESIGN-03): asserts/admin/contextsis mounted when theplaygroundfeature is compiled in but the runtime flag is off. -
POST /auth/token/revokeendpoint (FEAT-02). -
Graceful shutdown (
OPS-03):axum_server::Handlewith a 30s drain onSIGTERM/Ctrl-C. -
Pretty-log toggle (
OPS-04):ACDP_LOG_FORMAT=prettyswitchestracing-subscriberaway from JSON for local development. -
Initial 8-crate workspace scaffold.
-
acdp-registry-types: configuration (TOML + env),RegistryErrorwith HTTP projection, webhook event envelopes, JWT bearer claims. -
acdp-registry-store:ExtendedRegistryStoretrait extendingacdp::registry::RegistryStorewithlist_contexts,health,migrate. -
acdp-registry-sqlite: SQLite backend with FTS5 virtual table, migrations, atomiccommit_publish, idempotency cache, and visibility-filtered search. -
acdp-registry-pg: Postgres backend withTIMESTAMPTZ/TEXT[]/JSONB,tsvectorFTS,FOR UPDATErow locking on the supersession check. -
acdp-registry-auth: DID challenge-response viaacdp::did::WebResolver+verify_ed25519, HS256 JWT issuance/validation, pluggableChallengeStore(in-memory + SQLite + Postgres). -
acdp-registry-webhook: HMAC-SHA256-signed POSTs with retry/backoff. -
acdp-registry-core: axum router + handlers generic over the storage trait. -
acdp-registry-server: binary wiring via Cargo features (storage-sqlitedefault,storage-pg,storage-memory,playground). -
Docker image (multi-stage with
cargo-chef) + docker-compose with Postgres. -
GitHub Actions:
ci.yml(fmt + clippy across feature matrix + test + cargo-deny),release-plz.yml,docker.yml.
Changed
-
BEHAVIOUR CHANGE — a registry that boots today may refuse to start after this. A config with
playground.enabled = true,playground.pinned_only = trueand no[[playground.pinned_keys]]entries is now refused at startup, whether or not[receipt]is configured (#185). Previously the check was nested inside the receipts block, so a registry with no[receipt]got no check at all.If this stops your registry booting, it was not enforcing pinning. That combination reads as a lockdown and does the opposite:
enforce_pinned_signature(playground.rs) returnsPinOutcome::Skippedwhenpinned_keysis empty, beforepinned_onlyis consulted, andSkippedfalls through to the fully unverified publish path. So every non-did:keyagent was publishing without a signature check. (did:keyidentities take their own verified route before the playground gate and were never affected.) No deployment loses a working security property here; some discover they never had one.Remedies, in the message and in
docs/CONFIGURATION.md: add at least one[[playground.pinned_keys]]entry, or setplayground.enabled = false. If you intended to populate keys at runtime viaPOST /admin/pinned-keys/reload, boot withpinned_only = false, write both settings to disk, then reload — that endpoint swaps the whole[playground]section.No shipped default is affected — neither the compose stack nor the Railway recipe configures pinning at all.
The startup message is rewritten, which was
#185's original point: it claimed the config "would reject every publish outright", false in the dangerous direction — it reads as deny-all when the truth is the opposite. It now names the fall-through, explicitly negates the deny-all reading, and gives the remedies. Note the state it misnamed is real, just attached to the wrong config: apinned_keyslist whose entries have all expired does deny every publish — filed as#193.Because the guard now runs earlier in
validate_config, a config that is both this and separately invalid (bad receipt seed, unknown profile, missing TLS path) reports the playground diagnosis first.Proven by mutation rather than coverage: the new test was run against the unhoisted guard and observed to fail —
expect_errpanicking on(), i.e. the validator returningOk— then to pass after the hoist; re-scoping the guard back to receipts-only makes it fail again while the pre-existing receipts test stays green, which is what proves the test pins the hoist rather than the guard's existence.Runtime semantics are deliberately unchanged:
Skipped= "no policy active" remains the contractconfig.rsdocuments and callers rely on. Two residual gaps found and filed rather than fixed, both outside this change's scope:POST /admin/pinned-keys/reloadapplies config with no validation, so this guard and every other config guard can be bypassed at runtime (#192); and pinned-key entries are never validated at startup (#193).Line-pin sweep (CHARTER rule 10), recomputed from the final tree. The hoist moves code, so pins into
main.rsin the append-only records drift: bail A:259→:282, its rationale comment:249→:272, the public-bind guard:475→:488. The pins at:267/:271-273do not merely drift — their target text no longer exists, having been replaced. Thechangemeguard (:92-104,:102-103) sits above the edit and is unmoved. Reported, not re-pointed: re-pointing means editing existing lines in files this change treats as additive-only, and rewrites records that were true when written.config.rs's"Has no effect when pinned_keys is empty"stayed at:739because the new doc text was appended after it rather than inserted above, deliberately, soDECISIONS.md's citation ofconfig.rs:739-740is still exactly valid.
-
Both bump workflows pass the bot secrets explicitly instead of
secrets: inherit(REG-11,#144):.github/workflows/bump-acdp.ymland.github/workflows/bump-spec.ymlnow forward onlyACDP_BOT_APP_IDandACDP_BOT_PRIVATE_KEY. This is least-privilege hardening, not a fix for a breakage —inheritforwarded these two under the same names, so behaviour is unchanged. What changes is everything else that was in scope: this repo holdsCARGO_REGISTRY_TOKEN, andinherithanded it to a job that runsnpm installand twocurl | shinstallers. Both callees declare exactly these two secrets asrequired: trueat thev1ref the callers pin, run withpermissions: {}, and mint their own short-lived App token, soinheritalways granted strictly more than they consume. No callerpermissions:block was added — neither callee reads the caller'sGITHUB_TOKEN. (auto-merge.yml's callee does, so this does not transfer there.) -
bump-spec.yml'srepository_dispatchcomment corrected (REG-11,#139): it claimed that trigger was "inert until the spec repo adds this repo tonotify-spec-consumers.yml's matrix". That is false, and falsifiable from this repo alone —bump-spec.ymlhas repeated successfulevent=repository_dispatch/spec-releasedruns (2026-09-05 onward), and PR#147, the spec bump merged ase58eaf4, was opened byapp/acdp-deps-botoff that path. The replacement deliberately does not restate which consumers the spec repo notifies: mirroring another repo's routing table in a comment this file cannot keep in sync is what made it rot. The dispatch matrix itself is the spec repo's (spec#40) and is not edited here.
-
BREAKING (
#156): the memory-backend tenancy refusal now coversauth.require_tenant = trueas well as a non-empty[[auth.tenant_agents]]. Either signal alone, combined withstorage.backend = "memory", is refused at startup.This narrows the acceptance criterion the original refusal shipped under, which said every other backend/tenancy combination still starts. That is deliberate:
require_tenant = truewith an emptytenant_agentsis a real configuration. With no agent bindings, no registry-issued token ever carries atenantclaim, so on the read path a caller asserts its tenant with theX-Tenant-Idheader — which is what the registry's own default-deny message instructs. (Publishes differ: strict mode deliberately ignores that spoofable header when the producer has no binding, so a publish is denied outright.) On the memory backend those reads then fail identically to the case already refused. Strict mode denies every request resolving to no tenant, and any tenant a caller does assert cannot match the reserveddefaultthat every row reports, so each tenant-scoped read returns zero rows while the registry starts cleanly. Keying the refusal on[[auth.tenant_agents]]alone left that arm silently under-serving.An untenanted memory registry — neither signal set — still starts, which is the ephemeral demo case the backend exists for.
Nothing in the repo relied on the newly-refused combination. A sweep for the memory backend combined with either tenancy signal found no test, fixture, example config, compose file, CI job, or doc snippet that hits it:
config/registry.example.tomlshipssqlitewithrequire_tenant = false,docker/config.docker.tomlshipspostgres, and everyStorageBackend::Memorysite in the tree is invalidate_configor its own tests. (The three integration tests that setrequire_tenant = truealso settenant_agents, so they were already inside the Phase 8 guard; they run on SQLite and never callvalidate_configregardless.)
-
BREAKING (
REG-11Phase 8,#137):storage.backend = "memory"combined with a non-empty[[auth.tenant_agents]]is now refused at startup.validate_configruns before migrations and before the socket is bound, so the process exits with a message naming both the backend and tenancy instead of starting a registry that serves nothing. A deployment that set both previously started cleanly and answered every tenant-scoped read with zero rows; it must now move to thesqliteorpostgresbackend, which are tenancy-aware.A warning would have nothing working to preserve on the read path.
MemoryStore(crates/acdp-registry-server/src/memory_ext.rs) overrides none of the three tenancy methods onExtendedRegistryStore, so it inherits their untenanted defaults:set_tenant_of_ctxis a no-op, andtenant_of_ctx/tenants_of_ctxsreportdefaultfor every row.defaultisRESERVED_TENANT, whichreject_reserved_tenant(crates/acdp-registry-core/src/handlers/context.rs) refuses from bothX-Tenant-Idand the token claim — so the one tenant every row reports is the one tenant no caller may assert, and the filter matches nothing.The new check sits immediately after the existing
#17guard that refusestenant_agentswithrequire_tenant = false, keeping the two tenancy refusals together; because that guard forces strict mode whenevertenant_agentsis set, the rejected configuration is always the strict one. No other backend/tenancy combination was affected by Phase 8 itself (see the#156entry above, which later narrowed this). Documented indocs/MULTI-TENANCY.md(new "Backend support" section) anddocs/CONFIGURATION.md.(This entry originally noted that
require_tenant = truewith an emptytenant_agentson the memory backend fails the same way and was not refused, tracking it as#156. That gap is closed by the#156entry above, in the same release, so the caveat has been removed rather than left to read as a standing limitation.)
-
The
EXCUSED/DEFERREDratchet now actually ratchets, in every CI job (REG-11Phase 6,#115,#130): previously nothing incrates/acdp-registry-server/tests/conformance.rsforced a spec-required family out ofDEFERREDand away fromEXCUSED— zero coverage plus a written reason plus an open issue number was permanently green. A newCORE_INEXCUSABLE_FAMILIES: &[&str]const mirrors, by family, the pinned spec'sacdp-registry-corerequired_fixtures∪conditional_fixtures(18 families: the 5 alreadyCOVERED—can,idem,pub,ret,vis— plus the 13 stillDEFERRED—body,caps,data-ref,did-ssrf,dk,err,lin,meta,rate,rev,schema,sig,status). Two assertions give it teeth:core_inexcusable_families_ are_never_excused_or_unclassifiedis unconditional (noACDP_SPEC_DIRneeded, so it runs in the requiredtestsjob) and fails if any mirror family is ever moved intoEXCUSEDor drops out ofCOVERED ∪ DEFERRED; the existing spec-gatedno_excused_family_is_required_by_our_profile(requiredconformance (spec fixtures)job) now alsoassert_eq!s the mirror against the same set recomputed live from the pinned spec, so the literal cannot silently rot against a future spec-pin bump. Moving a familyDEFERRED→COVEREDrequires zero edits to either assertion or the const, by design — the mirror is keyed to the spec's family-level obligations, not to which bucket currently covers them. The unconditional half is deliberate belt-and-suspenders:conformance (spec fixtures)setsACDP_REQUIRE_CONFORMANCE=1and is a required status check today, but that required-check set is maintained by a sibling repo's hand-written mirror (independently confirmed stale as of 2026-09-05), so the unconditionaltests-job half is what keeps the guarantee even if that context is ever dropped from branch protection. Measured, not derived:ACDP_SPEC_DIR=<pinned-spec-checkout> ACDP_REQUIRE_CONFORMANCE=1 cargo test -p acdp-registry-server --features storage-sqlite,playground --test conformance --test conformance_gate -- --nocapture→ 44 passed + 1 passed, 0 failed,replayed 30 exchange(s); failures=0(pub: 3,ret: 1,vis: 26—MIN_REPLAYED_EXCHANGESunchanged);cargo test --workspaceandcargo clippy --workspace --all-targets -- -D warningsboth clean. -
Corrected seven
DEFERREDreasons that misdescribed this repo's own implementation (REG-11Phase 6,#130): four were factually false —rateclaimed a missing "clock/limiter seam", butlimits.publish_rate_per_minute(config.rs:560-561) is already enforced by the in-processAgentRateLimiter(rate_limit.rs, wired atstate.rs:86-89), proven end-to-end by the sibling challenge-limiter test (http_integration.rs:843-873);data-refcalled the family "consumer-leaning", but the 7data-ref-*fixtures inacdp-registry-core'srequired_fixtures(data-ref-001..007) are registry-side publish-path rejections (RFC-ACDP-0002 §6) — the 8th,data-ref-008-external-data-ref-hash-mismatch, is a consumer fetch-time check and is not required of this profile;statuscalled it "lifecycle status transitions", but the fixtures test thestatusstring's grammar (RFC-ACDP-0004 §4.1), not lifecycle state;revcalled it a "verification-side family", but RFC-ACDP-0014 §4/§5 assigns this to the registry, which already implementsContextType::KeyRevocation. Three more overstated the gap as "needing a new seam" when the seam already exists:log's/log/checkpoint,/log/proof, and/log/entriesroutes are always mounted (crates/acdp-registry-core/src/lib.rs:86-88).log,rcpt, andlhreach have two real causes, not one: one fixture per family (log-001/log-003,rcpt-001,lhr-001) carries noapplies_to_profilesand is a non-HTTP golden vector needing a direct-vector pass; the rest (log-002/log-004,rcpt-002..004,lhr-002..004) are restricted to a profile this harness doesn't advertise (HARNESS_PROFILES,conformance.rs:425) — advertising it would not make the non-HTTP vectors run. No family moved bucket in this phase — this is a record correction plus the new ratchet, not new coverage. -
did-ssrf's deferral reason corrected (same review pass). It claimed the family "needs a controlled resolver seam this harness doesn't have yet". That named the wrong cause and was falsifiable from this suite's own output: all fivedid-ssrf-*fixtures are reported undernon-HTTP fixture (vectors / schema / informative)in the skip tally, so they never reach a resolver. The seam also already exists —acdp_did::WebResolverappliesSsrfPolicy::default()unconditionally, and exposeswith_ssrf_policyandwith_test_endpointunder thetest-transportfeature already enabled onacdpin this crate's dev-dependencies. The real gap is a direct-vector pass likecan's.
ci.yml's conformance job checks the spec out throughacdp-ci/actions/checkout-specrather than a hand-rolledactions/checkout(REG-11, #143). The pinned spec ref is unchanged —d1f06d0d49b73d411a3983d3877321ccaccd38e7, exactly as merged in #147; only the mechanism that checks it out changed. This was an adoption ask, not a compliance defect: #143 states outright that the previous form already satisfied the substance of the spec-pinning rule. The action resolves at015910153b61c32abbe018afe85d44868897bf3b # v1— the commitacdp-ci's annotatedv1tag dereferences to (refs/tags/v1→ tag object82b2a25…→ commit0159101…, confirmed against the remote viagh api; theaction.ymlblob at that commit is8c5dacd…on GitHub and in the local clone alike). That isacdp-verifier-py's call-site pin verbatim, and it followsREG-8/REG-10's rule that a non-actions/*action resolves at an immutable 40-hex SHA with a# <version>comment. Theacdp-ci/.github/workflows/*@v1reusable-workflow refs are untouched and stay tag-tracked — a composite action is auses:step inside this job, not the family propagation that exemption protects. Three guarantees the hand-rolled form did not have: the action rejects arefthat is not 40-hex lowercase before any network call; it re-reads the checked-outHEADand fails when it differs from the pin; and it refuses theset-env: false+require-conformance: truecombination, which would exportACDP_REQUIRE_CONFORMANCEwithoutACDP_SPEC_DIR. TheConformance (spec fixtures required)step still sets both variables itself rather than relying only on the action's$GITHUB_ENVexports, so require-mode stays a property of this file and cannot be switched off by an action input.ACDP_SPEC_DIRnow reads the action'spathoutput instead of restating${{ github.workspace }}/acdp-spec, so the checkout location and the variable cannot drift apart. Norepository:orpath:override is passed, even though both would merely restate the action's own defaults:bump-spec-ref.ymlcounts arepository: <spec>line as a second pin anchor and then fails the bump outright rather than rewrite only the first, so adding one would silently disablebump-spec.yml. Checked by running that workflow's own anchor-countawk, itsperlrewriter and its post-rewrite assertion — copied verbatim fromacdp-ciatv1— against the new file: one anchor,d1f06d0…resolved as the current pin, and a simulated bump rewrote exactly one line, the specref:, leaving the action's own@0159101…pin alone. For this job, a green result is not by itself evidence — so acceptance was two negative controls plus the replay tally, not colour. Measured withcargo test -p acdp-registry-server --features storage-sqlite,playground --test conformance --test conformance_gateagainst a spec worktree detached at the pinnedd1f06d0…:replayed 30 exchange(s); failures=0, with aran by familytally ofpub: 3/ret: 1/vis: 26. No raw pass count is recorded here on purpose — that number moves with every merge that adds a test (it was 43 against this branch's original merge-base, 44 once#158landed, 46 after Phase 7) and says nothing about this change. The replay tally is the invariant #143 actually has to preserve, and it did not move. Re-pointed at a nonexistentACDP_SPEC_DIRwithACDP_REQUIRE_CONFORMANCE=1the same command panics atconformance.rs:2549and exits 101; with neither variable set it exits 0 while replaying nothing, loggingACDP_SPEC_DIR unset or no fixtures resolvable; skipping. That second control is why the pin-and-require design exists, and why the CI job log — not the check mark — is the acceptance artifact. CI reproduced the replay tally and the per-family breakdown exactly. Note thatMIN_REPLAYED_EXCHANGES(derived from source as 30) is met exactly, with no headroom: losing one replayed exchange turns the required job red.
-
BREAKING (
REG-11Phase 3,#133):GET /admin/contextsis now gated behindrequire_admin_bearer, like every other/admin/*route. A registry with an emptyauth.admin_tokens(the shipped default in bothconfig/registry.example.tomlanddocker/config.docker.toml) now answers 403admin-onlywhere it previously served rows to anyone — operators who rely on this route MUST configure at least one entry inauth.admin_tokensand send it asAuthorization: Bearer <token>.caller_from_headerswas deliberately removed from this handler: the admin gate has already resolved "who is calling", and re-parsing the same header a second time undercaller_from_headers's rules would hand the admin token tovalidate_bearer, which fails on a non-JWT and returns 403 wheneverauth.enabled = true— precisely the registries that configure admin tokens.tenant_for_requestis still called; it is a separate resolution step, deliberately tolerant of a non-JWT bearer. The frozen disclosure rule: an admin bearer counts as an authenticated but unnamed caller for the RFC-ACDP-0008 §4.5 public arm. Restricted and private bodies are never disclosed to the admin listing — the SQL predicate's restricted/private arms both require a non-NULL requester DID (LIST_VISIBILITY_SQLITE,crates/acdp-registry-sqlite/src/store.rs:423-436, and its Postgres twin,crates/acdp-registry-pg/src/store.rs:171-175), which aNonerequester can never reach. Both shipped configs gained a commented# admin_tokens = ["<generate out of band>"]under[auth]so the now-mandatory setting is discoverable. -
Restored the RFC-ACDP-0008 §4.5
anonymous_public_readsdisjunct tolist_contexts(REG-11Phase 2,#133): the pg/sqlite list-visibility predicate was a bit-for-bit copy ofsearch's disclosure predicate MINUS theanonymous_public_reads ||term on thepublicarm —retrieveandsearchboth already honor the flag;list_contextswas the sole outlier.ExtendedRegistryStore::list_contextsgains a fifth parameter,anonymous_public_reads: bool(crates/acdp-registry-store/src/lib.rs), mirroringRegistryStore::search's parameter of the same name; both SQL backends' visibility predicate now readsPublic => anonymous_public_reads || requester.is_some(), arm-for-arm withsearch(acdp-registry-pg/src/store.rs:171-175,acdp-registry-sqlite/src/store.rs:423-436; the SQLiteLIST_VISIBILITY_SQLITEconst grows from three?placeholders, all bound to the requester, to five:?req,?anon,?req×3). Observable behavior is unchanged in this release: the sole production call site,admin_listbehindGET /admin/contexts(acdp-registry-core/src/handlers/admin.rs), passes a literaltruerather thanauth.anonymous_public_reads, so today's "public rows always listed" outcome is reproduced byte-for-byte. Wiring the real config value there, together with gating the route behind the admin bearer, is a later, deliberately atomic change — not this one. Both backends'sql_disclosure_matches_rfc_4_5_across_the_matrixoracle test now exerciseslist_contextsunder bothanonymous_public_readsvalues inside the samefor anon_reads in [true, false]loop that already coveredsearch, closing the one place this repo's own §4.5 restatement didn't reach. -
Twelve dependency majors (
REG-11Phase 1,#136):rand0.8→0.10 (rand::thread_rng()→rand::rng(),RngCoreno longer at the crate root —crates/acdp-registry-auth/src/service.rs,crates/acdp-registry-server/src/main.rs),hmac0.12→0.13 (KeyInitnow imported separately fromMac—crates/acdp-registry-webhook/src/lib.rs:250),jsonwebtoken9→11 (switches the JWT crypto provider to therust_cryptofeature — no other provider was enabled by default before),thiserror1→2,ed25519-dalek2→3,toml0.8→1.1, plustower-http,sha2,base64,config,metrics-exporter-prometheus, andserial_test.serial_testis held at3.xrather than the dependabot-proposed4.x, which requires rustc 1.93.1 and would break themsrv (1.88)CI job. Added adeny.tomladvisories.ignoreentry forRUSTSEC-2023-0071("Marvin", an RSA private-key timing side channel):rsaenters the dependency graph only because jsonwebtoken 11'srust_cryptofeature enables it unconditionally, every JWT verification pins a single non-RSA algorithm before any crypto verifier is constructed, and this registry holds no RSA private key material to time. This is the repository's second advisories-ignore entry, not its first —RUSTSEC-2025-0134was ignored and then removed by PR #97 onceaxum-server0.8 droppedrustls-pemfilefrom the graph entirely; this one is expected to outlive that pattern, since no upstreamrsapatch exists for Marvin. -
Extracted a shared
tests/common/harness forconformance.rsandhttp_integration.rs(REG-10Phase 6). Rust integration tests compile to separate binaries, soconformance.rscouldn't reachhttp_integration.rs's router-building code and had grown three near-identical ad-hoc routers plus its own copies ofpct_encode_path_segment,body_to_json, andproducer. All of that now lives incrates/acdp-registry-server/tests/common/mod.rs(mod common;in both files), parameterized over store backend (StoreMode::Memory|StoreMode::File—conformance.rsused an in-memorySqliteStore,http_integration.rsaNamedTempFile; both are preserved) and capabilities document, soconformance.rs's three routers andhttp_integration.rs's harness ladder now both build oncommon::build_harness_with_webhook.body_to_jsonis deliberately kept as two functions rather than one: the two files' copies differed, withhttp_integration.rs's panicking on an empty or non-JSON body andconformance.rs's degrading toValue::Null. The strict form is the shared default at all 53 call sites, andbody_to_json_lenientis used at exactly one — the fixture replayer, which drains arbitrary spec fixtures where an empty response is legitimate. Collapsing them onto the lenient form would have silently removed a guard from ~50 assertions. Pure refactor: the only line changed inside a#[test]body is that one replayer call, which is behavior-preserving, and the full suite passes with the exact same test count as before (175, across all seven binaries under--features storage-sqlite,playground). -
Reworded the wit-001/wit-004 quorum assertion's message and its preceding comment (
REG-10Phase 5, GitHub issue #113) inwit004_key_mismatch_cosignature_is_rejected_and_wit001_golden_is_accepted. The old message claimed the assertion provedreport_both.witnessesnames wit-001's witness and not wit-004's — impossible by construction, since the test pins both fixtures to the same witness assertionMethod key and only ever registers one witness DID. The assertion is unchanged; it actually proves the one verifying cosignature is attributed exactly once inwitnesses, consistent withwitnessed_count. No executable change. -
storage-memorygets its first required CI coverage (REG-10, issue #109)..github/workflows/ci.yml'sclippyjob gains a fourth step,clippy (memory), runningcargo clippy -p acdp-registry-server --no-default-features --features storage-memory --all-targets -- -D warnings; thetestjob gains a matchingcargo test (memory)step runningcargo test -p acdp-registry-server --no-default-features --features storage-memory. Both are appended as steps to the existing jobs, matching how the pg/sqlite/playground legs are already structured — no new job, no matrix, and--no-default-featuresis load-bearing:storage-sqliteis the crate'sdefaultfeature, and a bare--features storage-memorywould trip thestorage-sqlite+storage-memorymutual-exclusioncompile_error!incrates/acdp-registry-server/src/main.rs. Both legs land in the requiredclippy/testjobs, not the advisorymsrvjob, so a future break inmemory_ext.rsnow blocks merge instead of going uncaught entirely — before this change no CI job builtstorage-memoryat all. Deliberately scoped as compile + lint coverage, not behavioral coverage:--all-targetscompiles the bin and test targets under this feature set (closing a compile gapmemory_ext.rshad never been checked against before), andcargo test's memory leg runs the binary's own unit tests plus three tests from the two always-compiled integration binaries, buttests/conformance.rs,tests/http_integration.rs, andtests/metrics_integration.rsare all#![cfg(feature = "storage-sqlite")]and compile to zero tests understorage-memory— this leg proves the memory cfg gates and the memoryrun()arm typecheck and pass lint, not that the memory-backed store behaves correctly against the HTTP surface. Verified locally: both commands pass as-is (clippy clean; 40 tests pass — 37 unit plus 3 from the two always-compiled integration test binaries, withconformance.rs/http_integration.rs/metrics_integration.rs/pg_integration.rseach reporting0 testsunder this feature set); a deliberate type error introduced intomemory_ext.rs'sputimpl made the clippy leg fail withE0061, confirming the leg is non-vacuous, then was reverted.CONTRIBUTING.md's feature-flag variant block gains the matchingstorage-memoryinvocations alongside the existing pg/sqlite ones. -
acdp_versioncapability advertisement now reaches"0.5.0"(REG-3Phase 4 — one-way-door). Phase 3's RFC-ACDP-0016 §10/§14 version gate shipped with no reachable configuration of the shipped binary that could ever clear it — the ladder topped out at"0.4.0", so every anchored publish was rejected forever.crates/acdp-registry-server/src/main.rs'sbuild_capabilitiesis refactored from the four-rung ordered if/else ladder into an order-independentmax()over independent per-feature version claims (ladder_claims/acdp_version_claim): witnesses configured still contributes"0.4.0", lifecycle/log/head-receipts still contributes"0.3.0", a configured receipt key still contributes"0.2.0", the base floor is still"0.1.0"— and a fifth, unconditional claim of"0.5.0"is added foranchorssupport, since RFC-ACDP-0016 §10 is explicit that anchors is "a body field, not a registry surface" with no new profile or admin-config gate to check (the accept/reject/store/serve handling runs on every publish regardless of config). Because that claim is both unconditional and the largest value in themax(), it wins for every configuration: every reachable deployment of the shipped binary now advertisesacdp_version >= "0.5.0", including a completely bare one with no receipt key, log, or witnesses configured. This is a deliberate, acknowledged trade-off, not an oversight — the previous four rungs no longer distinguish themselves in whatbuild_capabilitiesactually serves (a consumer can no longer infer "does this registry aggregate witness signatures" fromacdp_versionalone), which is exactly the cost the plan names for this option.RegistryServer::try_new'svalidate_capabilitiesstartup check still passes for every existing config permutation (its only version-conditioned guard is>= 0.3.0requiringsupports_idempotency_key: true, which is unconditionallytruehere regardless of version). This phase executes the follow-up OQ2's ownDECISIONS.mdentry (2026-08-29) already recorded — "if a 5thacdp_versionrung is ever added, consider replacing the ordered if/else ladder with an order-independentmax()over per-feature version claims" — rather than superseding OQ2's decision; OQ2's conditional 0.4.0-ahead-of-0.3.0 ordering is unchanged, just re-expressed as one candidate among several. LoggedUNCONFIRMEDinASSUMPTIONS.mdpending/reconcilesign-off, per this repo's standing practice for one-way-door decisions. -
Bumped
acdp0.8.1 → 0.8.2 (REG-3Phase 2), pulling all twelveacdp-*workspace crates in lockstep. This inheritsAnchorEntry,PublishRequest::anchors,Body::anchors,validate_anchors, and anchors-in-preimage hashing (RFC-ACDP-0016) as plain library types — this repo adds no local struct or validation code for them in this commit. The crypto dependency graph moved with it, not justacdpitself: major version bumps acrossbase16ct(0.2→1.0),crypto-bigint(0.5→0.7),ecdsa(0.16→0.17),elliptic-curve(0.13→0.14),ff,group,p256,primeorder,rfc6979,sec1, plus new dependenciescurve25519-dalek5.0,ed25519-dalek3.0,der,digest0.11,sha20.11, andsignature3.0 (hashbrown0.16 dropped). This repo's own directed25519-dalek = "2.2"dev/runtime dependency (acdp-registry-auth,acdp-registry-server) now coexists withacdp's transitiveed25519-dalek3.0 as two separate major-version instances in the graph; it previously relied on feature unification withacdp0.8.1's own (then-matching)ed25519-dalek 2.xdependency to enable therand_corefeature (SigningKey::generate), which the 0.8.2 bump broke by movingacdp's own dependency toed25519-dalek3.0. Fixed by splittingacdp-registry-auth'sed25519-dalek = "2.2"dependency: kept featureless under[dependencies](its actual production use,jwt.rs's PEM decoding, never callsgenerate) and added a new[dev-dependencies] ed25519-dalek = { version = "2.2", features = ["rand_core"] }for tests that do callgenerate— rather than widening the production feature set for a test-only need.acdp-registry-server's own[dev-dependencies]ed25519-dalekentry gains the same explicitrand_corefeature for the same reason (it did not carry the feature before this commit; feature unification withacdp's own then-2.x dependency supplied it implicitly). Neither fix bumps this repo's own dependency to 3.0 or pinsacdpback — both are manifest-only, no production source changed.cargo deny checkstays green (advisories, licenses, bans, sources allok) withdeny.toml'signore = []unchanged; the expanded graph's ~30 new/duplicated transitive crates (base64,block-buffer,cmov,const-oid,cpubits,crypto-common,ctutils,curve25519-dalek,der,digest,ed25519,ed25519-dalek,fiat-crypto,hmac,hybrid-array,pkcs8,primefield,serdect,sha2,signature,spki,wnaf, plus alru0.16→0.18 bump not previously called out) all license-clear under the existing allow-list and raise no new advisory;[bans] multiple-versions = "warn"accepts the resulting duplicate-major-version crates (including theed25519-dalek2.x/3.x split) as warnings, not failures. This commit alone would let a publish carryanchorswith no version gate whatsoever — it ships only combined with the RFC-ACDP-0016 §10/§14 version gate (REG-3Phase 3) in the same PR, and must not reachmainon its own. -
Pinned conformance spec SHA bumped to
417211f6a13aeceef4db00eb67f98ed0ed13761b(REG-3) in.github/workflows/ci.yml. The only substantive delta since the prior pin is RFC-ACDP-0016's draft and its conformance pack (the newanc-*fixture family, SPEC-9); the third commit in the range is an unrelated SPEC-7 rev-002 change with no anchors content. This repo does not yet implement RFC-ACDP-0016 —ancis classified incrates/acdp-registry-server/tests/conformance.rs'sKNOWN_FAMILIES(notEXCUSED) and picked up by the replay harness's non-HTTP fallthrough, same aswit; real coverage is a separate, later change. -
Pinned conformance spec SHA (
REG-2) adopted in.github/workflows/ci.yml: now31cf8743b62debe2c7c8572ce3a3a0b7ca5ad099. Annotation-only at this pin: RFC-ACDP-0015 promoted Draft → Final (0.4.0), theinvalid_witness_cosignatureerror code promoted Proposed → Stable, and theacdp-log-witnessprofile promoted Draft → Final. No fixture family, fixture shape,id,request, orexpectedfield changed; the conformance harness runs unchanged against the new pin (16 passed; 0 failed, 4 exchanges replayed). -
SHA-pinned credential-bearing workflow actions (
REG-8): every third-party action indocker.ymlandrelease-plz.yml(pluspeter-evans/repository-dispatchinnotify-website.yml) now resolves at an immutable 40-hex commit SHA with a# vX.Y.Z(or# stablefordtolnay/rust-toolchain, a selector rather than a version) comment, matchingacdp-rs's two-tier pinning posture: `docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6fv3.12.0
,docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9v3.7.0
,docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051v5.10.0
,docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8v6.19.2
(both call sites),dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30stable
(matchesacdp-rs`'s own current pin verbatim),`MarcoIeni/release-plz-action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9
v0.5.131
, andpeter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697v4.0.1
. First-partyactions/checkout@v4` stays tag-pinned(deliberate, matching the sibling's first-party tier), and the
acdp-ci/.github/workflows/*@v1reusable-workflow refs are untouched (pinning those would break family propagation).docker/login-action, the pushingdocker/build-push-actioncall,MarcoIeni/release-plz-action, anddtolnay/rust-toolchainare not exercised by this PR's own CI (gated offpull_requestevents or behindon: push: branches: [main]).docker/login-action,docker/build-push-action, andMarcoIeni/release-plz-actionwere each independently re-verified against their tag viagh api.dtolnay/rust-toolchainis the one deliberate exception: it is pinned to matchacdp-rs's own current SHA verbatim rather than to whatever@stableresolves to today (the two diverge — see the Approach section ofplans/reg2-reg5-reg6-reg8-reg9-wave4.mdPhase 9), so verifying it against@stablewould show a mismatch by design; parity with the sibling's pin is the actual check. -
Retired stale supply-chain narration (
REG-9): theCargo.tomlcomment above theacdpdependency wrongly described a 0.5.3-era, per-sub-crate version mix; it now states thatacdp0.8.1 is a facade crate over eleven sub-crates published to crates.io and kept in lockstep at the same version, without naming individual sub-crate versions.deny.toml's deadallow-gitentry for theacdp-rsrepo (a leftover from whenacdpwas git-sourced) is removed, which silences a persistentcargo denyunmatched-sourcewarning. The one behavioral consequence: any future git dependency onacdp-rsis now acargo denyfinding instead of a silent allowance. -
Wire-code mapping for
invalid_witness_cosignature(REG-2):AcdpError::InvalidWitnessCosignaturenow maps to wire codeinvalid_witness_cosignature/ HTTP 502 inacdp-registry-types::error::{acdp_wire_code, http_status_for_acdp}, instead of falling through to theinternal_error/500 catch-all. Deliberately kept distinct frominvalid_log_proofeven though both are 502, so a client can tell which upstream artifact failed verification. No handler emits this error yet (grep -rn InvalidWitnessCosignature crates/acdp-registry-core/src/handlers/returns zero hits) — this only closes the wire-mapping gap ahead of emission, which is a separate later change. -
acdp_versionclaims 0.4.0 when witnesses are configured (REG-2):main::build_capabilities's version ladder gains a new rung, checked first,!cfg.witnesses.is_empty() => "0.4.0". RFC-ACDP-0015 §6.1 witness cosignature aggregation — already implemented in full byacdp-registry-core::witness— is the sole registry-side 0.4.0 obligation, so a deployment that aggregates witnesses was serving a 0.4.0 wire member (witness_signatures) under a 0.3.0acdp_versionbanner; this closes that under-claim and makes the priorREG-2wire-code entry's below-0.4.0 gate satisfiable rather than vacuously true (no deployment could ever have claimed 0.4.0 before this). Gated on!cfg.witnesses.is_empty()rather thancfg.log.enabled, sincevalidate_configalready refuses startup with witnesses configured andlog.enabled = false— the new rung stays monotone with the existing 0.3.0 rung on the real startup path without over-claiming 0.4.0 for every transparency-log registry that aggregates nothing. Does not advertise theacdp-log-witnessprofile — the spec forbids that for a registry (a witness is not a registry); only the version string changes. -
Fixture-driven
wit-004/wit-001coverage (REG-2):acdp-registry-server/tests/conformance.rsgainswit004_key_mismatch_cosignature_is_rejected_and_wit001_golden_is_accepted, the first genuine coverage of thewit-*family against real pinned fixture data. Drivesacdp::client::verify_witness_cosignature_valueandevaluate_witness_quorumdirectly (not HTTP — RFC-ACDP-0015 §8 witness-cosignature verification is a pure library check): the pinned wrong-keywit-004cosignature is rejected withInvalidWitnessCosignaturenaming the actual signature-verification failure, the pairedwit-001golden verifies under the same witness key as a positive control, and the rejected cosignature does not count toward the N-witnessed quorum while the golden one does.wit-*remains classified "non-HTTP fixture" by the HTTP replay harness — this adds coverage beside it, not a reclassification. -
Strengthened registry-side fork-refusal tests (
REG-2):acdp-registry-core::witness's existingcosignature_over_wrong_root_is_rejectedandcosignature_beyond_current_head_is_rejectedpreviously asserted onlymatches!(err, AcdpError::InvalidWitnessCosignature(_))— a variant shared by the log-id mismatch, the beyond-head case, and every §8 verification failure, socosignature_over_wrong_root_is_rejectedcould not tell a real root-mismatch rejection from an accidental beyond-head one. Both tests now additionally assert on their own distinct error MESSAGE wording (root_hash/checkpoint-mismatch vs. "beyond this registry's current head"), and both now assert the store holds zero cosignatures for the checkpoint tuple after the rejection — the property that actually matters operationally, since a rejected forged cosignature must never be stored or the aggregator could later serve a bogus one.cosignature_over_wrong_root_is_rejected's forged root is now hardcoded to wit-002's own pinned root-rewrite vector (sha256:deadbeef00000000000000000000000000000000000000000000000000000000fromwit-002-consistency-refusal.json) instead of an arbitrary byte pattern. Scope note:wit-002describes a WITNESS's obligation (a witness refuses to cosign a root-rewrite BEFORE signing and persists evidence of the refusal). This repo is a REGISTRY, not a witness — it never cosigns anything and structurally cannot exhibit that half of wit-002's behavior. This change covers only the mirror-image defense the registry DOES own: refusing to STORE/AGGREGATE a cosignature that doesn't match its own recomputed root, pinned to wit-002's forged root value. It is not a claim of wit-002 coverage. -
BREAKING:
registry.profilesallowlist (REG-5):main::validate_confignow refuses to boot ifregistry.profilescontains anything other than the seven registry profiles the pinned ACDP spec defines (REGISTRY_ADVERTISABLE_PROFILES, new inacdp-registry-types::config:acdp-registry-core,acdp-registry-discovery,acdp-registry-federated,acdp-registry-receipts,acdp-registry-head-receipts,acdp-registry-transparency-log,acdp-registry-lifecycle) — derived by rule (everyprofiles[].idin the spec'sregistries/profiles.jsonprefixedacdp-registry-), not hand-maintained, and checked by a new conformance test against the pinned spec so a future spec change turns CI red rather than drifting silently. The allowlist check runs BEFORE the existing per-profile backing-config guards (receipts key, head-receipts, lifecycle, transparency-log), so a typo is reported before an unrelated missing-config complaint.acdp-log-witnessspecifically is rejected with a dedicated message: a witness is not a registry (RFC-ACDP-0015 §6.1) — a registry MAY aggregate cosignatures underacdp-registry-transparency-logwithout ever advertisingacdp-log-witnessitself. This is a breaking change for any deployment that had an unknown oracdp-log-witnessstring inregistry.profiles— this repo's own shipped examples and tests (config/ registry.example.toml,docker/config.docker.toml, and every in-repo test config) were audited and only ever set allowlisted values, so none of them are affected. -
BREAKING (
SEC-07):auth.anonymous_public_readsnow defaults tofalse, matchingCLAUDE.md. Operators upgrading who rely on world-readable public contexts MUST set the field explicitly:[auth] anonymous_public_reads = true. -
Pagination, search (
BUG-01,BUG-02,BUG-03): cursor-based pagination is now driven by the SQLLIMIT limit+1sentinel — no more phantom next pages when the in-Rust visibility filter drops rows. Postgreslist_contextsbindsLIMITvia$Ninstead of string concatenation; search applies the cursor predicate and limit in SQL on both backends. -
Health endpoint (
BUG-05):GET /healthzreturns HTTP 503 (withstatus: "degraded") when storage health fails, so load balancers and Kubernetes readiness probes take the pod out of rotation. The body shape is unchanged. -
DB-backed challenge store (
BUG-06,DESIGN-02): SQLite and Postgres binaries now wireSqliteChallengeStore/PgChallengeStoreinstead of the in-memory store. Multi-replica Postgres deployments no longer break the handshake when an agent hits a different replica for the token step. -
Challenge duplicate-nonce error mapping (
BUG-04): SQLite and PostgresChallengeStore::putmap unique-constraint violations toAuthError::ChallengeReplay, matchingInMemoryChallengeStore. -
Cross-registry resolution failures map to HTTP 502 (bad gateway), matching
KeyResolutionUnreachable. -
total_estimatein search responses (DESIGN-05) is nowNonerather than the page-local match count (which was misleading; it was always ≤limit). -
ContextTypestorage (DESIGN-04): typed accessor replaces theserde_json::to_value(...).as_str()round-trip in both backends and the publish event, so future multi-field variants don't silently serialize to the empty string. -
Webhook emitter constructor is now
WebhookEmitter::try_spawn(URL + secret validation);spawnis kept for tests but no longer invoked by the server binary. -
Default tracing format stays JSON; opt into pretty via
ACDP_LOG_FORMAT=pretty. -
Docker Compose: secrets sourced from
${VAR:-default}env substitution;auth.jwt_secret = "changeme"aborts startup. -
axumbumped 0.7 → 0.8,tower0.4 → 0.5,tower-http0.5 → 0.6 (REG-6): completes the HTTP-stack line-up the prioraxum-serverbump left half-done, collapsing the duplicatetower(0.4.13/0.5.3) andtower-http(0.5.2/0.6.11) trees the lockfile carried from the mismatched pairing —Cargo.locknow resolves a singletower0.5.x andtower-http0.6.x. All nine route path params inacdp-registry-core::build_routermove from axum 0.7's:ctx_id/:lineage_idsyntax to 0.8's{ctx_id}/{lineage_id}syntax (no behavior change —matchit's static-over-dynamic route priority, e.g./contexts/searchover/contexts/{ctx_id}, is preserved).TimeoutLayer::newis deprecated in tower-http 0.6; the 30s response timeout now usesTimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(30)), same behavior. Operator-visible change: the Prometheusroute=label on every parameterized endpoint (e.g.GET /contexts/{ctx_id}) changes from the old/contexts/:ctx_idform to the new/contexts/{ctx_id}form, since the label is sourced from axum'sMatchedPath. Dashboards or alerts keyed on the old label form will go silently blank until updated to match. A new assertion intests/metrics_integration.rspins the new label form on a parameterized request so this doesn't regress unnoticed again. -
SHA-pinned
ci.yml's third-party actions, and corrected an unreachable pin (REG-10, #111): the fifteendtolnay/rust-toolchain,Swatinem/rust-cache,taiki-e/install-action, andEmbarkStudios/cargo-deny-actionrefs acrossci.yml's eight jobs now resolve at an immutable 40-hex commit SHA with a trailing# <version-or-branch>comment, extendingREG-8's pinning posture fromdocker.yml/release-plz.ymlto the last unpinned workflow. First-partyactions/*refs stay tag-pinned, unchanged. All sevendtolnay/rust-toolchaincall sites now pin6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # masterand pass an explicittoolchain:(stableat six sites,"1.88"inmsrv). dtolnay requires a pinned SHA to sit withinmaster's history — anything else is eventually garbage-collected — and the previously-used4be7066…, inherited fromrelease-plz.yml, is today reachable from no ref at all. That pin is corrected inrelease-plz.ymlas part of this change rather than copied into six more jobs.Swatinem/rust-cache@6323deb… # v2.9.2(six sites) andEmbarkStudios/cargo-deny-action@3c63498… # v2.1.1are ordinary tag-to-SHA pins.taiki-e/install-action@1ed6d7be… # v2.87.2pins a release SHA onmainand passestool: cargo-llvm-covexplicitly. The@cargo-llvm-covtool tag defaults that input, but upstream strongly discourages pinning tool tags by hash: those commits are regenerated per release and are never inmain's history, so a hash pin starts referencing a commit that is not present on the repository. Because# masteris a ref selector rather than a semver tag, Dependabot'sgithub-actionsecosystem (.github/dependabot.yml, monthly) has nothing to track for thedtolnay/rust-toolchainpins; the# v2.9.2,# v2.1.1, and# v2.87.2pins will be kept current.
-
Cursor parse failures no longer name the parse step (
W2-U2,#187): the keyset-pagination cursor codec is lifted out of both store backends intoacdp_registry_store::cursorand everyInvalidCursorarm now carries one payload, so the wire message is exactly{"error":{"code":"invalid_cursor","message":"invalid cursor: malformed"}}. It previously appended the failing parse step — which field was missing, which failed to parse as an integer — describing the cursor's internal layout.cur-002's fixture rationale asks a registry not to "leak why a cursor failed to parse beyond the registered code"; that is now satisfied rather than documented as unsatisfied.This is an observable change to the
messagestring. The Rust API is untouched:error.codestill discriminatesinvalid_cursorfromcursor_expired(both HTTP 400), which is the distinction clients branch on, andmessagewas never a stable contract. But the wire string did change, and a client string-matching the old per-arm wording will stop matching — it should readerror.code. Those two axes are why the commit carries a conventional-commits!marker while this entry says the API is unbroken: the marker exists to force the version bump that the wire change warrants, not to claim a Rust-level signature changed. Nothing in this repo asserted the old message text except the conformance tripwire retired below.Behind the wire change,
encode_cursor/decode_cursorwere byte-identical duplicates inacdp-registry-sqliteandacdp-registry-pg; there is now one copy and both stores call it. The count in#187was low: the tree held 8 arms per store, 16 literals, not 7/14 — neither the issue norDECISIONS.mdentry 10 counted"cursor is not utf-8". One of the eight was unreachable (splitnalways yields a first element) and is deleted rather than collapsed, so the accounting is 16 literals to 1 constant plus one dead branch removed.The conformance tripwire that existed solely to detect this change is retired with its explanatory note and its
ASSUMPTIONS.mdentry, and replaced by the inverse assertion — the message must equalinvalid cursor: malformedexactly. A structural self-inspection test in the store crate additionally requires everyAcdpError::InvalidCursorconstruction to use the shared constant, so a future arm cannot reintroduce a leak past a by-example test.
-
base64removed from theacdp-registry-sqliteandacdp-registry-pgmanifests (W3-U4,#195): both declared it and neither used it. The dependency became unused when#187lifted the cursor codec — the onlybase64caller in either crate — intoacdp-registry-store, which declares it correctly.No resolved dependency graph changes at all — not the workspace's, and not a downstream consumer's. The
base64package is not dropped: ten other crates still use it,acdp-registry-storeamong them, andCargo.lockloses exactly the two dependency edges. It also stays reachable from these two crates through mandatory, non-optional paths —acdp-registry-sqliteandacdp-registry-pgeach depend unconditionally onacdp-registry-storeand onacdp, both of which pullbase64.cargo tree -p acdp-registry-sqlitestill reports the same 13base64entries it did before.So the gain is manifest hygiene, not dependency reduction: the two manifests no longer declare something their code does not use, they stop misrepresenting what the crate needs, and they stay clean under a future
cargo-udepsorcargo-machetegate — of which this repo currently has none, which is why nothing flagged it.Safe under every feature combination, and provably so rather than by sampling: neither crate defines a
[features]block or a single optional dependency, socargo metadatareportsfeatures={}for both and there is no gated path that could reference the crate.grepacross each crate's entire directory —src/,tests/,migrations/— returns zero occurrences.
-
Cursor wire format documented with the wrong separator (
W3-U6,#196):crates/acdp-registry-server/tests/http_integration.rsdescribed cursors asbase64("mint_ms|anchor_ms|ctx_id"). The separator is a colon, and that is load-bearing rather than incidental —ctx_idvalues are URIs containing colons (acdp://reg/ctx-1), which is exactly why the decoder splits with a limit of 3 and lets the final field keep its own. A reader who trusted the|and wrote a greedy split would corrupt everyctx_id.The comment now also names
acdp_registry_store::cursoras the single authority for the format. That is the half that matters: this defect existed because a wire format was described in a place other than where it is defined, which is the same failure#187removed by collapsing two byte-identical copies of the codec into one crate. -
Nine line-pins repaired in
docs/AUTHENTICATION.md, plus a prose correction indocs/OPERATIONS.md(W3-U6). The breakdown matters more than the total, because only the first group is drift:- six drifted when
#201inserted intohandlers/admin.rs; - three were misaligned independently of that merge — one pinned
caller_from_headersstarting inside its doc comment, and two (the same pin cited twice) stopped one line before the end of the statement they describe.
docs/OPERATIONS.mdrepaired prose, not a pin: the line it cites was correct and unchanged.A tenth pin, in
crates/acdp-registry-store/src/lib.rs, carried the same claim asdocs/OPERATIONS.md— thatadmin_listpassesanonymous_public_reads = trueunconditionally — but cited the requester line rather than the one that sets it. Two places asserted one fact and named different lines; only one could be right. Repaired together with its sibling, because a half-fixed truth-claim is worse than an unfixed one: the corrected doc would otherwise lend the stale one credibility by contrast.The
admin.rsdrift resolved at three distinct offsets (+27, +34, +42), not the two that were expected, so no uniform shift could have landed all of them — every pin was re-derived against the merged tree by locating its construct, rather than by applying an offset. Two of the six were additionally short by one line before the drift, ending just before the closing brace of the test they cite; their end lines were therefore wrong by four rather than three, the drift and the pre-existing error compounding.OPERATIONS.mdsaidadmin_list"passesanonymous_public_reads = true" and cited a line where the local is spelledadmin_sees_public_arm. Both names are real — the local binds positionally to the store parameter — so the text now names both and cites each. - six drifted when
Fixed
-
Pinned-key entries are validated for usability, not just presence, and the reload endpoint can no longer bypass config validation (
W3-U1,#193,#192).#185made startup refuseplayground.enabled+pinned_only = true+ an emptypinned_keyslist. These are the two remaining routes to the state that guard exists to refuse:- Under it (
#193): the guard checked the list was non-empty, never that an entry was usable. An entry with a typo'dalgorithm, key material that is not base64, aned25519key that is not 32 bytes, anecdsa-p256key that is not 65 bytes or does not begin0x04, orvalid_from >= valid_until, booted clean and then failed at request time — as a redactedinternal_error(crates/acdp-registry-types/src/error.rs:125), so the operator saw a bare 500 with nothing pointing at the config. All five are now startup refusals naming the entry by index and DID. - Around it (
#192):reload_pinned_keysranRegistryConfig::load(None)and then*guard = fresh.playgroundwith nothing in between, so every config guard — including#185's — was bypassable at runtime on a registry that booted clean. The handler now validates before taking the write lock and returns400with the live config untouched.400, not500, and deliberately distinct from the existingConfigReload500: that one means the registry could not read its own config (a server fault), this one means the operator wrote something invalid. Conflating them makes a config typo page someone as an outage.
Both doors now call one function,
validate_playground_configincrates/acdp-registry-core/src/playground.rs— placed there becausevalidate_configlives in the server binary, whichacdp-registry-corecannot call into, and because the algorithm list it checks against isPinnedAlgorithm::parse, private to that module.acdp-registry-typesis unchanged, so no new public API surface is committed ahead of therelease-plzpublishflip.Not a refusal: a pinned-key list where no entry is currently within its validity window warns loudly instead of refusing. Expiry is time-dependent, so refusing would make bootability a function of the wall clock, and under
pinned_only = falsethe state is behaviourally identical to having no pins at all, which is supported. The warning branches onpinned_only, because the two modes do opposite things — strict rejects everydid:webpublish, lax accepts them with no signature check.Upgrade note: a config that boots today can be refused after this — any entry matching one of the five is now fatal, however harmless it looked before. Three shapes cover essentially all of it: a rotated-out entry left in place with broken key material (expired and malformed — wholly inert before, since
pinned_for_atfilters on the validity window and nothing ever decoded it); any broken entry whileplayground.enabled = false, because these rules do not consultenabled; and a currently-live entry with a typo'dalgorithmor bad key material, which was already failing but only for that one agent's publishes and only as an opaque 500, which is how it goes unnoticed. Delete rotated-out entries rather than leaving them broken. Runtime pin-evaluation semantics are unchanged: this refuses bad config at the two doors, it does not change what a good config means. Docs:docs/CONFIGURATION.md(startup validation) anddocs/HTTP-API.md(the reload endpoint's three responses). - Under it (
-
The playground publish branch now honors
supports_idempotency_key(REG-11Phase 5,#128):crates/acdp-registry-core/src/handlers/ context.rs's playground unpinned publish branch — the manual idempotency lookup/record dance it runs aroundpublish_unverified_for_tests— reaches the SDK'sRegistryServer::commit_via_storejust like the other three publish paths (verified did:web, did:key, pinned-verified), but only via an unconditionalcommit_via_store(req, None, None, None)tail call (server.rs:557) that hardcodes the idempotency key toNone, socommit_via_store's ownsupports_idempotency_keygate (server.rs:666) is a no-op for this path and the branch cannot delegate this decision to it — previously honored ANYIdempotency-Keyheader unconditionally, with no check ofstate.server.capabilities().supports_idempotency_keyanywhere in that branch. Fixed with one sharedidem_keybinding, computed once before the lookup and reused at both the lookup and record call sites, rather than two independent&&conditions: gating only the lookup would still write a record that a latersupports_idempotency_key = trueflip would start replaying — resurrecting replays from records that should never have existed — and gating only the record would still replay today. A single binding makes that divergence unrepresentable. Also corrected the handler's doc comment, which claimedIdempotency-Key"is honored when the registry advertises support" — true of the other three branches, false of this one before the fix — to instead name the actual mechanism and why this branch cannot delegate this decision. Two new direct tests incrates/acdp-registry-server/tests/conformance.rs, placed immediately afteridem005_no_support_ignores_idempotency_key_header:idem_playground_branch_honors_supports_idempotency_key_gate(two publishes of the same body with the same key now get differentctx_ids when the capability isfalse) andidem_playground_branch_writes_no_idempotency_record_when_gated_off(assertsGET /admin/status'sidempotency.records == 0, which a lookup-only fix would not catch). Both use a did:web producer, not did:key —context.rs's did:key branch is checked, and returns, before the playground branch is ever reached, so a did:key producer would silently re-exercise the already-gated SDK path and prove nothing about the branch under test.idem005_no_support_ignores_idempotency_key_header's own doc comment is amended (not its assertions) to reflect that the gap it recorded is now fixed and covered from both sides.#128's second bullet (docs/CONFIGURATION.md:242) was already discharged by PR #132 (REG-11context finding 5); no further doc change was needed for it here. No test result is asserted as measured in this entry:cargo testcannot run in this environment (EACCES writing.dfiles even withCARGO_TARGET_DIRon a fresh scratch dir), so verification is via CI on the PR. -
Documentation sweep for the
#133fix (REG-11Phase 4): removed every doc statement and source comment describingGET /admin/contextsas ungated or as "the one exception" among/admin/*routes —docs/HTTP-API.md(the "Most/admin/*routes" paragraph, the "one exception" paragraph, and the route's own "Not admin-bearer gated" paragraph — the route-table footnote had already been removed in an earlier phase),docs/OPERATIONS.md(two "is an exception" / "is not" gated statements),docs/CONFIGURATION.md(theadmin_tokensrow's "does not disableGET /admin/contexts" clause),crates/acdp-registry-core/src/lib.rs's route comment ("but NOT every/admin/*route"), andcrates/acdp-registry-types/src/config.rs'sadmin_tokensdoc (which named onlyPOST /admin/pinned-keys/reload, stale even before this plan — the field already gated five routes before Phase 3, and now gates all six). Added the disclosure rule toExtendedRegistryStore::list_contexts's trait doc (crates/acdp-registry-store/src/lib.rs), the one file this correction belongs in that no phase's file list had named. This explicitly supersedes the "Documentation-accuracy pass (REG-10docs follow-up)" entry under the Documentation section below (the one claimingHTTP-API.md's "requires the admin bearer... it does not" and describing the gap as "a pre-existing gap... not a behavior change") — that entry was correct when written (PR #132, before Phases 2-3 shipped the fix) and is now the opposite of current behavior. Read it as history, not as the current contract. This is the sentence SECURITY.md's "Keepauth.anonymous_public_reads = falseunless the registry is meant to serve world-readable public contexts" was really about: it needed no edit here, but it only became fully true with this fix — before Phase 3,GET /admin/contextsdisclosed public contexts to anonymous callers regardless of that setting. Docs-only; no behavior change. Not compiled locally —rustdoc(docsCI job) is the verification signal for this phase.
- Webhook deliveries no longer emit a duplicate
event_idJSON key (U-002,#179):WireEnvelope(crates/acdp-registry-webhook/src/lib.rs) serialises a top-levelevent_idand then#[serde(flatten)]s the event.WebhookEvent::ContextRetractedand::ContextRepublished(crates/acdp-registry-types/src/event.rs) each carry their own actor-mintedevent_id, so those two deliveries — and only those two — put the key on the wire twice. The envelope serialises first and the flattened variant second, so every last-wins parser (serde_json, JavaScriptJSON.parse, Pythonjson) resolvedevent_idto the lifecycle UUID and silently shadowed the envelope's per-delivery dedupe id; first-wins implementations saw the opposite. One of the two meanings was always lost, and which one depended on the receiver's parser.- Wire change: on
context.retractedandcontext.republishedthe actor-minted lifecycle id now serialises aslifecycle_event_id. The envelope'sevent_idkeeps its name and its meaning — the per-delivery dedupe id, still echoed inX-ACDP-Event-Id. The other three event types are byte-identical to before. - For receivers: anything deduping on the
X-ACDP-Event-Idheader is unaffected. A receiver that read the body'sevent_idon these two types and relied on last-wins was reading the lifecycle id and now reads the delivery id — readlifecycle_event_idto keep the old value. - Implemented with
#[serde(rename)]rather than a Rust field rename: every construction site lives incrates/acdp-registry-core, and the emitted JSON is byte-identical either way. schema_versiondeliberately stays"1.0". It describes the envelope, which did not change, and it is stamped per delivery — so asearch.executedbody carrying a bumped version would assert that something about that delivery changed when nothing did. That holds however many variants a future change touches. The constant's doc comment, which previously promised a bump on "any" backwards-incompatible shape change, has been narrowed to match what it actually tracks.- Enforced by a test that drives all five variants through the real
emitter and asserts on the serialized body. Duplicate detection walks
the raw JSON with a
MapAccessvisitor rather than parsing toserde_json::Value, whose map silently keeps only the last of a repeated key and therefore cannot observe this defect at all.context.retractedandcontext.republishedare also documented in WEBHOOKS.md for the first time — the two event types carrying the bug were the two that had never been written down.
- Wire change: on
-
Releases work again, and the container pipeline can finally publish a version tag (
W2-U3):release-plzhad produced no release since 2026-06-13 despite 83 squash-merged pull requests onmainsince then — 23 of themfeat/fix, the prefixes that should bump a version — reporting success in under a minute each time. It resolves "what was last released" from crates.io even withpublish = false; nothing here is published, so all eight crates read as never-released, it proposed the current0.1.0, and the release step then refused because that tag already existed.git_only = truemoves the baseline onto git tags, andgit_tag_namechanges to{{ package }}/v{{ version }}so the un-packageable June tags stop matching without being deleted.Reading old tags: the eight
acdp-registry-<crate>-v0.1.0tags and their GitHub Releases are untouched and still valid; new ones use a/instead of the final-. The eight slash-namespacev0.1.0Releases were backfilled by hand on 2026-09-11 (#210): the bootstrap run that minted those tags deliberately suppressed Releases (#204), and release-plz only acts on version bumps, so it would never have created them. Until the backfill, "Latest release" pointed at June's9bd4fb3while the GHCR image:0.1.0was built from6ae49bf— 124 commits apart.acdp-registry-server/v0.1.0is now Latest, and the two agree. The first release run after this change re-bases the tag namespace and produces no release PR — that is expected; the run after it produces one.docker.ymltriggered ontags: ["v*"], which has never matched any tag this repo creates, so that trigger had never fired and itstype=semverrule was dead — the image has only ever existed as:latest,:mainand:sha-<sha>. It now triggers onacdp-registry-server/v*(one tag per release, not eight) and extracts the version with an explicitmatch=, guarded by a step that fails the job if a tag push ever stops yielding a semver version.release-plzalso now authenticates as a GitHub App, because tags pushed with the defaultGITHUB_TOKENdo not start workflow runs — without that, the corrected trigger would still never fire. That App token is scoped tocontentsandpull-requestsonly, andCARGO_REGISTRY_TOKENis no longer passed to the release job at all: nothing ingit_onlymode publishes to crates.io, so withholding it makes an accidental publish impossible rather than merely unintended.docker/RAILWAY.mdadvertisedghcr.io/…/acdp-registry:0.1.0built on "everyv*tag"; that image has never existed, and the image islinux/amd64, not multi-arch. Both corrected.PROGRESS.mdand.drive.lockare now git-ignored, and the duplicate## 6.heading inDECISIONS.mdis disambiguated without renumbering entries 7-10.
-
The
docker compose upquickstart boots (W3-U5). It did not. The stack shippedACDP_REGISTRY_JWT_SECRET:-changeme, and on HS256 every non-emptyjwt_secretis decoded and length-checked on every boot —changemeis valid base64 of six bytes, so the container exitedrc=1before serving a request.docker/docker-compose.ymlnow ships no secret (${ACDP_REGISTRY_JWT_SECRET:-}); an empty secret withauth.enabled = false, whichdocker/config.docker.tomlsets, is a supported configuration, so the demo needs no secret material committed to the repo. Chosen over shipping a real 32-byte default, which would have worked at the cost of a fixed secret in a tracked file that gets copied into non-disposable use.Observed against the real image and a real Postgres, not reasoned from the code: no secret set → boots,
GET /healthz200,GET /.well-known/jwks.json200 {"keys":[]},POST /auth/challenge404. WithACDP_REGISTRY_JWT_SECRET=changeme→ exit1, and the error is the container's entire output. -
jwt_secretis validated regardless ofauth.enabled, before migrations run (W3-U5).validate_configonly checked a non-emptyjwt_secretwith auth ON, so an auth-off registry with a bad secret still failed — just late, fromserve_with_store, afterstore.migrate()had connected and run DDL. That contradicted whatvalidate_configdocuments about itself ("before running migrations or binding the socket"). The check is hoisted out of the gate.No boot outcome changes. Every casing of
changemeis valid base64 of six bytes, already below the floor the serve path always applied, so ungating the literal guard swaps a generic length error for an actionable hint. The seven-row boot matrix reproduces with identicalBOOTED/EXITEDand identicalrc. One genuine difference, scoped rather than smoothed over: for a config with two fatal faults the first error reported can differ, because validation now precedes the backend checks andPgStore::connect.The EMPTY-secret check deliberately keeps its
auth.enabledgate — an auth-off registry with no secret is supported — and that asymmetry is commented rather than left to read as an oversight. Not closed here, and stated rather than swept: the algorithm check and the EdDSAjwt_private_key_pemcheck remain gated onauth.enabled. With auth off an unrecognised algorithm boots and an empty secret boots, but an empty EdDSA PEM still refuses — from the serve path, after migrations. A badjwt_secretis now refused before migrations; a missing EdDSA PEM is still refused after them. -
Four documents that described the old gating now describe the binary (
W3-U5) —docker/docker-compose.yml,config/registry.example.toml,SECURITY.md,docs/CONFIGURATION.md— in one commit with the compose change, so no window exists whereSECURITY.mdwarns about a hazard the repo has just removed.SECURITY.mdhad the error inverted: it warned that a placeholder "can survive there unnoticed" with auth off, when a placeholder in fact stops the stack from booting.config/registry.example.toml's "Two checks, gated the same way: neither runs while auth is disabled" was wrong on both counts.Each claim is scoped to HS256 where HS256 is load-bearing. Under EdDSA
jwt_secretis never examined — not for the literal, not for length, auth on or off — so a stale placeholder is silently ignored there rather than rejected.SECURITY.mdgains that as its own bullet, because the bullet after it recommends EdDSA for federation and an unqualified "a placeholder cannot survive unnoticed" would have been false for exactly the configuration being recommended.
Security
- Both stores now enforce the RFC-ACDP-0014 §4
supersedesrule (#174, supersedes PR#175):acdp0.10.0 addspredecessor_admissiontoPublishCommit, andPgStore::commit_publish/SqliteStore::commit_publishinvoke it on the predecessor's storedBody, propagating itsErr. Before this, the rule was the last unenforced row of that table.- This is a behavior change affecting every deployment, not an opt-in
one. The hook is live whenever the registry advertises
acdp_version >= 0.3.0, andANCHORS_VERSION_CLAIM(crates/acdp-registry-server/src/main.rs) is unconditional — every configuration advertises>= 0.5.0, asserted for a bare config bycapabilities_acdp_version_ladder. So every registry now rejects, withschema_violation(non-transient — do not retry), a publish that supersedes a key-revocation context with anything but a same-trust-class key-revocation. Publishes previously accepted will now be refused. That is the intended effect: the registry was advertising compliance it did not enforce. - The ordering is itself the security property. The call sits after tenant
scoping, producer continuity, lineage/version coherence and
AlreadySuperseded, and before every write. Running it earlier would turn publish into a cross-tenant, non-owner existence-and-context_typeoracle on the predecessor, leaking past the uniformsuperseded_target{NotFound}both stores already return for absent / wrong-tenant / non-owner targets. Upstream mandates this position ("never earlier"); the contract floor would violate it. - Refusal leaves nothing behind — no successor row, no supersession of the
predecessor, no transparency-log leaf, no idempotency record — because the
call precedes every write and
Transaction::Droprolls back. - The predecessor
Bodyis read by addingbody_jsonto each store's existing predecessorSELECT: no extra round-trip, no new lock, and no change to pg'sFOR UPDATElock mode. It is deserialized lazily, only when the hook is present, so a gate-off registry pays nothing and cannot newly fail. A body that will not decode fails the publish rather than silently skipping the check. - 17 regression tests (8 sqlite, 9 pg) exist for one reason: the field is a
plain struct field, so a store that binds it and never calls it compiles
cleanly, warns about nothing, and silently enforces nothing — and the
conformance fixtures do not cover the reject path (spec issue
#57). Each test was verified by mutation: the call was deleted, hoisted above each gate, defanged at the parse, made to swallow the one error variant the real closure emits, pointed at the lineage-head row instead of the predecessor, and keyed on the predecessor's type, the successor's type, the tenant, the receipt minter and the visibility. Each was observed failing its guard, and most left all-but-one test green — which is the point: several plausible wrong implementations pass almost the whole suite, so each needed its own test. The list is not a proof of exhaustiveness; three of these mutations were found by review after the suite was already written and passing.
- This is a behavior change affecting every deployment, not an opt-in
one. The hook is live whenever the registry advertises
-
The
/metricsbearer gate now compares in constant time (#168). It usedpresented != Some(token)—&strequality, which is free to stop at the first differing byte and so leaks the matching-prefix length of a configured credential./admin/*has compared with a constant-timect_eqsince#23, deliberately: a dedicated helper, a doc comment, a unit test pinning the property, and an allowlist fold with no early return so it does not leak which entry matched. Two gates comparing the same kind of secret disagreed.ct_eqwas private tohandlers::admin; it moves to a sharedacdp_registry_core::secure_compareand both gates now call it. One implementation, not a copy each — a duplicated fold is one refactor away from drifting, which is the outcome the issue explicitly warned against.Severity: hardening, not a live vulnerability. Remote timing attacks on a
memcmp-style comparison over HTTP are hard, and no shipped config exposes the gate. The argument is consistency with a decision this codebase already made. Two limits are documented rather than glossed: the length guard returns early, so token length remains observable at both gates; and no unit test here can demonstrate constant-timeness — the tests pin behaviour, and the timing property rests on a single reviewed implementation. -
Fixed a coverage hole found while implementing the above: nothing asserted that
/metricsrefuses a wrong bearer token. Replacing the comparison with a literaltrueleft the entire workspace suite green (measured). Every refused case in the existing tests fails at the"Bearer "prefix, so the authorization decision itself — the reason the gate exists — was untested, and/metricscould have been made to accept any token with CI staying green.metrics_wrong_bearer_token_is_refusedcovers it, including the near-miss shapes (Bearer scrape-secre,Bearer scrape-secreT) that a timing oracle would target. The equivalent mutation on/admin/*turns four tests red, so the gap was specific to/metrics.
-
A whitespace-only
metrics.bearer_tokenis refused at startup (#162):metrics_endpointapplies its bearer gate only when the trimmed token is non-empty, so" "or"\t"was read as "no gate configured" and/metricswas served unauthenticated to anyone who could reach the port — leaving no failed-auth signal in the logs, because no authentication was attempted. The realistic source is a value templated from an unset environment variable, and it fails open.validate_confignow refuses a blank-but-present value whilemetrics.enabled = true.An empty value keeps its documented meaning —
/metricsis open, the shipped default for a trusted scrape network — so only a value that was set and then blanked is refused. The guard is scoped tometrics.enabledbecause the route is not mounted otherwise; it still fails closed, since enabling metrics later trips the check before anything binds.Deliberately narrower than the
auth.admin_tokensguard added in#161: padded-but-non-blank values are accepted here. That guard refuses padding because only one of its two sides trims, which makes a padded admin token authenticate over HTTP/2 and fail over HTTP/1.1. The/metricspath trims the configured value and the presented one, so" tok "and"tok"behave identically on both protocols and there is nothing protocol-dependent to refuse.
-
Startup now refuses a blank or whitespace-padded entry in
auth.admin_tokens(#161).The failure this closes is not an operator blanking their admin allowlist — it is a deployment with several working admin tokens where one templated from an unset variable. The allowlist compare folds over every entry without early return (deliberately, for constant time), so
["tok-a", "tok-b", ""]admits the empty token alongside the real ones. Every genuine token keeps working, the list is non-empty, and nothing looks anomalous — the deployment reads as correctly configured while/admin/*is open, including the live pinned-keys reload and the registry-attested retract/republish routes.The mechanism:
require_admin_bearerstrips"Bearer "and does not trim, soAuthorization: Beareryields"", which matches an empty entry. This is reachable over HTTP/2, which preserves trailing whitespace in header values. Over HTTP/1.1httparsestrips trailing SP/HTAB/CR/LF before the value reaches the handler, so the same request arrives as"Bearer"and is refused. The registry serves both protocols.Padded entries such as
"tok "are refused for the same reason from the other direction: HTTP/1.1 trims the request header but not the configured value, so such a token authenticates over HTTP/2 and 403s over HTTP/1.1. That fails closed rather than open, but it is the same templating accident and it is indistinguishable from a typo.This is a hardening gap, not a live vulnerability. It requires operator misconfiguration, and no shipped configuration is affected — both
config/registry.example.tomlanddocker/config.docker.tomlleaveadmin_tokenscommented out, i.e. an empty list, which correctly means "admin routes disabled" and remains valid. What makes it worth closing is the direction of the failure: templating from an unset environment variable fails open rather than closed, which is the wrong direction for a security gate.The guard is in
validate_config, beside the existingauth.jwt_secretchecks — the same class of shared secret, already guarded three ways (empty, thechangemeplaceholder, and a decoded-length floor) whileadmin_tokensentries were not inspected at all. Failing at startup rather than per request keeps a misconfiguration from presenting as a client-side 403.The constant-time comparison itself (
ct_eq,#23) is correct and unchanged; the gap was strictly upstream of it, in what reached the allowlist.
chacha20bumped 0.10.1 → 0.10.2 (REG-11Phase 2 ride-along; lockfile-only version bump, not adeny.tomlignore):0.10.1is yanked from crates.io. It sits behindrand::rng()on the auth-challenge-nonce path (crates/acdp-registry-auth/src/service.rs), so this closes the CSPRNG core to a yanked dependency without any code change;cargo update -p chacha20only re-resolves the lockfile.SEC-01throughSEC-07— full sweep landed; see Added/Changed above for individual items. Notable: empty-string webhook secrets no longer silently produce valid HMACs (SEC-04), and theRequestBodyLimitLayer(SEC-06) protects every route from arbitrarily-large request bodies.h2bumped 0.4.15 → 0.4.19 (lockfile-only version bump, not adeny.tomlignore): resolvesRUSTSEC-2026-0258, in whichh2queued emptyDATAframes without limit, risking unbounded memory growth or a length-overflow panic. Reached transitively viahyper←axum/axum-server/hyper-rustls/reqwest.[graph] all-features = true(REG-7) indeny.toml: the cargo-deny advisory/license/bans gate now resolves every feature-gated subgraph, including thestorage-pgpath the Docker image ships (STORAGE_FEATURE=storage-pgby default indocker/Dockerfile), instead of only the default-features graph.cargo deny --workspace checkremains green with no new findings.axum-serverbumped 0.7 → 0.8 (REG-6): removesrustls-pemfilefrom the dependency graph entirely (0.8.0 replaced it withrustls-pki-types'sPemObjecttrait), so theRUSTSEC-2025-0134ignore entry is deleted fromdeny.tomlrather than merely satisfied.axum_server::Handleis now generic over the bind address (Handle<A: Address>);main.rsannotates its oneHandle::<SocketAddr>::new()construction site (shared by both the non-TLS and TLS-capable serve paths) and thespawn_shutdown_watchersignature accordingly. No router or crypto-provider changes —tls-rustlsstill resolves torustls/aws-lc-rs.
Documentation
-
Five documented behaviours the code does not implement, plus four pins and two anchors (
#190,#191). All re-derived from the artefact before editing; two were worse than their issues recorded and two were in nobody's issue at all.invalid_log_proofis reachable from this registry's own handler. Three sites said otherwise —docs/HTTP-API.md's error table, the wire-code arm incrates/acdp-registry-types/src/error.rs, and the doc comment oninvalid_log_proof_is_502_with_registered_code./log/proofechoes the leaf for retrieval-authorized requesters viarecord.leaf()(handlers/log.rs:359), and a stored leaf that no longer parses under the closed schema raises it locally. Recorded but deliberately not changed: that path answers502, which blames an upstream for a local data fault — a wire change, not a docs fix.lifecycle.enabled = falsedoes not stop emission.CONFIGURATION.mdstated the absolute; there is nolifecycle.enabledcheck in either store. Both attachregistry_state.lifecycle_eventsand derive theretractedstatus from stored columns unconditionally, so a registry that enabled lifecycle, accumulated events, then disabled it keeps serving both while answering501on the endpoints. Superseded by #373: that combination now refuses to start (see the #373 entry at the end of this log); the read paths are unchanged.docs/RECEIPTS.mdpromised aCache-Control: privateposture that does not exist. The onlyCache-Controlthis registry emits ispublic, max-age=300on three/.well-known/*routes, none of them requester-relative. Rewritten as an operator obligation with the exposure scoped to deployments that actually front a shared cache. Whether the registry should emitprivate/no-storeitself is#205, split out so a wire change is not shipped inside a docs correction.- Ten stale spec-SHA citations, removed rather than re-pointed.
conformance.rscited417211ften times against a CI pin ofd1f06d0. Every assertion was still true — only the coordinate was wrong, so nothing could turn red. Re-pointing would re-stale on the next bump; the counts now read "at the CI-pinned spec", which resolves to the singleref:inci.ymlthatbump-spec.ymlrewrites. - Four pins and two anchors.
OPERATIONS.mdpinnedstore/src/lib.rs:74foranonymous_public_reads;:74istenant.CONFIGURATION.mdpinnedcontext.rs:414for thedid:keybranch (that line is a comment about the playground snapshot; the branch is:421) and misquoted its rationale comment's range.AUTHENTICATION.mdstated one/metricsfact twice, each with its own pin at the same lines — one claim, two pins drifting in lockstep, reading to a checker as two independent confirmations. Two broken anchors inHTTP-API.mdandCONFIGURATION.md; every same-file and cross-file anchor acrossdocs/now resolves.
Pins touched here name their construct next to the line, so a pin that rots degrades to searchable rather than to wrong.
-
Corrected two false claims replicated across eleven operator-facing sites (
#180). Both would have led an operator to configure the registry incorrectly, and they failed in opposite directions.1.
[receipt]and the playground were documented as flatly incompatible. They are not.main.rs:259refuses onlyplayground.enabled && !pinned_only— the fully unverified sub-mode. Pinned-only playground alongside receipts is deliberately supported (rationale atmain.rs:249-258;receipt_with_pinned_only_playground_is_accepted). The harm was foreclosure: an operator who wanted receipts was told to disable the playground outright when pinned-only would have served them.docs/RECEIPTS.mdwas doubly wrong — its rationale ("the playground path never resolves the producer key") is false in pinned mode, which is the point of pinned mode. Corrected atdocs/CONFIGURATION.md,docs/RECEIPTS.md,config/registry.example.toml, each now also carrying the second precondition (main.rs:267refusespinned_only = truewith an emptypinned_keys) so the corrected docs cannot strand an operator at startup.config/registry.example.tomlgains a commented, copyablepinned_only+[[playground.pinned_keys]]stanza; it previously contained no mention of pinning at all.2. "
changemeis always rejected" never held for either stack that ships it. The only such check (main.rs:92-104) is nested insideauth.enabled && jwt_signing_alg != "EdDSA" && !jwt_secret.is_empty(), anddocker/config.docker.toml:24setsauth.enabled = false.docker compose upwith the shippedchangemedefault boots cleanly — the guard never fires in the one setup where a placeholder secret is likeliest to survive into production. Operators auditing whether they were affected should note this: if you relied on that documented check with auth disabled, it never ran. Corrected atSECURITY.md,docker/docker-compose.yml(×2),docker/RAILWAY.md,config/registry.example.toml,docs/CONFIGURATION.md,docs/OPERATIONS.md,docs/AUTHENTICATION.md. The check is also case-insensitive after trimming (main.rs:102-103), not a literal match, and does not apply under EdDSA — both now stated.docker/RAILWAY.mdwas the worst instance. Its required-env-var table never setsACDP_REGISTRY_AUTH__ENABLED,AuthConfig::default()isenabled: false, and the GHCR image mounts no config file there — so the documented production recipe runs unauthenticated while its only mention of auth was a false guarantee. It also instructsALLOW_PUBLIC_BIND = true, waiving the guard atmain.rs:465-478whose own bail text names its precondition as a proxy that terminates TLS and authenticates; Railway's edge does only the first. A note under the table now states all of this. Scoped deliberately: publishes and lifecycle events remain bound to DID-signature verification, so this is not "anyone can publish anything."Deliberately not changed:
auth.enabledanywhere (including the compose stack), the${ACDP_REGISTRY_JWT_SECRET:-changeme}default, and the addition ofACDP_REGISTRY_AUTH__ENABLED = truetoRAILWAY.md's required env vars. The last is a change to what a deployment recipe instructs, which is an operator-visible posture change rather than a docs fix;#180says posture must not change silently in a docs pass, so it is raised for a ruling instead. Documentation only — no logic, signature, or behaviour changed.Two further corrections found by the pre-merge verifier, both in text this change itself introduced:
pinned_only = truewith an emptypinned_keysdoes not "reject every publish" —playground.rs:109-111short-circuits toPinOutcome::Skippedbeforepinned_onlyis read, so publishes fall through to the unverified path. That rationale had been copied from the startup guard's own bail message (main.rs:271-273), which is itself wrong; the message is filed separately. Andplayground.pinned_keys.algorithmacceptsecdsa-p256as well ased25519(playground.rs:58-64), which the reference table had listed as ed25519-only.Line-pin sweep (CHARTER rule 10): two live pins point into
docs/CONFIGURATION.md—DECISIONS.md:267→:112andCHANGELOG.md:2054→:242— both below both edit points, both drifting+5. Reported, not re-pointed: re-pointing means editing existing lines in files this change treats as additive-only. The threechangemementions in CHANGELOG history (:1274,:1966,:2287) are left stale by the same historical-record principle. No test or workflow asserts on any changed string (verified acrosscrates/and.github/).
-
Corrected a class of stale
401doc comments in the ACDP request path (#164): six comments acrosscrates/acdp-registry-core/src/handlers/{auth.rs,context.rs}described auth failures as401. They returnRegistryError::AuthToken/Jwt/AuthChallenge, whichhttp_statusmaps to 403 (not_authorized), pinned byauth_errors_are_403. Comments only — no logic, signature, or behaviour changed.The most load-bearing was
caller_from_headers' "ReturnsErr(401)", which had already been cited as a source in a#152review and put a false401claim intodocs/HTTP-API.md's neighbourhood before being caught — which is what makes this a defect worth fixing rather than a typo: a stale doc comment is more credible than prose in an issue, because it sits directly above the function.Fixed as a class rather than the two reported instances. The sweep is bounded by a fact that makes it checkable:
StatusCode::UNAUTHORIZEDhas exactly one non-test occurrence incrates/(metrics.rs:130), so/metricsis the only endpoint in this registry that answers401, and any comment claiming401on an ACDP path is wrong by construction. Comments that correctly describe/metrics, and those inerror.rsexplaining why there is no 401-bearing code, were deliberately left alone.
-
docs/AUTHENTICATION.md: corrected two false claims (#166) that reachedmaininc8f9a40.It stated that two bearer parsers coexist, as a complete enumeration. There are three: the third is inline in the
/metricsgate (crates/acdp-registry-core/src/metrics.rs:124-128) and is a hybrid of the other two — case-sensitive on the scheme likerequire_admin_bearer, trimming likeextract_bearer. The count was wrong in two places, not one; the second instance was in the per-parser acceptance table, which now carries a/metricscolumn.It also stated that auth failures "on this registry" are
403, "never"401, with noWWW-Authenticatechallenge. That is true of the ACDP routes and of/admin/*, and false of/metrics, which answers401and sendsWWW-Authenticate: Bearer realm="metrics"(metrics.rs:130-134). The sentence is now scoped, and/metricsis named as the exception to both halves.Both claims were introduced by the fix for an earlier false claim — a correctly-scoped statement about the ACDP auth path was restated as a claim about the whole registry. A new
/metricssection documents that endpoint's gate directly, including the fact that it sits outside the ACDP auth pipeline entirely (crates/acdp-registry-core/src/lib.rs:153-155), so no bearer it receives is ever validated as an ACDP token.The parser differences are now pinned, not merely described. The first draft of this section claimed all three parsers were "locked by tests" — a third false claim of the same class it was correcting, since only the admin parser's differences were covered. Measured on the parent commit: deleting
.map(str::trim)frommetrics.rs, deletingextract_bearer's"bearer "arm, and deletingextract_bearer's own.map(str::trim)each left the entire workspace suite green. Two tests close that gap —extract_bearer_accepts_two_casings_and_trimsandmetrics_bearer_parser_shape_is_pinned— and each of the three mutations now turns exactly one of them red.This also protects
#162's rationale: that guard is narrower than#161's because the/metricspath trims both the configured and the presented token, a claim asserted in four places. Had that trim been refactored away, CI would have stayed green while every padded-token deployment began401-ing its Prometheus scrapes.Also corrected:
docs/HTTP-API.md's error-envelope note, which stated registry-wide that noWWW-Authenticatechallenge is emitted — the very sentenceAUTHENTICATION.mdlinks to as its authority.
-
Bearer-parsing behaviour is now documented (
#152), in a new "Presenting a bearer" section ofdocs/AUTHENTICATION.md, with cross-references fromREADME.mdandSECURITY.md.Two facts were true but written down nowhere. First, an
Authorizationheader the registry does not recognise as a bearer — a non-Bearerscheme, a misspelled header, a non-UTF-8 value — is treated as anonymous on the ordinary read/publish routes rather than refused: a typo'd scheme never reaches token validation at all. Only a well-formed bearer that then fails validation is rejected, and that rejection is403 not_authorized— this registry emits no401on the auth path. What the caller sees after the anonymous classification depends on the route and onauth.anonymous_public_reads(defaultfalse), so it may be a refusal or a filtered result set./admin/*refuses all the same inputs with403.Second, the parsers disagree. (This entry said "the two parsers"; there are three — the
/metricsgate carries a third, corrected under#166.)extract_beareracceptsBearerorbearerand trims the token;require_admin_beareracceptsBeareronly and does not trim. Sobearer <jwt>authenticates on/contexts/*and 403s on/admin/*. Both admin behaviours are pinned by tests (bearer_scheme_is_case_sensitive,rejects_token_with_extra_whitespace), so the strictness is deliberate rather than accidental.The section corrects a claim in the issue that prompted it:
#152argued the lax parser is the RFC 7235-conformant one. It is not — both parsers hard-code their prefixes, soBEARERandBeArErare rejected by both. Neither is case-insensitive; one simply accepts an extra spelling.Trailing whitespace is documented as protocol-dependent, which is the one behaviour here that cannot be stated flatly:
httparsestrips trailing whitespace from HTTP/1.1 header values before any handler sees them, while HTTP/2 preserves it.Bearer <jwt>is therefore accepted everywhere over HTTP/1.1 and refused by/admin/*over HTTP/2.
- Reference guides under
docs/: an index (README.md) plusHTTP-API.md(every endpoint, media types, and the RFC-ACDP-0007 error envelope),AUTHENTICATION.md(DID challenge-response, JWT claims, HS256 vs EdDSA/JWKS, token revocation, cross-issuer revocation federation),CONFIGURATION.md(the full config tree and startup validation),MULTI-TENANCY.md(tenant resolution and strict mode), andWEBHOOKS.md(event payloads and the signature scheme). ARCHITECTURE.mdandOPERATIONS.mdrefreshed to match the current code (crates.ioacdpdependency, EdDSA/JWKS, revocation federation, multi-tenancy, admin endpoints, rate limiting). Protocol-level material links to theacdplibrary docs rather than being restated.README.md,CONTRIBUTING.md, andSECURITY.mdcorrected to reflect thatacdpis consumed from crates.io (no sibling path dependency) and the current auth/hardening surface.- Documentation-accuracy pass (
REG-10docs follow-up) — two inaccuracies corrected, both in the permissive direction (docs described the code as safer/more restricted than it actually is). Two source comments carrying the same false permissive framing were also corrected (crates/acdp-registry-core/src/lib.rs,crates/acdp-registry-core/src/handlers/admin.rs) — comments only, no logic or signature changes, no behavior change.CONFIGURATION.md's[playground]section andREADME.md's feature list said the DID-signature bypass itself was "compiled in only with theplaygroundCargo feature." It is not: only the two/admin/*routes (admin_routerincrates/acdp-registry-core/src/lib.rs) are#[cfg(feature = "playground")]-gated. The publish handler's DID-verification skip (crates/acdp-registry-core/src/handlers/context.rs, theplayground_snapshot.enabledbranch) is a plain runtimeifpresent in every build, including a stock release binary — verified directly: that file's onlycfgattributes are three#[cfg(test)]blocks. Corrected to say so; the existing "never enable in production" warning is retained and strengthened — it now leads the section (previously buried behind a paragraph of compile-gating detail) and reads as the stronger, accurate claim (the risk exists regardless of how the binary was built, and is scoped to non-did:keypublishes), not weakened.OPERATIONS.mdandHTTP-API.mdalready scoped the feature-gate claim correctly, to the admin routes only.HTTP-API.md's endpoint table and Admin section saidGET /admin/contextsrequires the admin bearer (auth.admin_tokens), like the other/admin/*routes. It does not:admin_list(crates/acdp-registry-core/src/handlers/admin.rs) calls onlycaller_from_headers/tenant_for_request— the same resolution the regular tenant-scoped read routes use — and neverrequire_admin_bearer, unlikereload_pinned_keys,admin_status,lineage_audit, and the lifecycle-transition handlers, which do. Withauth.enabled = falsethe route is fully anonymous — and even withauth.enabled = true, no bearer is required at all when theAuthorizationheader is simply absent (caller_from_headersreturnsOk(None)), so an unauthenticated request still enumerates public contexts. This is a pre-existing gap between the route's/admin/path and its actual authorization, not a behavior change —OPERATIONS.mdandREADME.md's endpoint table corrected to match.
-
This changelog said
docker compose upboots cleanly. It did not (W3-U5). Correcting a false claim introduced by theU-005entry above. Appended, never rewritten, perD-008.The claim, quoted verbatim:
docker compose upwith the shippedchangemedefault boots cleanly — the guard never fires in the one setup where a placeholder secret is likeliest to survive into production.What was actually true. It did not boot. It exited
rc=1before serving a request, and had done so for as long as the placeholder had been there. The first half of that sentence was false; the second half was true, and is why the first half was believed. The literalchangemeguard is indeed gated onauth.enabledand indeed never fired in the compose stack — but it was never what stopped the boot.serve_with_storepasses any non-emptyjwt_secrettoJwtSecret::from_base64, which imposes a 32-byte floor with noauth.enabledgate.changemeis valid base64 of six bytes, so the stack died on the length floor, in a code pathU-005never examined.Two further sentences in that entry are also wrong and are corrected here rather than in place. It described the check as "nested inside
auth.enabled && jwt_signing_alg != "EdDSA" && !jwt_secret.is_empty()" — theauth.enabledconjunct is gone as of this unit. And it instructed: "Operators auditing whether they were affected should note this: if you relied on that documented check with auth disabled, it never ran." That instruction is wrong in a way worth being explicit about: the check that actually stopped the boot — the ≥32-byte floor in the serve path — always ran with auth disabled. An operator following that audit advice would have looked for a silent acceptance that never happened.What operators should actually note. No deployment was made less safe by the original error: the stack refused to start rather than starting insecurely. But anyone who tried the documented quickstart and concluded the repo was broken was right, and anyone who read this file to decide whether to bother was misled. If you are on EdDSA, a different fact applies — a
changemeinjwt_secretis ignored entirely there, then and now.For the record, since this entry corrects a pin as well as a claim:
U-005listed seven files as corrected. Four are corrected again here.docker/RAILWAY.md,docs/OPERATIONS.mdanddocs/AUTHENTICATION.mdstill carry the false gating claim and are not this unit's to change; they are reported to their owners with quotes rather than edited.
Added
-
A mutation oracle, the number it ratchets against, and the harness bug that made the first number a lie (
#216). The conformance file has never been able to prove that a test it vouches for asserts anything. Two mechanisms guard those claims and both are PRESENCE oracles: the substring guards overinclude_str!(covered_direct_families_have_present_test_functions,partial_direct_test_functions_are_present) and the compile-checkedDIRECT_FNStable added by#249. A test that exists, compiles, runs and asserts nothing satisfies every one of them. Breaking the code and watching the test go red is the only thing that does not, and that is now wired.THE BASELINE, at
52c0111,cargo-mutants 27.1.0, 6m at-j4:outcome count mutants in scope 74 viable (the honest denominator) 48 caught 46 survivors (missed) 2 (one since killed → budget 1) timeout 0 unviable (does not compile) 26 The committed survivor budget is 1 (survivor 1 below was killed rather than accepted), in
.github/workflows/mutants.yml'senv, alongside an exact-equality check on the scope size.Read the denominator honestly: 48, not 74. The 26 unviable mutants are mutations that do not compile — every one is
replace <fn> -> <T> with Ok(Default::default())(or similar) whereThas noDefault; the five axum handlerslog_entries/log_proof/log_checkpoint/inclusion_proof_response/consistency_proof_responseaccount for 20. There is nothing there for a test to catch, so they are excluded from the claim rather than counted toward it.A FIRST BASELINE OF "48 CAUGHT, 0 SURVIVORS" WAS MEASURED, BELIEVED, AND WAS WRONG. It is recorded here because the way it was wrong is the most useful thing this unit produced.
cargo-mutantstests each mutant in a$TMPDIRcopy of the tree and does not copy.gitby default.conformance_gate.rs'sno_tracked_file_contains_a_conflict_markershells out togit ls-files, which fails there;cargo teststops at the first failing test binary; andconformance_gateruns beforehttp_integration. So every mutant whose real killer lived later was scored CAUGHT by that unrelated panic — 41 of the 48, i.e. everyhandlers/log.rsverdict. Only 7 were genuine, all inreceipt.rs, and only because core's unit tests happen to run before the poisoned gate. Had that gate run earlier, all 74 would have been false.The hazard was already written down in
.cargo/mutants.toml— "a pre-existing failure elsewhere in the workspace would mark mutants CAUGHT for the wrong reason" — and it was checked:cargo test --locked --workspace, 632 passed, 0 failed, quoted as evidence the zero was real. The check was sound and its answer was true. It was about the tree we were standing in, and the verdicts come from the tree the tool builds. Naming a hazard is not checking it, and checking something is not checking it. Fixed bycopy_vcs = true;.githere is a 4 KB worktree pointer, so it costs nothing.What exposed it was not doubting the number — a 0 reads as success — but reading the killing test's name:
no_tracked_file_contains_a_conflict_markercannot possibly be killed by mutatingroot_for.THE HARNESS CONTROL, because a budget with no control is a number on trust. Under the harness and in the copy tree,
root_for -> String::new()is CAUGHT by the tenhttp_integrationlog tests that genuinely exercise it — matching a by-hand mutation of the same line run outside the harness. Two independent derivations agreeing..github/workflows/mutants.ymlalso carries a standing check for the same class: it fails if any single test is the SOLE failing test for more than half the caught mutants. Falsified on the two real runs, not on fixtures — the pre-copy_vcsrun FAILS it (41 of 48 = 85%), the corrected run passes (largest sole killer 8 of 46 = 17%).cargo-mutantscannot do this for us: its unmutated baseline runs PACKAGE-scoped even undertest_workspace(baseline.logsays--package=acdp-registry-core, the mutant logs say--workspace), so it never builds the binary that was failing and a green baseline is compatible with every verdict being noise.THE TWO SURVIVORS, enumerated with a reason each, not totalled.
-
handlers/log.rs:117:19—replace != with ==inrequester_can_retrieve. A real unasserted branch, and security-relevant. Line 117 isif stored != tenant {, the tenant gate. Inverted it is wrong both ways: a matching tenant is DENIED, and a mismatched tenant falls through toOk(true)— disclosure across the tenant boundary. The function is live (:269,:303) and gates the §8.2ctx_idproof surface and everyleafecho — i.e./log/proof?ctx_id=…, not/log/entries, which moved to the batched predicate in H-I-s (:474records that). No test reaches it: every/log/prooftest inhttp_integration.rsfetches viaget_jsonwith noX-Tenant-Id, sorequested_tenantis alwaysNoneand theif let Some(tenant)block never executes.log_entries_leaf_presence_is_tenant_scopedlooks like the guard and is not — it exercises the batched/log/entriespath, a different predicate. KILLED, not budgeted. The claim onhttp_integration.rswas granted and the two tests landed in the same commit as the ratchet:log_proof_ctx_id_is_served_to_the_owning_tenantandlog_proof_ctx_id_is_withheld_from_a_foreign_tenant— two tests rather than one because inverting the operator breaks both directions, and a single test would stop at whichever assertion ran first and never evaluate the other. Falsified: with==the first failsleft: 404, right: 200(the owning tenant denied) and the secondleft: 200, right: 404with the response body carrying the other tenant's fullleaf—ctx_id,content_hash,key_fingerprint,receipt_hash. Reverted, both green. Budget therefore 1, not 2.Say what this is accurately. The code is CORRECT:
!=is the right operator and no cross-tenant disclosure ships. What the oracle found is an unguarded correct property — nothing executed the branch, so nothing would have noticed if it stopped being correct. "Mutation testing found a cross-tenant disclosure" would be false. -
handlers/log.rs:131:18—replace == with !=inroot_for. Accepted: an equivalent mutant. Line 131 isif tree_size == current {and it guards onlylog.cache_root(...).root_forreturns the samerooton both branches, and append-only makes any(size → root)pair immutable, so a cached historical root is still correct and an uncached current root is merely recomputed. Nothing observable changes — only which sizes are cached. No assertion over responses can kill it; doing so would need instrumentation counting merkle computations, which is a performance harness, not a correctness one.
WHAT THIS NUMBER DOES NOT COVER, stated where the number is rather than in a footnote. The scope is two files, chosen to be ones no other unit is editing:
acdp-registry-core/src/receipt.rs(9) andsrc/handlers/log.rs(65). It is not the workspace, which is 1398 mutants at this sha — roughly 2.4h at the ~6.2s/mutant marginal cost inDECISIONS.md#17. It deliberately excludessrc/handlers/context.rs(134 at this sha), held by another unit this wave: a budget keyed to a file being rewritten underneath it goes red for reasons unrelated to what it guards, and a red check nobody can explain gets disabled. The drift alone shows the exclusion was right — #17 measuredcontext.rsat 132 and the workspace at 1383 one day earlier.And the conformance tests only assert under
ACDP_SPEC_DIR. 42 of the 70 tests inconformance.rs— essentially everyDirect-registered family test the coverage tables name — return early without it:let Some(fixtures) = spec_fixtures() else { … return; }. The suite prints69 passedeither way (0.09s skipping versus 0.36s doing the work), so the omission is invisible in a green log. The first baseline ran that way, meaning the tests#216is named after contributed nothing to it. Both the local re-measurement and the scheduled job now setACDP_SPEC_DIR(pinnedd1f06d0d…, the same refci.yml's conformance job uses) andACDP_REQUIRE_CONFORMANCE=1, so a broken spec checkout fails loudly instead of 42 tests quietly skipping. Verified by reading the work done rather than the pass line: for the same mutant, the conformance binary takes 0.55s in require mode against 0.12s in default, while a control binary of the same test count is 0.04s in both. One conformance test remains out of reach —playground_compiled_in_but_runtime_disabled_keeps_admin_route, which needs the non-defaultplaygroundfeature. A three-command wrapper would triple every mutant's cost (#17: ~6.2s → ~22s) to recover one test, so it is declined deliberately rather than overlooked.#216stays open. Its item 1 is fault injection oversrc/generally; this is a bounded 74-mutant ratchet. PARTIAL BY DESIGN. -
U-501 — #242: publishes that fail late are now charged on two of four branches
P5 (H-A) split the publish limiter into peek (read-only, never inserts) before the
pipeline and record (charges) on the success path. That removed a real vulnerability — an
unauthenticated caller could spend another agent's budget by naming them, and grow the bucket
map without bound because the map key was attacker-controlled — and disclosed, as #242, the
gap it left: a publish that fails after the limiter costs a full verify plus a store
round-trip and is never charged.
#242 recorded two candidate fixes and rejected both. Post-hoc classification of the error at
the central record_publish(e.wire_code()) wrapper is a denylist over a #[non_exhaustive]
enum, so it fails open as variants are added. Reserving at peek time puts the reservation
in an attacker-keyed map entry, which is the unbounded growth peek-not-inserting exists to
remove. Both rejections still stand and neither was shipped.
What changed
A PublishCharge drop guard (rate_limit.rs). Once armed it charges on every exit —
?, explicit return, panic, a cancelled request future. It classifies nothing, which is
precisely the property the rejected denylist could not have: an error variant that does not
exist yet is charged the day it is introduced, with no edit. Fail-closed by construction.
Arming is monotonic, so the pre-flight arm and the success-path arm collapse into one
mechanism without double-charging.
The guard is armed only where the signer is proven, so it never inserts on an
unauthenticated path. peek is untouched and peek_does_not_create_a_bucket still passes
unmodified.
The finding that made this bigger than one branch
#242 assumed only enforce_pinned_signature was a clean charge site — one branch of four —
and that anything more needed an SDK change. That was not true. acdp-server's own did:key
pipeline (crates/acdp-server/src/registry/server.rs:492-518) establishes identity with two
functions that are already public and already outside the client feature gate:
compute_content_hash and verify_publish_request_signature_offline. Composing them in the
handler proves the signer offline, before the SDK call, with no new dependency and no
cross-repo write.
Both halves are load-bearing, and the order is not cosmetic.
verify_publish_request_signature_offline verifies the signature over content_hash but never
binds content_hash to the body. Keyed on it alone, a captured (agent_id, content_hash, signature) triple replayed under a different body reads as "identity proven" and spends the
real agent's budget. Recomputing the hash first kills that. This was not a theoretical worry:
deleting the hash comparison reddened nothing in the entire suite until
a_replayed_envelope_over_a_different_body_does_not_spend_the_budget was written for it.
The four-way split, stated rather than left to be discovered
| branch | late failure charged? | |
|---|---|---|
did:key | yes | proven offline in the handler before the SDK call |
| playground, pinned | yes | enforce_pinned_signature proved it before the SDK call |
| playground, unpinned | no — by design, permanently | nothing is verified at all, so there is no identity to charge |
production did:web | no — remaining gap | identity is established only inside the resolver-backed SDK call |
The two "no" rows are not the same kind of thing and must not be collapsed. Arming the
unpinned playground branch would key an insertion on an attacker-supplied agent_id — the
shape #242 rejected. The did:web row is outstanding work: closing it from this side would
need a second DID-document resolution per publish (network cost, a second SSRF surface, a cache
that can disagree with the SDK's), all worse than the gap. It needs an SDK seam, designed in
plans/cross-repo/acdp-rs-publish-charge-seam.md and filed upstream against acdp-rs.
late_failures_are_charged_on_exactly_two_of_the_four_publish_branches pins the split with an
assert_eq! on the count, not a >= floor — a floor passes the very regression it exists to
catch. It reddens in both directions: removing an arm, and adding one to an unauthenticated
branch.
Cost, priced rather than hidden
The did:key branch now pays one extra JCS canonicalization + SHA-256 and one extra signature
verification per publish, because the SDK redoes both. That is the price of keeping the fix
in-repo, and the cross-repo seam above is what removes it — along with the did:web gap.
A test renamed because this change made its name false
a_publish_that_fails_late_does_not_consume_the_agents_budget is now
a_publish_that_fails_before_the_signer_is_proven_does_not_consume_the_agents_budget. The
failure it exercises is a tenant-check rejection, which runs before the branch dispatch — the
unproven side of the line. Its old name would have read as a direct contradiction of the new
tests sitting beside it.
U-505 — making the repo's deferred work measurable
The repo's real backlog was one open issue plus an unknown number of follow-ups recorded in prose inside two cumulative files that nothing scans. A follow-up nobody measures is indistinguishable from one that does not exist — and worse, because the prose records it, so it reads as tracked.
The count
| source | deferred items | how bounded |
|---|---|---|
ASSUMPTIONS.md (2535 lines) | 35 | exact; scanner bound-checked to 0 unexplained markers |
DECISIONS.md (2328 lines) | 42 candidate blocks | superset by construction; prose mentions included deliberately |
DECISIONS.md is 2328 lines, not the 6400+ the unit assignment estimated. All eight
file:line citations in the assignment were exact.
Why the enumeration took three attempts, which is the transferable part
A line-anchored grep '^- \*\*Status:\*\*' finds 88 of the 120 status markers in
ASSUMPTIONS.md. The 32 it misses are not exotic:
- markers wrapped mid-paragraph, because
grepis line-based (... would have stayed green with the guard deleted outright. **Status: CONFIRMED); **Status (updated 2026-09-01):**, where a parenthetical sits between the key and the colon;- entries with no
Statusline at all, whose bullet is the status (- **UNCONFIRMED — awaiting human ruling:** ...) — 9 of these, structurally invisible to any status-line scan; - one item that is an
###heading, not a bullet (### UNCONFIRMED: the four new steps run clippy, not cargo build); - one recorded only as an update inside another entry
(
- **Update, 2026-09-11 — PARTIALLY narrowed, still UNCONFIRMED.**).
The last two were found only by a bound check: assert that every UNCONFIRMED token in the
file falls inside a counted block, then read the ones that do not. That check turned up five
stragglers, of which three were genuine prose and two were real items the parser had missed. A
scanner that is not bound-checked reports a confident number that is simply the number of items
matching its own assumptions.
An open item is a claim about the past, and half of them had expired
Every item verified against the tree rather than inferred from the record. Of the ones checked, more were already fixed than were still live:
| recorded as deferred | actual state | evidence |
|---|---|---|
/metrics sets no cache headers, dismissal deserves revisiting | ALREADY DONE | lib.rs:265-271 sets Cache-Control: no-store via a route layer, with a comment covering exactly the 200-vs-401 concern raised |
EdDSA PEM case still fails late; validate_config narrowed to jwt_secret | ALREADY DONE | the EdDSA/PEM check is in validate_config (main.rs:111-117), and validate_config runs at :83, before every store.migrate() (:677, :725, :758) |
acdp-playground types webhooks as a closed Literal of three, dropping two lifecycle events | ALREADY DONE | acdp_client/models.py WebhookType now lists all four |
| 14 cursor-error literals duplicated across two store crates | ALREADY DONE | consolidated into acdp-registry-store/src/cursor.rs; the DECISIONS.md:1021-1022 line pins are dangling and now point at unrelated code |
storage-memory uncovered by CI | ALREADY DONE | ci.yml:59, :154, :324 |
dtolnay/rust-toolchain@master — the loosest pin in the repo | ALREADY DONE | SHA-pinned at 6c977a6c… in all 8 uses |
add bump-spec.yml to this repo | ALREADY DONE | .github/workflows/bump-spec.yml exists |
Still open, verified live, filed
- #265 — CI's four feature-configuration steps run
cargo clippy, notcargo build(ci.yml:146-165). Clippy does not run codegen or link, so a monomorphization or linker failure passes all four. A disclosed deviation from #200 that nothing ever decided. - #266 —
docker.ymlsets nojwt_secretand never boots the stack the quickstart ships. This is the specific hole the W3-U5 defect escaped through, still open. - acdp-website#43 —
webhooks.mdxdocuments 2 of the registry's 4 webhook event types;context_retractedandcontext_republishedare absent from the public docs.
plans/ is now partially tracked, and the exception is load-bearing
.gitignore ignored all of plans/. /plan's cross-repo handoff writes a design for another
repo here (writing into a sibling needs a human gate; reading one never does) and then files an
issue there linking a GitHub blob URL — which 404'd for every such handoff, because the file was
never committed. acdp-rs#273 was filed that way and had to carry its design inline.
Ruling: plans/cross-repo/ is tracked; the rest of plans/ stays ignored. Per-feature plans
are per-run working documents and committing them adds churn; a cross-repo plan is a contract with
another repo and has to be linkable.
The form matters and was tested, not assumed: git does not descend into an excluded directory,
so a bare plans/ makes !plans/cross-repo/ unreachable. Verified by reverting to the bare form
and watching git check-ignore call the cross-repo file IGNORED again.
Added
-
The mutation ratchet, verified by re-measurement rather than by assertion (
#216, U-502). The entry above records the baseline at52c0111(74 mutants / 48 viable / 46 caught / 2 survivors) and states that one survivor was killed. This is the confirmation that it actually was, run at6da5b7dafter the two tests landed:outcome at 52c0111at 6da5b7dmutants in scope 74 74 caught 46 47 survivors 2 1 timeout 0 0 unviable 26 26 8m at
-j6.handlers/log.rs:117:19moved from MISSED to CAUGHT, and the log for that mutant names its killers: exactlylog_proof_ctx_id_is_served_to_the_owning_tenantandlog_proof_ctx_id_is_withheld_from_a_foreign_tenant— the two tests written for it, and nothing else. A mutant that changes verdict while the killing tests are named is the whole claim; "we added a test and the number went down" would not have been.The one remaining survivor is
handlers/log.rs:131:18, theroot_forcache equivalent mutant, accepted with its reasoning in the entry above.The committed ratchet and harness checks were then run against this report, extracted out of
.github/workflows/mutants.ymlitself so the text checked is the text that runs: ratchetrc=0atscope=74 / budget=1, harness checkrc=0with the largest sole killer at 8 of 47 (17%), well under the 50% ceiling.Appended rather than edited into the entry above, deliberately. That entry was corrected in place while it existed only on an unmerged branch, and
DECISIONS.md#18 records that the licence for doing so expired the moment it was pushed. It is pushed. So this is an append — which is the rule being applied to its author rather than merely written down by them.
U-509 — #266: the documented quickstart is now a CI property
.github/workflows/docker.yml already booted a registry and hit /healthz. It did so by
hand-rolling docker run with docker/config.docker.toml mounted, so it never opened
docker/docker-compose.yml — and cd docker && docker compose up --build is what README.md's
"Production (Postgres + Docker)" section tells operators to run.
That distinction is the finding. W3-U5 existed because the quickstart did not boot: the compose
file shipped ACDP_REGISTRY_AUTH__JWT_SECRET=changeme, which validate_config rejects by name
(crates/acdp-registry-server/src/main.rs:166), so the stack exited rc=1 before serving a
request. The defect lived in the one file CI never read, which is why a user hit it before CI
did. It was fixed in abfebf7 with nothing guarding it since.
What landed
Three steps in docker.yml, driving docker/assert-quickstart-boots.sh:
--check— boots the recipe throughdocker compose, asserts/healthzandstorage:true(a stack that silently fell back to SQLite would still answer/healthz).--check-auth-on— boots it with auth enabled and a real secret, reachingvalidate_config's auth-gated branch (main.rs:129-142) that the shippedauth.enabled = falsenever executes.--self-test— two negative controls, wired into CI rather than asserted in a PR body, matching the conventionassert-image-tags.sh --self-testalready set.
compose.ci.yml points the service at the image the job already built, so this costs no second
release build. It overrides only image:/build:; the environment block, config mount,
depends_on and postgres service are the recipe's own, verified by reading docker compose config.
The negative control caught a decorative check — mine
The first draft of --check-auth-on exported ACDP_REGISTRY_AUTH__ENABLED=true before
docker compose up. Compose forwards only the variables named in a service's own environment:
block, and that one is not among them, so it never reached the container. The step booted the
auth-off stack and reported success. It could not have failed.
Nothing about the passing check revealed this. What revealed it was negative control (2) — auth on with an empty secret must be refused — declining to go red. The control was right and the check was wrong. This is the same shape as U-501's hash-bind (a security property fully implemented, fully green, completely unguarded) and U-505's grep undercount, three units running: the check that passes is not evidence; the control that fails to fail is.
Fixed with a CI-only overlay (compose.ci-auth-on.yml). Its effect is proven by control (2) now
firing, not by inspection.
Falsified against the real historical defect
Not a synthetic break. ${ACDP_REGISTRY_JWT_SECRET:-} in the shipped compose file was changed back
to ${ACDP_REGISTRY_JWT_SECRET:-changeme} — W3-U5's literal defect — and --check exited 1 with
"the documented quickstart (docker compose up) did not come up healthy". Reverting restored green,
and git diff --quiet docker/docker-compose.yml confirms the file shipped byte-identical.
The recipe gap this exposed, reported not papered over
The compose header tells operators to "set a real secret before enabling auth" but gives no env
path to enable auth. The obvious fix — a ${VAR:-false} passthrough — is a security regression:
compose renders an unset variable as set-to-empty rather than absent, and env beats TOML, so an
operator who set auth.enabled = true in the file would have it silently forced back to false.
That is the same precedence trap the header already documents for jwt_secret. Logged as
UNCONFIRMED in ASSUMPTIONS.md for a unit that can design the passthrough safely, rather than
taken here to make a CI step convenient.
U-511 — #271: an empty env override is treated as absent
Two behaviours composed into a footgun: an env var beats the TOML file, and docker compose
renders an unset variable as set-to-empty rather than absent. So every ${VAR:-} passthrough
in a compose environment: block silently replaced whatever the operator wrote in their config
file. The shipped recipe carried two separate caveats about this one rule, which is the signal
that the rule was the defect rather than its documentation.
The measurement that decided "correction, not breaking change"
The unit could have gone either way, and the deciding question — could anyone be relying on the old behaviour? — turns entirely on what empty did today. It is not uniform:
| field type | empty override, BEFORE |
|---|---|
| number | hard ERROR (invalid type: string "") |
| bool | hard ERROR |
Vec, not a list-parse key | hard ERROR (expected a sequence) |
Vec, list-parse key | [""] — a one-element list of nothing |
String | overrode with "" |
Four of five arms are a refusal to boot or a value nobody wants, so no deployment can have depended
on them. Only the String arm was leanable-on, and nothing in the repo documents it. Treating
empty as absent therefore fixes three hard errors and one garbage value and changes one arm
from "override with empty" to "fall through" — decided under the autonomy ladder rather than
escalated.
It is still made loud: RegistryConfig::empty_env_overrides_ignored() plus a startup warn!
names every dropped variable. A behaviour change nobody can see is the part that becomes a support
ticket.
${VAR:-false} is not the safe form, and that is the counter-intuitive part
The obvious way to give the recipe an env path to enable auth is
ACDP_REGISTRY_AUTH__ENABLED: ${VAR:-false}. That is still a security regression after this
change. Verified with docker compose config rather than reasoned about: ${VAR:-false} renders
the literal string "false", which is non-empty, so it is a real override and would force an
operator's auth.enabled = true back to false. Only ${VAR:-} — which renders "" — becomes
safe. The recipe uses that form, and both the inline caveat and the header paragraph it replaced
now say what is true instead of warning about a trap that no longer exists.
The test-fixture defect the falsification pass exposed
Falsifying the JSON-hatch filter reddened four tests, only one of which touched the broken
code. The other three inherited a leaked ACDP_REGISTRY_AUTH__TENANT_AGENTS_JSON="" — the failing
test panicked before reaching its own cleanup, and cargo test runs tests as threads sharing one
environment. A falsification run that names the wrong culprit is worse than no falsification,
because it sends the next reader to the wrong file.
Fixed with an EnvGuard that holds the lock and restores every variable it touched on drop,
including on panic. Re-running the same falsification now reddens exactly one test.
Two comments were corrected as collateral, both of which had become false: the existing env test's
SAFETY note claimed "no other test in this crate reads or writes process env" — already false
before this unit, since that same test does two separate env round-trips — and its inner note about
"preserving the only-one-test invariant". The invariant is now a lock, not an assurance.
Upgrade ordering, stated because it is a real hazard
The recipe now passes an empty ACDP_REGISTRY_AUTH__ENABLED. A binary from before this change
rejects an empty boolean outright, so pulling the new docker-compose.yml against an older image
breaks the boot. Called out in README's Configuration section. Not mitigated in code — the
alternative is omitting the passthrough, which leaves the gap #271 was filed about.
U-512 — #267: declining to make sha- tags immutable, and documenting what actually is
#267 asked for a pre-push existence check so a hand-run re-run of a main build could not repoint
sha-<short>. Declined, with the argument recorded in DECISIONS.md and the residual risk
accepted explicitly rather than left implied.
The deciding fact was checked rather than assumed: GHCR's package API exposes no tag-immutability,
tag-protection or retention setting — the returned keys are created_at, html_url, id, name, owner, package_type, repository, updated_at, url, version_count, visibility. There is nothing at the
registry to turn on, so anything shipped here would be a workflow check, and the package is
repo-scoped, so anyone with package write can push over a tag without touching the workflow. Written
as one sentence with its limit inside — which is how the assign asked for it — the guarantee would
read: "this workflow will not repoint a sha- tag, though anyone with package write access still
can." That is not what #267 asked for, and shipping it under the name "immutability" is the
overclaim class this board keeps finding.
Meanwhile the immutable identifier already exists and costs nothing: the digest. Verified end-to-end rather than asserted —
docker buildx imagetools inspect :sha-33bb3a3 --format '{{.Manifest.Digest}}'
-> sha256:002469d2dc7d6f1263a058010976f0ec7e4a2ba52c6db3cdece1e866a9a29df3— which matches the digest the packages API records for that tag. So the real problem #267 names,
that the sha- prefix invites being read as content-addressed, is a documentation problem.
docker/RAILWAY.md now answers it where operators actually choose a tag, including the cost of
pinning a digest (it never picks up a fix) so the trade is stated rather than sold.
The counter-argument, kept rather than buried
A CI check would stop the realistic accident — a maintainer clicking Re-run all jobs. That is
the honest case for implementing, and it is recorded in DECISIONS.md next to the reasons for
declining rather than omitted to make the decision look cleaner. It does not carry because the harm
is bounded: the rebuild is from the same commit by construction, so what differs is build metadata,
image.created and the provenance attestation — reproducibility and audit, not behaviour.
On not manufacturing mechanism
The escape hatch that would have made the fail-closed cost tolerable (a workflow_dispatch input
permitting overwrite) is pullable by anyone who could re-run the job in the first place. It would
have converted the guarantee into "immutable unless someone chose otherwise" — which is the status
quo with more moving parts and a more reassuring name. A decision recorded with its reasoning beats
a mechanism nobody wants.
No workflow change, so U-503's type=sha single-writer gate, flavor: latest=false, assert image tags and its --self-test are all untouched and still running.
U-514 — #276: making an operator hazard reach the operator
The #271 upgrade-ordering hazard — pull the image and docker-compose.yml together or the stack does
not boot — lived in a commit body, ASSUMPTIONS.md, the README and an issue. Release notes are
generated by release-plz from commit subjects, and a subject cannot carry it. So it reached
nobody upgrading.
A premise that had to be disproved first
Mid-unit the board reported that v0.1.4's release PR (#278) had dropped the change entirely —
acdp-registry-types had no 0.1.4 section despite config.rs gaining 430 lines — and framed that as
upstream of the whole question.
It is staleness, not a defect. #278's branch parent is 0e5bd14; 33bb3a3 (#275) is not an
ancestor of it. The numbers close it completely: commits touching crates/acdp-registry-types/
since its v0.1.3 tag are 0 at #278's base and 1 at current main; for the server crate,
2 and 3, matching exactly the two entries #278 shows. The release PR's content is correct output
for the tree release-plz actually read.
Worth recording because the caveat was stated and then not used: the same message that reported the finding also said the PR was BEHIND. Naming a limitation is not the same as gating on it.
What was checked before designing (Rule 147)
release-plz generate-schema — the tool's own schema rather than recollection of its docs — confirms
[changelog] accepts body, commit_parsers, commit_preprocessors and protect_breaking_commits.
So option 1's mechanism exists; it was not rejected as impossible.
Two facts then shaped the choice:
- The schema carries no default for
body, so adopting a template means authoring a full replacement for release-plz's built-in one without having that text authoritatively. The generated format today is good; silently regressing it is the realistic failure. - Squash bodies on
mainare multi-commit concatenations —0e5bd14's body is 1920 lines. Renderingcommit.bodywholesale is unusable, so any viable template design reduces to a targeted footer convention: a convention needing enforcement, exactly like option 2, plus a template rewrite.
Why option 2, in one sentence
A body template only runs during changelog generation, verifying it locally needs
release-plz update (which runs cargo package --verify across eight crates and failed on this
toolchain), and a release PR is open right now — so shipping it would have meant an undischarged
"it will work at the next release" with the release pipeline as the blast radius.
What landed
docs/UPGRADING.md, one section per version, newest first, with 0.1.4 carrying the #271 hazard as a
table of which pull combinations boot. A version with no operator-visible change says so — an
absent section is indistinguishable from one nobody wrote.
Named from where readers and contributors actually land: CHANGELOG.md's lookup table and its
"Where new entries go" section, README.md, and docs/README.md's index.
docker/assert-upgrade-notes.sh is what stops this being a convention with nothing behind it. It
fails the build when the workspace version has no section, and it sits in docker.yml because that
workflow builds the operator-facing artifact and runs on pull_request — so the release PR, where
the version bump happens, is gated before it merges. Verified against a simulated 0.1.4 tree: the gate
passes and the section carries the hazard.
Four negative controls wired into --self-test. The one worth naming: ## 0.1.40 must not
satisfy a check for 0.1.4. During a release those differ by a single trailing character, and a
prefix match would make the gate pass on the wrong section.
The limit of this choice, stated rather than left to be found
The hazard text is not inside the rendered GitHub Release body. A reader who reads only the release
notes and follows no link still does not see it. Option 1 remains available and its design is written
up in DECISIONS.md; the convention established here is what such a template would render.
U-516 — the gate that reported: making an enforcement claim testable
docker/assert-upgrade-notes.sh was added in U-514 with four negative controls, all of which fired
in real CI. Every one of them tested whether the script discriminates. None tested whether the
workflow it ran in could stop a merge. The unit then wrote "blocks" — a claim about the second
thing, resting on evidence about the first.
The controls were not weak; they were aimed one layer below the claim. A script that correctly rejects a missing section, running in a job nobody requires, produces exactly the same green transcript as a working gate. That is the whole failure mode, and it is invisible to any amount of testing of the script itself.
What made it visible was a question the unit never asked: which check name does this workflow
publish, and is that name in required_status_checks.contexts? Two gh api calls. The answer was
build, and the list was ["rustfmt","clippy","tests","conformance (spec fixtures)"].
Two things worth keeping separate here, because conflating them is how this recurs:
An equality, not a floor. "At least one required job runs the script" cannot catch the case that
actually occurred, where the name assumed to be required is spelled differently from the one GitHub
publishes. The check has to be membership of a quoted name in a quoted list. build versus docker
versus rustfmt is precisely that distance.
Observation and cause are separate sentences. In the same window this unit ran, two sessions
independently misattributed one real failure — release-plz update failing locally — first to the
toolchain, then to a malformed macOS 27.0 SDK. Neither attribution was tested and both were wrong.
Measured here with RUSTC_WRAPPER="":
cargo package -p acdp-registry-types -> rc=0 (0 intra-workspace deps)
cargo package -p acdp-registry-server -> rc=101 no matching package named `acdp-registry-auth`
found; location searched: crates.io indexThe cause is release-plz.toml's publish = false: nothing in this workspace is on crates.io, so
cargo package cannot resolve a path dependency on a sibling through the registry index. The
discriminator is exact — the one crate with no intra-workspace dependency packages cleanly; the seven
with at least one do not. Both wrong attributions pointed the same direction, at "local quirk,
someone could work around it", when the truth is structural and would reproduce on a clean Linux
runner. "Fails here, cause not established" would have been a complete and honest report; a confident
wrong cause was not.
This changes nothing about U-514's conclusion, and strengthens its stated reason: a [changelog].body
template cannot be verified short of an actual release for seven of the eight crates, anywhere.
U-504 — #216: the mutation ratchet extended to handlers/context.rs (2026-09-13, lane-2)
Measured with cargo mutants -j1, scope from .cargo/mutants.toml (receipt.rs +
handlers/log.rs + handlers/context.rs), ACDP_REQUIRE_CONFORMANCE=1.
Measured twice, at two spec pins, and the result is identical. main moved the conformance
pin from d1f06d0d49b73d411a3983d3877321ccaccd38e7 to 16211e64cf54973526a7af71adc8aed8996c3ae1
during this unit (U-518's merge), and that diff touches the replayed fixtures — five modified
(can-004, dk-001, dk-002, dk-004, data-ref-007) plus a new err-002. Since
mutants.yml derives its pin from ci.yml, a baseline measured at the old pin would have been
true of a spec CI no longer uses. So the full 8-shard run was repeated at af6647d against the
new pin: 213 / 131 caught / 8 survivors / 1 timeout / 73 unviable, with the survivor set
content-identical across both pins. The caveat is therefore closed rather than carried.
The new pin was obtained with git -C ../acdp-spec-pinned archive <sha> | tar -x into scratch —
a pure READ of the sibling repo, which stayed at d1f06d0 with a clean tree throughout. Moving
another repo's checkout is a cross-repo write and is not a lane's call, least of all for a fixture
other lanes may be running against.
| U-502 (74-mutant scope) | U-504 (213-mutant scope) | |
|---|---|---|
| mutants in scope | 74 | 213 |
| viable | 48 | 140 |
| caught | 47 | 131 |
| survivors | 1 | 8 |
| timeout | 0 | 1 |
| unviable | 26 | 73 |
The comparable figure is 28, not 8. The first run over the new scope found 28
survivors; 20 were killed with additive tests in http_integration.rs and these 8 are what
is left. caught moved 111 → 131, exactly +20, which is the independent arithmetic check that
the kills are real rather than the scope having shifted under them.
Method: the completeness claim is an equality, not a floor
Run as 8 shards at -j1. Every shard's outcomes.json carries an end_time (a shard without
one is an incomplete run, not a result — two were discarded on that basis across this unit), the
union of the shards' own mutant lists equals the full cargo mutants --list set exactly
(213 == 213) with zero overlap, and viable == caught + missed + timeout (140 == 131+8+1).
A >= check cannot detect undercounting, which is precisely how this lane's first baseline was
wrong.
Slice sharding nests for even multiples (0/4 == 0/8 + 1/8, verified by set equality),
so a completed coarse shard stays valid when subdivided — which is what makes a partial run
resumable.
-j1, measured, not assumed. On one 18-mutant shard: -j6 and -j8 each exceeded 10
minutes with every mutant still building; -j1 finished in 1m55s (~6.4s/mutant). Parallel jobs
cannot share incremental artifacts, so N simultaneous rebuilds of core plus every dependent
contend instead of pipelining. mutants.yml changed from -j2 to -j1 on that basis. Full
scope ≈ 23 min plus 5 min for the one timeout.
What the oracle found: 20 real gaps, in five clusters
Framing as in U-502, and it is the honest one: the code is correct, no disclosure ships, these were unguarded correct properties. Every gate below worked; nothing would have noticed if one stopped working.
- Five tenant gates with no coverage at all —
/contexts/{id}/body(:1018),/lineages/{id}(:1322retain skipped,:1328retain inverted),/lineages/{id}/current(:1361), andretract/republish(:1511). The last protects a write: with!=inverted, a caller scoped to another tenant successfully retracted the context, event recorded. It looked covered —X-Tenant-Idappears 26 times in these tests andretrieve_with_tenantexercises this exact gate on/contexts/{id}, the envelope route — but the tenant-header sites and the requests to those three routes did not overlap on one line. - Seven survivors behind an unasserted webhook payload. Three handler values reach nothing
but the webhook:
context_type_str(one caller), the validatedx-run-id(one use), and the retract delivery's reserved-tenant filter.count_connections_and_reply_okcounts connections and discards the bytes. A value with exactly one consumer is untested if that consumer is untested. Deleting the webhookRetractedarm delivered a retraction as"type":"context_republished"— telling a consumer the context came back. - Six survivors in the search refill loop, whose body was dead to the whole repo.
Instrumenting
iterationsand running the entire server suite produced not one iteration beyond the first. Reaching it needed?visibility=private, which empties every page. The matches cannot discriminate (empty either way) —next_cursorcan, so the test resumes an unfiltered search from it and counts what remains: 10 of 70 when the cap holds, 60 when the loop gives up after one page, none at all when it runs to exhaustion. - Two on
Idempotency-Key, the validation whose comment records it as the fix for #20. The existing test sends a 257-char key and asserts only200— but a key wrongly honored also returns 200. Only thectx_iddistinguishes "ignored" from "used". The fix for #20 shipped with a test that could not see the behaviour it was named for. - A did:web/lifecycle finding that is a gap rather than a kill — see survivor 7 below.
The 8 accepted survivors, each argued
Equivalent — the mutation changes nothing observable (4):
log.rs:131:18== -> !=inroot_for. Carried unchanged from U-502, not re-argued. Re-measured here and still MISSED, which matters: an equivalence claim expires when the suite changes, and this one survived 20 new tests.context.rs:1223:16delete !onif !matches.is_empty(). H-H-w moved the tenant predicate into the search SQL (search_in_tenant), so every row reaching this retain already belongs to the caller's tenant and skipping the block removes nothing. Positive evidence, not inference: the retain's own comparisont == tenantwas CAUGHT at the same site. Inverting drops every row and reddens a count assertion; skipping drops nothing.context.rs:81/:82, deleting the"public"/"restricted"arms ofparse_visibility. Measured: search returns only PUBLIC rows to every requester — probed with an anonymous caller, an audience member, and the row's own producer, all of which saw only the public row. So?visibility=publicselects everything already visible and?visibility=restrictedselects nothing, for anyone. The same site's"private"arm was CAUGHT — the one arm still doing work, since it must yield zero where no filter yields the visible public rows. These become coverage gaps the moment search serves restricted rows to entitled requesters, and the budget must then drop to 6.context.rs:627:39>->>=onrec.expires_at > Utc::now(). Differs only when a stored expiry equals the clock to the nanosecond.expired_idempotency_key_is_not_matchedpins the behaviour either side; the boundary itself is not reachable deterministically and a test that waited for it would be a flake generator.
Blocked on test infrastructure this suite does not have (4):
context.rs:1542:13, deleting theRetractedarm of the did:web dispatch — a retract processed as a republish. The entire did:web lifecycle branch has zero coverage:signed_event_envelopecan only sign as did:key, soactor.starts_with("did:key:")is true in every lifecycle test in the repo. Verified unreachable rather than assumed — a did:web-signed retract was written and fails atkey_resolution_unreachable, becauseretract_verifiedresolves the actor through a realWebResolverand playground mode does not bypass it. Follow-up: an HTTPS fixture servingagents.test's did.json, which also unblocks did:web publish.context.rs:1399:5->""and ->"xyzzy"onlifecycle_outcome. The function ise.wire_code()behind a metrics label, so its only observer is a/metricsscrape./metricsis deliberately not mounted in thehttp_integrationharness (404, measured), andmetrics_integration.rsis a separate test binary precisely so "the process-globalmetricsrecorder is isolated from other integration tests" (its own module doc). Asserting it there would install a global recorder into a binary built without one and put 158 tests behind shared mutable state. The right home ismetrics_integration.rs, outside this unit's grant. Follow-up: a rejected-transition label assertion there.
New floor: 8. Three of the eight are retired by one HTTPS did:web fixture plus one metrics
assertion; two more go if search ever serves restricted rows. Only the three genuine equivalents
(log.rs:131, context.rs:1223, context.rs:627) are permanent.
The one timeout is budgeted, not folded into "not missed"
context.rs:1277:12 delete ! on if !should_refill { break; } loops forever whenever the loop
should not refill — every unfiltered search. Non-termination cannot be converted into a fast
assertion failure, so the verdict is TIMEOUT and a timeout here is the detection. The ratchet's
previous timeout != 0 rule was reasoned about slow tests, which is a different thing; it is now
a named ceiling of 1, and a second timeout still fails the job. Measured directly: :1223:16
MISSED in 10s, :1277:12 TIMEOUT at the full 300s.
I had reasoned the new refill test would catch :1277 — under the mutation should_refill is
true on the first pass, so it breaks immediately and the assertion fires. That reasoning was wrong:
the other tests still hang, and a hanging binary times out whatever one failing test says. Hence
the measurement.
Lesson: a survivor is a fact; "a survivor means a missing test" is an inference
Eight of the 28 were not gaps. The other branch is that the mutation changes nothing observable, and defence-in-depth manufactures that branch on purpose — so the redundant-guard case is commonest in exactly the most-hardened code, which is where a tenant-isolation audit points an oracle first.
The cheap discriminator: two mutations at one site with opposite verdicts is positive evidence of
equivalence. t == tenant CAUGHT beside delete ! SURVIVED; "private" CAUGHT beside "public"
and "restricted" SURVIVED. Both times the caught sibling proved the site was reachable and the
survivor unobservable. For any survivor that deletes a fast-path or short-circuit guard, check for
upstream enforcement before writing the test.
I got this wrong first and corrected it on the board: :1223 was reported as an uncovered tenant
gate before search_filters_by_tenant — a green test that should have reddened — turned out to be
the evidence rather than the noise.
AC-8: the spec-pin coupling now has a guard
mutants.yml derives its acdp-spec pin by grepping ci.yml's 40-hex ref:. Nothing in ci.yml
says another workflow parses it, and mutants.yml is schedule-only, so a PR restructuring that step
could not turn red — the breakage would surface on the next Monday cron, detached from its cause.
CHARTER Rule 48: a doc artifact no command can check is a defect while it is still correct.
conformance_gate.rs now asserts four invariants as a pure function over both files' text (forced:
ci.yml is outside this grant, so falsifying by editing it was never possible), each falsified by a
valid-YAML restructuring — and the falsification test itself falsified by disabling each check.
One invariant changed shape on contact. As proposed it was "no hardcoded 40-hex ref anywhere in
mutants.yml". Measured: mutants.yml legitimately carries four 40-hex action pins, because
pinning actions by SHA is correct. Narrowed to a literal on a ref: line, with a test asserting
action pins are not flagged. A guard that fails against the correct file is a guard that gets deleted.
U-518 — when the compiler names the site and not the cause
axum::handler::Handler has two halves: the extractors must implement FromRequestParts/
FromRequest, and the returned future must be Send. When the second half fails, the error
points at the .route(...) line and says nothing about why:
the trait bound `fn(...) -> ... {retrieve::<...>}: Handler<_, _>` is not satisfied
--> crates/acdp-registry-core/src/lib.rs:79:42
= note: Consider using `#[axum::debug_handler]` to improve the error message#[axum::debug_handler] does not apply to a generic handler, so that suggestion is a dead end here.
Two plausible causes were tested and eliminated before the right one — both about the return type, which is where the eye goes first. What made them cheap to eliminate was asserting the bound directly rather than reasoning about it:
fn assert_ir<T: axum::response::IntoResponse>() {}
assert_ir::<axum::Json<acdp::types::body::FullContext>>(); // compiles
assert_ir::<Result<axum::Json<..::FullContext>, RegistryError>>(); // compilesBoth passed, which killed both hypotheses in one build and pointed at the future.
The technique worth keeping. A future's type cannot be written down, but it can be named by a
call expression, and that is enough to demand Send of it:
fn probe<S: ExtendedRegistryStore + 'static>(
st: State<Arc<AppState<S>>>, h: HeaderMap, p: Path<String>,
) {
fn is_send<T: Send>(_: T) {}
is_send(handlers::retrieve::<S>(st, h, p));
}That converts axum's "not a Handler" into the full chain: the offending type, every async fn
body it passes through, and the upstream file and line where it is held across an await — here
acdp-client-0.13.2/src/revocation.rs:459, a &dyn Fn(&KeyRevocation) -> bool missing + Sync.
About twenty lines of output, all of it load-bearing. Reach for this whenever a Handler bound
fails and the extractors and return type are unchanged.
And then prove it. Naming a plausible cause is not establishing one — the two dead hypotheses
were also plausible. The cause was confirmed by repairing it: + Sync on the two parameters in a
local copy of the upstream crate, wired in with [patch.crates-io], and the symptom disappeared
while nothing else changed. A one-line edit that makes the failure go away is an attribution;
reading a diff and finding something that looks related is not. See DECISIONS.md for the decision
this fed, and acdp-rs#279 for the upstream report.
The shape to remember: a private helper's parameter can change a public future's auto-traits.
acdp-client's own build stayed green — nothing in that crate observes Send-ness of its public
futures — so the regression could only ever surface in a downstream axum consumer. Auto-traits are
part of an async API's contract even though they appear in no signature.
U-520 PR A — the reuse that would have broken something else
The obvious fix for an ungated POST /contexts was to route publish through the AcdpJson
extractor that already gates /auth/*. It is the right instinct — one implementation of the
accept-set, one envelope, one minted code — and it was wrong here for a reason that only showed up on
the way to being measured.
AcdpJson delegates to axum's Json, whose JsonDataError rejection carries 422. So routing
publish through it fixed the 415 and simultaneously moved every wrong-shaped publish from 400 to
422. RFC-ACDP-0007 §5's status table pins schema_violation to 400. The reuse would have closed one
conformance violation by opening another with a much wider blast radius.
It surfaced as a one-line test failure — scenario A expected 400, got 422 — and the temptation at that moment is to adjust the expectation, because A is only supposed to assert "not rejected". Reading the spec's table instead is what turned a puzzling status into the reason not to take the obvious path.
The fix that shipped keeps the reuse where it pays (AcdpRejection, the envelope, the code constant)
and declines it where it costs (the deserializer and its status). The cost of that choice is a
re-implemented six-line predicate, which is a genuine drift risk — so it is pinned by a test asserting
both gates agree across a matrix of content types, rather than by the comment claiming they do.
Two things worth keeping from the measurement.
The 104. Gating an absent Content-Type reddened 104 of 159 integration tests. The fixture allows
either reading, so the number is not a bug report — it is the shape of the client breakage the other
reading would have caused, and it converted a coin-flip into an obvious decision. Scenario E accepts.
The two-line diff. The entire non-comment change to handlers/context.rs is the import and the
parameter type; the body binding is unchanged. That is also the evidence that nothing about hashing
or signature verification moved — not a claim that it didn't, but a diff in which it could not have.
When a change touches a signing path, the argument to reach for is one that makes the risky thing
structurally absent rather than reviewed and found safe.
U-520 PR B — the falsification that failed, and what it caught
Five of the six falsifications behaved. The sixth did not: mis-grading err-002 as
RequiredByProfile left the test green.
The assertion was fine. The test never reached it. registries/profiles.json stores profiles as an
array of objects with an id field, not a map keyed by profile name, so a .get("acdp-registry-core")
returned None, the helper returned None, and the caller took its "spec unavailable, skipping"
branch — reporting PASS while asserting nothing.
Two things are worth separating here.
The first is that the skip branch is the hazard, not the lookup bug. A lookup bug that panicked
would have been obvious. What made it survive was a well-intentioned pattern copied from the
surrounding tests: gracefully skip when the spec is unreachable. That pattern is correct for a
developer running cargo test with no ACDP_SPEC_DIR, and it is exactly wrong in CI, where the spec
IS reachable and a skip can only mean something is broken. The fix is not to remove the skip but to
condition it: assert!(!require_conformance()) before returning, which is the idiom spec_fixtures()
already uses. A green skip and a green pass are indistinguishable in the summary line, and only one of
them means anything.
The second is that I had already hit this exact shape an hour earlier and it did not transfer.
While measuring, my first Python probe of the same file crashed with 'NoneType' object is not subscriptable on the same map-vs-array assumption; I fixed the probe, got my numbers, and then wrote
the Rust against the assumption the probe had just disproved. The scratch tool and the shipped code
were treated as different problems because they were written in different languages twenty minutes
apart. When a throwaway script teaches you the shape of a file, that lesson belongs in a note, not in
the script you are about to delete.
What this says about the practice, not the bug. Falsifying every assertion individually is what separated these: had I falsified "the test suite" rather than each assertion, five reds would have drowned the one green that mattered. The green falsification is the informative one — a red proves the assertion works, a green proves the test does not — and it is the one that is easy to skim past, because a passing test after a deliberate break looks like a test that is merely lenient rather than one that never ran.
U-523 — a falsification that reddened nothing, and why that is the useful outcome
Four assertions, four falsifications. Three reddened. The one for 415 reddened nothing, and the green was the informative result again — for a different reason than last time.
Last unit a green falsification meant the test never ran. This time the test ran fine and asserted
exactly what it claimed; the thing I broke was not the thing it reads. AcdpBytes hard-coded
StatusCode::UNSUPPORTED_MEDIA_TYPE in its own rejection while status_for_code carried a 415 arm
used only by AcdpJson. Breaking the shared arm left the publish path untouched, because the publish
path never consulted it.
That is worth more than the bug it revealed. The whole decision behind this unit is the code decides the status, so code and status cannot disagree — and the code contained two independent places where a status was chosen, which is precisely the drift the decision exists to prevent. A guard against divergence that is itself duplicated has not removed the divergence; it has added a second copy of it. The falsification is what surfaced that, and only because it was aimed at one arm rather than at "the suite".
The fix was to make AcdpBytes derive its 415 from the same function, after which breaking that arm
reddens both the publish matrix and /auth/* — one edit, two paths, which is the property the design
claimed from the start and did not have.
The generalisable form: when a falsification comes back green, resist reading it as "the assertion
is lenient". Ask which of two different things happened — the test never ran, or the test never
reads what I varied. Both are silent, both look like leniency, and they have opposite fixes: condition
the skip, versus point the code at the single source it claims to use. See also probe must read what you vary — this is that rule applied to the guard rather than to the test.
U-506 — making the coverage tables name what actually guards each family (2026-09-13, lane-2)
conformance.rs's family tables said the log family's emission half was covered by two
golden-recompute tests. It is not. This unit makes the tables truthful and pins the citations that
were holding nothing.
The finding, reproduced before anything was planned
Mutating handlers/log.rs's root_for to String::new() — gutting the Merkle root every log
endpoint serves:
| with the root gutted | |
|---|---|
| the whole conformance suite | 73 passed, 0 failed |
the two tests PARTIAL_DIRECT names for log | both pass |
http_integration.rs | 11 failed |
EXCUSED's log entry argued that a direct pass "would assert something about acdp-crypto's
merkle code, not about this registry" — and then, four lines later, offered two golden-recompute
tests as proof that "the emission half IS covered". Its own argument applied to its own golden
tests, and the entry did not notice. log001_leaf_root_and_inclusion_golden_recomputed and
log003_consistency_proof_golden_recomputed reach only merkle::*; they never enter
handlers/log.rs.
PARTIAL_DIRECT was not wrong, which is worth stating: it pins exactly what it claims to pin.
The defect was prose inviting a stronger reading than the mechanism supports.
Classified as a DOCUMENTATION defect, not a coverage defect. The tests that hold the handler path exist; they were simply unnamed. The opposite conclusion would have sent someone writing duplicate tests — which is why the survivor/gap distinction from U-504 is applied here explicitly.
The number in the file was wrong, and it was my own doing
source_test_fn_body's doc comment said the mutation was "CAUGHT by ten tests" and referred to "the
whole 69-test suite". It is eleven: U-502 added
log_proof_ctx_id_is_served_to_the_owning_tenant to that log suite after the sentence was
written. Both figures were true when written, neither had anything holding it, and the later edit
that expired the first was mine. The count now lives in LOG_HANDLER_GUARD_COUNT where an assertion
reads it, and the suite size is no longer restated in prose at all.
The structural constraint, and the limit it forces
conformance.rs and http_integration.rs are separate integration-test binaries. Rust cannot
reference a #[tokio::test] function across them, so #249's direct_fn!/DIRECT_FNS compile-time
binding is structurally unavailable here — not merely unused. Cross-binary names can only be
verified by reading the other file's text: existence plus a test attribute, with the same substring
ceiling documented on covered_direct_families_have_present_test_functions.
So this unit makes the tables more TRUTHFUL without making the guarantee STRONGER, and those are different axes. Stated at the check rather than left for a reader to infer, because a more accurate table reads like a stronger guarantee and is not one.
The second, more general defect: a hand-maintained list cannot catch omissions
this_file_cites_constructs_and_never_line_numbers already reads sibling files from disk and asserts
a construct is present — the right idiom, already in the file. But its list is hand-maintained, and
it named 1 of the 9 http_integration.rs test functions this file cites. The other eight —
including publish_enforces_the_err002_media_type_matrix, cited as the test that enforces the
err-002 gate — were pinned by nothing and would rot silently on a rename.
So CROSS_BINARY_GUARDS is checked by a derived equality rather than by a longer list: read the
sibling from disk, compute its present test-attributed functions, intersect with what this file cites
by word-boundary match, require the result to equal the table. A >= floor would pass the very
scanner that is silently missing citations.
Two design corrections found while building the falsifications, both recorded in the code rather than fixed quietly:
- The set equality does not protect the
loggroup. Those eleven names are cited only by the table, so deleting one shrinks both sides together and the equality stays satisfied.LOG_HANDLER_GUARD_COUNT = 11is that group's guard. The nine prose-cited names need no count — prose keeps citing them, so the set difference fires. assert_eq!(cited, tabled)was structurally unfireable and was removed. Tabling a name is citing it, sotabled ⊆ citedalways and the reverse half could never fail. An assertion that cannot fire reads as coverage and provides none.
The survey of the other 21 entries, and why eight look wrong but are not
19 Direct blocks and 3 PARTIAL_DIRECT entries were classified by log's own signature: a test
that reads fixture vectors and recomputes through a library without building a router cannot be
holding a handler. The parser was checked against the known count of 19 before its output was
trusted — its first version matched exactly one family and reported "no families at risk", which is
what a broken extraction looks like: a confident zero. That is U-504's own lesson, applied to myself
one unit later.
Nine families' named tests never touch the HTTP surface. Eight are correct anyway, for three different reasons, and the reasons matter more than the count:
can,lin,caps— pure-vector families. Canonicalisation, lineage derivation and capabilities validation are recomputations; no registry path exists to hold, so a golden test is the complete and correct test.rcpt,lhr— word-for-word the same "The producer half IS covered and stays pinned" constructionlogused, and sound. Measured, not read: apanic!inreceipt.rs'sbuild_signerreddens bothrcpt001_…_and_remintableandlhr001_…_and_remintable, so they genuinely traverse this registry's producer code.log's goldens reach onlyacdp-crypto. That is the whole difference, and it is why identical wording was not enough to convict them.wit,dk,err— their registry-side path is held in a third place neither test binary can see. Apanic!inwitness.rs'sverify_cosignature_against_own_logleaves conformance (73 pass) andhttp_integration(0 failures) entirely green and reddens fivewitness::tests::*unit tests inside the core crate. The table credits no coverage that does not exist; it never claimed to enumerate in-crate unit tests, and the comment now says so.
So log was the only family whose table asserted something its named tests did not hold — a
measured claim about the other 21, not an assumption that the first defect found was the only one.
The bound, stated rather than implied: this is a structural discriminator plus three targeted probes, not a per-family mutation sweep. That is the ratchet's job (#216, U-504). A family whose named tests do build a router could still assert the wrong thing about it and nothing here would notice.
Falsification
Five assertions, each individually, each firing its own message: the fn scanner broken → "scanner is broken"; one log test dropped → "expected exactly 11"; a nonexistent name tabled → "no longer defines it"; a test listed twice → "appears twice"; a prose-cited test untabled → "does not list them". And green against the unmodified tree, because a guard that flags correct entries is a guard someone reverts.
http_integration.rs was READ ONLY for this unit (lane-1 was writing it concurrently under
U-523), so the "rot" assertion was falsified by varying the table rather than the sibling file —
which exercises the same assertion. The guard was then re-run after merging lane-1's #293, which
touched that file: it passes, so no name this unit depends on was renamed.
log's own claim was falsified the way the finding was made: with root_for gutted, the conformance
suite stays green and the failing http_integration set is set-identical to the tabled eleven —
equality, not a matching count.
A note on where the decisions are recorded
U-506's grant covered conformance.rs, docs/** and CHANGELOG.md; it did not include
ASSUMPTIONS.md or DECISIONS.md. None of this unit's four judgement calls is a one-way door — the
derived equality over a hand list, removing the unfireable assertion, the count const, and Phase 3's
scope bound are each one commit to reverse — and all four are documented at the code they govern. So
they are recorded here rather than by reaching outside the grant.
An agreement test is blind to a uniform regression
Two tests written earlier in this arc — the_two_media_type_gates_agree and
the_admin_media_type_gate_matches_the_publish_gate — compare one route's verdict against another's.
Breaking status_for_code's 415 arm, the single shared centre both routes consult, left both
green. The break moved both sides equally, so the property they assert was preserved while the
behaviour they exist to protect was destroyed.
This is not leniency and it is not hard-coding; I initially mislabelled it as the latter. It is
structural: an assertion of the form a == b cannot see a change that maps a -> a' and b -> b'
together, and a shared implementation guarantees that changes to it are exactly of that shape. The
more centralised the code, the blinder its agreement tests become — which inverts the usual
intuition that consolidating logic makes it easier to test.
The fix is not to delete them. They still catch the thing they were written for: one route drifting
away from another. The fix is that at least one test must pin the absolute verdict — what status
this input actually produces — so a uniform move has something to break. That test is
every_acdp_bytes_route_shares_one_media_type_gate, and breaking the shared arm reddens it.
How to tell in advance: ask what happens if the code under test is replaced wholesale with something wrong. If every assertion still passes, the suite is measuring internal consistency rather than behaviour. Relational assertions (agree, match, round-trip, idempotent) all share this blind spot and all read as strong coverage.
Check the name against the thing before shipping the name
every_body_bearing_route_shares_one_media_type_gate covered five of eight body-bearing routes, and
the missing three did not use the gate it named. The test was correct; the name was a false claim
about the system, and a name is the most quotable unit a test has — it is what a future reader greps
for and what a summary repeats.
Verifying it cost one grep of the route table and one read of the other extractor. That grep is what
surfaced the actual finding of this unit: the two accept predicates are independent implementations
that agree by coincidence. The overclaiming name was the only thing pointing at it — the code
compiled, the tests passed, and nothing else in the run would have asked whether /auth/* shared
the gate.
A falsification can be absorbed by an earlier assertion
Four falsifications, three mechanisms visible. The third — remapping
JsonRejection::MissingJsonContentType off 415 — was aimed at the absent-header assertion at the end
of the test, but reddened the present-type loop above it instead, because axum returns that same
rejection variant for a wrong content type as well as an absent one. The run came back red, the
mechanism named was real, and the assertion actually targeted was never evaluated.
A red falsification is therefore not proof that the assertion you aimed at works. Read which
assertion fired, not merely that one did. Where an earlier assertion absorbs the change, the later
one needs a separate falsification chosen to leave the earlier one satisfied — here, making
AcdpJson infer an absent header, which is also the precise "cleanup" the assertion exists to block.
Verifying code against code agrees with itself
Two places in conformance.rs recorded that did-ssrf-* was "not HTTP-replayable", and both said so
carefully. One noted it was "confirmed for this phase by re-reading extract_shapes directly rather
than trusting the prior DEFERRED reason's claim on faith (it held up)". The other was headed "The
prior DEFERRED reason's claim, verified before building on it". Both then walked the dispatcher
shape by shape and concluded correctly that nothing matched.
Every step was accurate and the conclusion was wrong. did-ssrf-001..004 are ordinary HTTP
publishes with concrete bodies; they now replay. Re-reading the dispatcher can only ever establish
what the dispatcher does. It cannot distinguish "this fixture is not an HTTP request" from "the
dispatcher does not parse this spelling of one" — and those two have opposite fixes. The check was
diligent, repeated, and pointed at the wrong artifact: to catch this, the code had to be checked
against the fixture, not against itself.
The tell was available and unread: the reason string said "vectors / schema / informative" about a
file containing "endpoint": "POST /contexts". A classification that contradicts the thing it
classifies is visible without any tooling, and it survived two deliberate verification passes because
both passes asked "does the dispatcher reach the fallback?" instead of "is the fallback's claim
true?".
How to apply: when a check concludes that some input is out of scope, verify the predicate
against the input, not the code path that produced it. "I re-read the function" is evidence about the
function. See also probe must read what you vary and assert the mechanism, not the symptom.
A floor is satisfied by every number above it
MIN_REPLAYED_EXCHANGES: usize = 30 guarded the conformance replayer with replayed >= 30, and its
own comment named the hazard correctly — "a fidelity gate may be over-matching and silently shrinking
coverage". It could not catch that hazard. Coverage was 30 while extract() silently declined 12
parseable fixtures, and 30 satisfies >= 30. The guard was calibrated to exactly the broken state and
would have gone on passing as coverage decayed anywhere above its floor.
Replaced with REPLAYED_EXCHANGES_AT_PIN = 38 and assert_eq!. Falsified by dropping a single
fixture: 37 passes the old floor and fails the new equality. Movement in either direction is now a
human decision — fewer means a dispatch gate started over-matching, more means fixtures became
replayable and the coverage tables were not updated.
This is the same lesson TOTAL_FIXTURES_AT_PIN already carries one level up, which is the useful
part: the repo had written down that a >= floor "passes the very scanner that is silently missing
items", pinned its fixture total as an equality on that reasoning, and left the exchange count a
floor. Knowing the rule did not propagate it to the neighbouring constant. Worth a sweep when a
lesson is recorded: find the other guards of the same shape, not just the one that prompted it.
Fixing the harness is half of curing a wrong-reason pass
pub-011 expects 400 invalid_signature. It was unreachable because the conformance harness bypassed
DID verification — so the obvious cure was to make the harness verify signatures. That cure alone
would have left the defect standing, and the fixture would have looked cured.
Its content_hash is the literal placeholder sha256:<recomputes-correctly-against-this-body>, and
the replayer pinned no error code for publishes, on the reasonable-sounding grounds that validation
ordering is impl-defined. Measured with the harness fixed but the code still unpinned: pub-011
passes, receiving schema_violation: content_hash digest must be 64 lowercase hex chars. A
fixture whose entire purpose is signature verification, scored green by a schema error.
A wrong-reason pass has two independent causes — the check that cannot run, and the assertion too weak to notice. Removing either one alone leaves a green test. They have to be counted separately, because fixing the dramatic one feels like completion: the harness change is the hard, interesting work, and it is exactly the moment you stop looking.
The general form: whenever a test asserts a class of outcome (any 4xx, an error occurred, it threw) rather than the specific outcome it names, restoring the capability it was missing does not make it discriminating. Ask what else could produce the same class.
Pinning the codes said four of my own fixtures had never been right
Pinning the expected error code turned up five publish fixtures passing for the wrong reason. Four
were did-ssrf-001..004 — fixtures I had lit up in the previous unit and reported as a coverage
win. They return schema_violation because their bodies omit a required member, so they never reach
the DID resolution they exist to exercise. The fifth, pub-002, was changed by this unit: pinning
the producer key makes signature verification run before the hash gate, so it now fails on the
signature rather than the hash its fixture names.
Both went into a CODE_DIVERGENCES table that records the code actually returned, with a reason, and
asserts it. The three available responses were: skip them (loses the coverage), leave them
unpinned (keeps the wrong-reason pass), or pin the truth and name the gap. Only the third leaves a
reader able to tell what is covered from what merely runs.
The uncomfortable part is the useful part: "+8 fixtures replaying" was true last unit and half of it was not coverage. A count of things that execute is not a count of things that check. When reporting newly-covered items, the honest figure is how many now assert the thing they were written to assert — and you only learn that by pinning the specific outcome and seeing what breaks.
Four instances, one shape, three units
The same defect has now been found four times: pub-011, did-ssrf-001..004, pub-008, and
cur-001 caught in advance. Every one had an identical structure — the fixture names a specific
error, the replayer pinned only the status, the registry returned a different 400 for an unrelated
reason, and the fixture was scored as coverage of a rule it never reached.
What makes it worth a log entry is that the instances were not found by looking for the class. Each
surfaced while doing something else, and the search that would have found all four at once —
grep 'want_error_code: None' — was one command and was never run until the fourth. After the second
instance the class was named, after the third it had a table, and the sweep still only happened
because a reviewer asked for it explicitly.
When you find the same defect twice, stop fixing instances and enumerate the class. The cost is usually one grep; the cost of not doing it is that the fourth instance is found by someone reading a green test and wondering.
pub-008 is the one that should sting: it predates all of this work. Every unit that touched the
replayer ran it, saw it green, and moved on.
Inverting an invariant is a decision, and must read as one
The sweep's fix required reversing an existing assertion — want_error_code.is_none(), documented as
"Shape A's publish branch never pins an error code (validation ordering is impl-defined), this must
still hold". Someone wrote "this must still hold" on the exact property that was hiding the bug.
The reasoning behind it was correct: RFC validation ordering genuinely is implementation-defined, so demanding a specific first-failing code genuinely can be wrong. The error was in what that licenses. "We cannot assert THIS particular thing" was silently widened into "we assert nothing", and nothing is what let an unrelated rejection pass as coverage. The middle option — assert the code we DO return, and record why it differs from the fixture — is strictly stronger than silence and was available the whole time.
So the assertion was inverted rather than deleted, and the comment now says it was inverted, by which unit, and why the original reasoning was sound but insufficient. An invariant that turns out to be wrong should leave a scar, not a clean surface — the next reader needs to know the property was considered and reversed, not that it never existed.
A guard's first act was to correct its author
The new sweep guard asserts a known-positive bound: N replayed fixtures name an error code, so that an empty scan cannot read as a clean sweep. I wrote 14 from my own reading. The real number is 15, and the assertion failed on its first run.
That is the bound check working exactly as intended, on the person who wrote it, thirty seconds after
writing it — and it is an argument for putting the number in as an equality even when you are
confident. A >= would have accepted 14 silently forever.
U-521 — the mutation floor drops 8 -> 5, and the "blocked on infrastructure" class is empty
2026-09-13, lane-2. Supersedes "New floor: 8" in "### The 8 accepted survivors, each argued" (U-504) — that section stays as written because this log is append-only, and it was accurate at its own sha. This entry is the current baseline.
The measurement
213 total / 134 caught / 5 survivors / 73 unviable / 1 timeout, at 8768b07, from the documented
command cargo mutants (scope and test command come from .cargo/mutants.toml).
Against U-504's 131 caught / 8 survivors: exactly +3 caught / -3 survivors, with unviable (73), timeout (1) and total (213) all unchanged. That is the arithmetic three retirements should produce, and nothing else moved.
It was measured over six shards, not one run, and how it was judged matters more than the number.
Two long single runs were killed partway (see below), and an interrupted cargo-mutants run writes a
partial missed.txt that is indistinguishable from a result by eye. So:
- every shard's
outcomes.jsonwas checked to carry anend_time— the null case is the tell; - the four tallies were checked to sum to 213;
- the shard sets were checked to partition exactly: sizes summing to 213 and the union of unique mutant names equalling 213. The sum alone cannot catch an overlap and the union alone cannot catch a dropped shard, so both are needed;
4/8 + 5/8 == 2/4and6/8 + 7/8 == 3/4were verified by listing before substituting finer shards for a coarser one, rather than trusting that slice shards nest.
One real contamination was caught this way: a leftover output directory from the killed shard run
was still matched by a */mutants.out/caught.txt glob, making the concatenated count 135 against a
true 134. The per-shard sum disagreed with the glob, which is what surfaced it. The totals above were
computed from six explicitly named shards, never from a glob.
The three retirements
Both SITES were "BLOCKED ON MISSING TEST INFRASTRUCTURE" in U-504's list — three mutants across the two of them. Both blockers are now gone.
1. The did:web LifecycleEventType::Retracted arm (context.rs, cited :1542:13 then, :1577:13
now). The blocker was real and structural: signed_event_envelope can only sign as did:key, so
event.actor.starts_with("did:key:") was true in every lifecycle test in the repo, and
retract_verified resolves the actor through a real WebResolver that playground mode does not
bypass. Covering it needed a genuine HTTPS endpoint.
tests/didweb/mod.rs now generates a CA + leaf chain at test runtime with rcgen and serves
agents.test's did.json over TLS on loopback, with WebResolver::with_test_endpoint pointed at it
and trusting the generated CA. The DID keeps its real authority; only DNS is faked.
Why generated rather than committed, because it is a policy fact worth recording: .gitignore's
# TLS material block forbids *.pem/*.crt/*.key repo-wide, its only negation is an empty
.gitkeep, and git ls-files finds zero committed TLS material in this repo. Committing a fixture
would have required an exception to a secret-bearing ignore rule — repo policy, not a lane's call and
not the lane leader's either. Generating needs no exception, commits no private key, and deletes the
expiry problem outright: nothing persists, so nothing can lapse, so there is no expiry guard to
maintain.
And a correction to U-504's entry for this mutant, in the alarming direction. It said the deleted
arm meant "a retract would be processed as a REPUBLISH". Running the mutant disproves it:
republish_verified validates event_type itself, so the request returns 400 schema_violation.
did:web retracts break loudly and entirely rather than silently succeeding. A quietly readvertised
retracted context would have been a data-integrity hole; this is a denial of function. Worth fixing
either way — not the same finding, and the wrong version was the scarier one.
The mutant is killed by the acceptance assertion (the retract must return 200), not by the status assertion beside it. That status assertion guards a different class — a retract accepted but not persisted — which no mutation at this site can produce; it is written as a before/after pair on the context's state so it cannot pass against a response that never carries a status.
2 and 3. lifecycle_outcome -> "" and -> "xyzzy" (cited :1399:5 then, :1424:5 now). Two
mutants at one line, which is why this retirement is three mutants at two sites.
a_rejected_lifecycle_transition_is_counted_under_its_wire_code in metrics_integration.rs — the home
U-504 named — asserts the outcome label equals the wire code the response actually carried, read from
the response rather than hardcoded.
The witness deliberately needs no publish. That binary's counters are process-global and a single
test owns the accumulation-sensitive assertions, including publish_total{outcome="inserted"} == 2. The
on-the-nose witness (a double retract, yielding invalid_lifecycle_transition) requires a published
context, which turns that 2 into a 3 — the suite said so: "9 passed, 1 failed, two accepted publishes:
left 3.0, right 2.0". Retracting a ctx_id that does not exist exercises the same mechanism, because
the metric wraps the whole lifecycle_transition call so any error flows through lifecycle_outcome,
and it perturbs no series that test pins. Preserving that documented split was worth more than the more
quotable wire code.
What is left is five, and all five are equivalent
There is no survivor in this scope blocked on missing infrastructure any more. All five are
equivalent mutants — log.rs:131:18, context.rs:81:9, :82:9, :642:39, :1238:16 — each argued in
.github/workflows/mutants.yml beside the budget. The ratchet cannot fall further without a code
change, or without search beginning to serve restricted rows to entitled requesters, at which point the
two parse_visibility arms become genuine coverage gaps and the floor drops to 3.
Line numbers in U-504's list were stale and are corrected in mutants.yml: #295 inserted above
them, shifting sites past ~line 600 by +15 and lifecycle_outcome by +25. Verified by reading each
line, not by arithmetic, and now cited as file:line plus the expression so the next shift does not
strand them.
The ratchet was re-falsified at the new value
A check verified at 8 proves nothing about 5. Driving the workflow's three conditions against the env values read back out of the file: 5 survivors passes, 6 fails; scope 212 and 214 both fail (it is an equality, not a floor); 2 timeouts fails. The load-bearing pair is 5/6.
MUTANTS_EXPECTED_SCOPE stays 213 — re-confirmed by the partition check, not assumed — and
MUTANTS_TIMEOUT_BUDGET stays 1, still the same provably non-terminating mutant
(:1292:12, was :1277:12). U-521 added a test that makes the refill loop's other survivors
observable and it did not change that verdict: a hanging binary times out whatever any single test
asserts. I predicted otherwise, measured, and was wrong.
Two process failures from this unit, recorded because they nearly cost more than they did
A long background job was killed twice, and I misdiagnosed it. Free disk had genuinely fallen
82Gi -> 8Gi during the first run, so I diagnosed ENOSPC and had a full report drafted before reading
mutants.out/debug.log, which ends cargo_mutants::interrupt: interrupted with zero ENOSPC matches
anywhere. The disk fact was true and unrelated. A true, strongly-correlated fact survives every check
you would run against a guess — only the causal link was invented. Read the failing thing's own record
before admitting correlated evidence, because once you hold a mechanism you read the log for
confirmation instead of for cause. (Separately: du -sh on a 208k-entry directory reported 77G where
summing its entries gave ~4G. Sum the parts before a total drives a decision.)
A stale hand-saved copy silently reverted four corrections. Restoring a file from a scratch copy taken before later edits reverted a doc-comment fix, a before/after assertion pair and two assertion messages. Nothing could have detected it: the file was valid and the suite had been green before those corrections too. It surfaced only because a mutant's failure output quoted the old message text. A restore is not a revert to known-good; it is a jump to an arbitrary past state whose contents you must remember, and what it eats is the most recent work — the work you are least likely to re-derive because you believe it is done.
U-535 — a differential with a shared centre points at the wrong file
parity.rs cross-checked the RFC-ACDP-0008 §4.5 disclosure rule across what its own comment called
three implementations. Two of the three were the same function: the trait default body is
retrieve_visible plus a tenant check, and the N-call reference calls retrieve_visible directly.
The interesting part is the failure mode. A shared-centre differential does not just miss a defect in
the centre — it misattributes it. With retrieve_visible broken, the leg that consults it agrees
with itself and stays green; the SQL, derived separately, is the only thing that can disagree; so the
report reads "the SQL disagrees". Falsified at baseline: the old legs printed
over-disclosure: [] and blamed the batch for under-disclosing, which is precisely backwards.
Fix: EXPECTED_BY_SPEC, a literal per-requester table transcribed from the RFC, with both
implementations compared against it. Falsification, per backend and per leg:
| mutation | anchor names the Rust rule | anchor names the SQL |
|---|---|---|
retrieve_visible: None => false → None => true | 4 | 0 |
SQLite LIST_VISIBILITY_SQLITE: second-arm AND → OR | 0 | 6 |
Postgres LIST_VISIBILITY_PG: second-arm AND → OR | 0 | 6 |
Attribution is correct in both directions, on both backends. The two SQL mutations also fired the new named outsider pins (2 each), which is what those pins exist for.
The generalisable lesson: count the distinct expressions a suite can bottom out in, not the number of comparisons it performs. Two differentials terminating at the same leaf give the coverage of one, and the doc comment claiming otherwise is itself the defect.
U-533 — required-but-unexercised 6 -> 1, and a retirement that cannot be faked
2026-09-13, lane-2.
The counts
required 6 -> 1; the survivor is pub-007. conditional 3,
TOTAL_FIXTURES_AT_PIN 144 and REPLAYABLE_FIXTURES_AT_PIN 22 all unchanged and asserted
as such — in a diff, "did not move" and "was not checked" are the same thing, so each is a live
assertion rather than a claim in prose.
Why AC6's "measure against git archive <pin>" is load-bearing
There are three spec trees on this box and neither checkout is the pin:
| tree | HEAD | fixtures |
|---|---|---|
nested clone agentcontextdistributionprotocol/ | a0adda7 | 145 (adds rev-003) |
acdp-spec-pinned/ | d1f06d0 | 143 |
git archive 16211e64… | — | 144 |
The drift runs in both directions, so using either checkout fails TOTAL_FIXTURES_AT_PIN in a
different way — and the directory whose name says -pinned is the one that is behind. Two sessions
reported different shas for "the spec checkout" and both were right about different trees. Measure
the archive; never the working tree.
The five retirements, and why four are direct tests rather than replays
The obvious repair was to widen the replayer. Measuring each fixture body showed that would have been actively harmful:
pub-006/pub-009carrysignature.valueof 96 base64 chars where ed25519 needs 88. Shape validation rejects them before RFC-ACDP-0001 §5.11 step 2, so they would have replayed green on400 invalid_signaturewhile asserting nothing aboutkey_not_authorized— the identicalpub-008defect from U-531 and thedid-ssrf-001..004defect before it. Widening Shape A would have manufactured two new wrong-reason passes inside the unit whose purpose is removing them.pub-006has a second blocker: both its DIDs usedid:agent:, unsupported here.pub-010has no inline body at all — its excerpt's signature is the literal placeholder<base64 signature that verifies under alice's did:web key>.pub-003is blocked by seeding only:input.preconditionsmatches no seeding path. Widening Shape D's shared seeder was rejected becauseREPLAYABLE_FIXTURES_AT_PINis an equality and one fixture is not worth changing what others replay.ret-002needs lineage seeding with per-version statuses, whichparse_shape_dcannot express.
Every blocker is asserted, not described, so a spec bump that fixes one reddens the test that depends on it.
pub-007 stays, and pub-010 is not a second instance of it
Both expect 201 where this repo returns 200. The first read was that U-526 blocks two fixtures
and the target should be 6 -> 2 — nearly escalated, and wrong. This file already carries the
anc-001 / idem-001 precedent: assert the corrected status, assert the fixture's own literal
separately so the deviation is demonstrably real, record it, neither fake nor fix it. That covers
pub-010, whose subject is contributors[].
It does not cover pub-007, whose entire subject is the response shape and the Location
header — a corrected status there deletes the fixture's point. Its row stays, with that reasoning in
it.
The defect worth more than the retirements
fixture_accounting_totals_are_exact's own doc comment said moving a fixture OUT "requires editing
the list and the count, and nothing else". Nothing checked that a retired fixture was exercised.
Deleting five rows and changing a 6 to a 1 went green unaided — a hand-maintained list cannot
catch an omission, the same shape as the >= floor this file has fixed twice.
EXERCISED_FIXTURES now binds each retirement to the test that requests it: compile-time via a
stringify! macro over the same token (rename/delete/comment-out = compile error), and at runtime
by calling each registered test and asserting the fixture id reached find_fixture_by_id.
Testing that guard found it closes only half the hole, and that is recorded rather than papered
over: a retirement that registers nothing is still green, because the two tables are not joined by an
invariant. A conservation law (required + retired == 6) was considered and rejected — a spec bump
legitimately adding a required fixture would redden it for a correct reason, and a guard that fails on
correct input is one someone deletes. The real closure is deriving the set from profiles.json's 72
required_fixtures, named as the follow-up.
ret-002 scenario 1: undriven, on the fixture's own authority
All-versions-superseded cannot be produced over HTTP — superseding a head makes the superseding
version the new non-superseded head. The fixture's own note calls it "Abnormal state: reachable only
via admin correction or data corruption". Rather than fabricate it with a store-level insert, which
would assert against a state no client can reach, the test asserts the note still says so, so the
scenario becomes visibly owed if the spec changes its mind. expired proved producible via
expires_at, so no blocked was needed in this unit.
U-536 — three spec trees disagreed, and the harness accepted whichever one it was handed
spec_root() took whatever ACDP_SPEC_DIR named and said nothing about it. Three spec
checkouts exist on this machine and they disagree in both directions — 143, 144 and 145
conformance fixtures — and the directory named -pinned is the one two revisions behind
the pin. Each wrong tree produces a plausible count that fails TOTAL_FIXTURES_AT_PIN, which
sends the reader to debug the fixture ratchet instead of their own checkout. That cost two
false alarms and one wasted audit before this unit.
.spec-pin is now the single declarative source: repository:, ref:, and a
conformance-digest: of the fixtures at that revision. ci.yml, mutants.yml, the spec
bumper and the conformance harness all read it, and none of them restates the sha.
The verdict comes from content, and that is forced rather than preferred
The way to materialise an exact revision locally is git archive <sha> | tar x, whose output
carries no git metadata at all. So the tree that must PASS is precisely the one
git rev-parse cannot identify, and any git-based check would reject the correct input. Git
is used only to name a revision once one is found, never to decide.
One digest covers both worlds, measured rather than assumed: an archive extract, an
independent second extract, and a real git clone + checkout of the pin — what CI's
actions/checkout produces — all hash to
rfc6962-sha256:03644a90cc643fd5bd5fe3c762389c16def704a874f94716619f7a949b965f85. There are
zero .gitattributes in the 253-file tree at the pin, so no filter can make checkout and
archive content diverge; that grep was run against a known positive (the same scan finds 145
schemas/conformance paths) before its zero was trusted.
RFC 6962 via acdp::crypto::merkle rather than a hand-rolled hash, and specifically not
std's DefaultHasher, whose output std does not promise is stable across releases — fatal
for a value committed to a file. No new dependency: leaf_hash/merkle_tree_hash were
already reachable. acdp::crypto::canonical_preimage is unusable here because it strips an
RFC-ACDP-0001 §5.7 EXCLUDE-set key by name, and fixtures legitimately carry signature and
ctx_id at top level.
The two guards that had to be falsified at the guard itself
A tree inside another repository must report "not nameable", not the outer repo's HEAD.
With the rev-parse --show-toplevel comparison removed, the message confidently reports the
enclosing repository's 63164d99 as the spec's revision. A .git-directory test would also
be wrong: one spec checkout here is a linked worktree whose .git is a 127-byte file.
merkle_tree_hash(&[]) is SHA-256("") — a real, confident-looking digest for "no files".
The fixture count is therefore asserted where the digest is computed rather than inherited
from resolve_fixture_dir, and that path is reachable, not decorative: has_json accepts a
directory named *.json, so a directory whose only .json entry is a subdirectory
resolves as a fixture dir and hashes nothing.
Ten mutations in all, each confirmed RED at its own assertion with its own message, then
reverted and confirmed green. The decisive ones name wanted 16211e64 / found d1f06d0 rather
than failing on a fixture count — a count failure would have been the right outcome for the
wrong reason. Renaming one fixture, with identical content and an identical file count,
changes the digest and restoring the name restores it, which is what the 0x00 between name
and bytes is for.
The bumper's anchor count, and an invariant that exists because a falsification failed
spec_pin_violations (tests/conformance_gate.rs) was four invariants about the old
derivation and is now ten about the new wiring. Invariant 2 counts column-0 repository:
declarations — what the reader action parses. I wrote a falsification in which a comment
spells the anchor form, expected invariant 2 to fire, and it stayed silent. The failure was
correct: acdp-ci's bump-spec-ref counts anchors with
index($0, "repository: " SPEC) || index($0, "acdp-ci/actions/checkout-spec@")a substring search over every line, comments included — and it then refuses to bump the
file at all rather than rewrite one anchor and leave the rest stale. So a helpful comment
freezes the pin with nothing going red, and invariant 2 was citing that external contract in
its own failure message while being unable to enforce it. Invariant 4 counts the bumper's
way, and is verified against the bumper's own awk rather than my reading of it: with a spelled
anchor the test reports lines [7, 57] and that awk independently reports ANCHOR_COUNT=2;
restored, both say 1. This is why .spec-pin's comments describe the two anchor forms
instead of quoting them.
Line order is load-bearing for the same external reason. conformance-digest: must stay
below ref:, because a 64-hex digest contains a 40-hex run and that awk would otherwise
return the digest's first 40 characters as the current pin. Falsified against the real awk.
All ten invariants are falsified twice — once on a synthetic fixture, then again by
mutating the real .spec-pin/ci.yml/mutants.yml/bump-spec.yml and confirming the
real-file test goes red with that invariant's number and no other. A synthetic pass alone is
not enough: an invariant can hold on a fixture and be structurally unfireable on the shape
the repository actually has.
What the move costs, in the direction it runs
Adopting a revision now changes three values and the bumper rewrites only the first:
ref:, conformance-digest:, and TOTAL_FIXTURES_AT_PIN. So a bump spec PR arrives RED on
conformance until the other two are updated. TOTAL_FIXTURES_AT_PIN already had that
property, the bump PR is held for review and never auto-merged, and the failure message names
the replacement digest itself — but it is a real added cost, stated in
.spec-pin where the value lives rather than in a footnote. CONTRIBUTING.md carries the
three-step procedure.
The lint gap this unit created, and closed
.github/actions/read-spec-pin/ is this repo's first composite action, and neither existing
linter covered it: actionlint enumerates .github/workflows/* and cannot parse an
action.yml at all — pointed at one it reports "jobs" section is missing and exits 1,
measured. The actionlint step's own comment claimed every run: block was covered, so this
unit would have made that comment false. lint.yml now extracts each composite-action run:
block and shellchecks it, with ${{ … }} replaced by a placeholder (the substitution
actionlint performs internally; a quoting defect inside an expression is therefore not
visible — a stated limit). Falsified by unquoting one variable in the action, which turns the
step RED with SC2086 at the right line.
The extractor asserts its own yield, and it earned that. The first version was an awk
one-liner whose indentation was off by two: it extracted zero lines, and all six of its
negative cases reported PASS. An extraction expression that matches nothing fails to a
confident zero, so the step now refuses a run that extracts 0 blocks from a non-zero number of
actions — and that guard fired for real during development, when git ls-files correctly
reported 0 because the action was not yet staged. The step's embedded heredoc was also
verified to survive YAML round-tripping, by parsing the run: block back out of lint.yml
with a YAML parser and executing it: the Python body must land at column 0, and an
IndentationError there would have been visible only in CI.
Two references this unit made stale, outside its path grant
ASSUMPTIONS.md:2872— entry 9, "mutants.ymlderives the spec pin fromci.ymlinstead of restating it". The derivation is gone; both files now read.spec-pin.ASSUMPTIONS.md:315— records thatbump-spec-ref.ymlrequestspermission-workflows: writebecause the pin lives under.github/workflows/. That because no longer holds: the bump PR now touches a root-level data file, so the scope is no longer load-bearing for the spec bump. The scope is still requested by the shared workflow in acdp-ci, and the installation grants it, so nothing breaks — the reasoning is what went stale, and it moved in the safe direction.
Earlier entries in this log describe the retired derivation as current — including the U-521 baseline note at line 5301, which explains why a mutation baseline had to be re-measured across a pin move. That reasoning was true when written; the mechanism it names was replaced here.
U-539 — a comment that outlived its decision, and #216's last open question
.cargo/mutants.toml spent a paragraph explaining why handlers/context.rs was excluded
from the mutation scope. The glob list three lines below it had included that file since
U-504. The prose also pinned the scope at 74 mutants while the workflow that consumes it
pinned 213.
Measured, not inferred (cargo mutants --list, at 60b08b7): 213 = context.rs 139 +
log.rs 65 + receipt.rs 9; workspace 1427 under --no-config; and --file unions with the
globs rather than replacing them (213 + admin.rs 49 = 262 exactly).
The instructive part: the stale number was correct for a configuration that no longer
existed. Deleting the context.rs glob reproduces exactly 74. So the failure was not a
miscount but a comment that kept describing a tree it no longer matched — and the arithmetic
that looked like it would recover the truth (74 + 134) gives 208, because context.rs had
grown to 139 in the meantime. Every figure in that file now carries the command that
reproduces it.
#216 item 4 — keep the substring guards or retire them — is settled: KEPT. The reasoning
and the three measured grounds are in DECISIONS.md. The measurement that decided it:
- over 65
handlers/log.rsmutants run against the pinned spec, 17 distinct tests reddened and the two substring guards reddened zero times — while provably running in 41 of those runs (= 40 caught + 1 missed; the 24 unviable never compile); - conversely, three falsifications turned them red on things no
src/mutation can express: a body gutted to{}, a golden gutted toassert!(true), and aPARTIAL_DIRECTfamily also listed inCOVERED.
That third one is the load-bearing find: partial_direct_test_functions_are_present is not
purely a text search. It enforces table invariants an oracle structurally cannot see, so
"retire the substring guard" would have silently retired those as well.
Independent confirmation worth recording: the run's single survivor was exactly the
log.rs:131:18 == -> != mutant the workflow already enumerates as equivalent — a budgeted
entry re-derived rather than re-read.
U-545 — a guard is only as wide as the filter that sized it
U-542 (#309) stopped the SQLite sidecar leak in acdp-registry-server and, by existing, made the
class look closed. It was not. Two crates were still leaking, and the reason the second one was
invisible is the part worth keeping.
The census that sized U-542 used an acdp-* filename filter. The surviving sites call
tempfile::NamedTempFile::new() with no prefix, so their files land as .tmpXXXXXX and that filter
could never have counted them: 17,538 -wal + 17,538 -shm = 35,076 files, against 174,003
*-wal in the directory in total. The filter was not wrong about what it measured; it was silently
narrower than the question being asked of it. A tool default is a filter you never typed — and so
re-reading your own pipeline cannot reveal the omission, because the omission is not in anything you
wrote. The same failure arrived from the opposite direction moments later, when an ls -1 reading
omitted dotfiles and returned a clean-looking 0. What settled it was arithmetic over two independent
enumerations: 156,465 + 17,538 = 174,003, exactly.
The other crate was invisible for a structural reason. acdp-registry-core leaked from a
#[cfg(test)] module inside src/. No integration test can reach that — it is a different
compilation target — and the crate had no tests/ directory at all, so there was nowhere the defect
could have been caught and nobody listed it as a candidate. Meanwhile the starting list included
acdp-registry-pg, which has zero tempfile references and is Postgres-backed. The list was wrong
in both directions, which is the argument for re-deriving a class from the tree rather than
inheriting it.
Two mechanisms, because neither closes the class alone. The source scan keys on the call shape — a file guard bound and connected within ten lines — and contains no prefix anywhere, since a no-prefix call is the default form and therefore the most likely shape of the next instance. The runtime guard proves that shape actually cleans up, in the crate that had never had a test target.
Both were falsified separately, and that is the lesson that cost the most. The scan went red on
the real defect reintroduced in witness.rs, naming the exact file:line. On the strength of that
red I described both guards as working. The core guard did not compile — store.migrate() is a
trait method and its trait was not in scope — and had never executed once. cargo test -p <pkg> --test <name> compiles that package's target and nothing else, so a red from one guard is zero
evidence about a guard beside it in the same commit. It does not feel that way: a clean
falsification reads as "the unit is in good shape" rather than "this one assertion in this one binary
fires". A brand-new test file in a crate with no prior tests/ directory is the worst case, because
nothing in that package had ever proven its dev-dependencies or trait imports. cargo test --workspace --no-run costs one command and would have caught it.
The runtime falsification left exactly two files behind, -wal and -shm — not three. The guard
cleaned the parent and orphaned both siblings, reproducing on demand the 14-parents-vs-286,894-
sidecars signature that identified the defect in the first place. The leaked names were
.tmpFXt9XC-*: the blind spot, caught by name in the failure output.
Both guards carry their limits in their own doc comments, including that a source scan fails on a pattern being present and never on a correct-but-absent test.
U-554 — a release PR that breaks CI is a process defect, not an accident
The v0.1.4 release PR failed three checks that pass on main. All three fail on one assertion,
root_changelog_stays_a_pointer: the workspace is at 0.1.4 but acdp-registry-auth, -pg and
-webhook have no ## [0.1.4] section.
Three rules, any two of which are compatible. Every crate is version = { workspace = true }, so
a release bumps all eight. release-plz writes a changelog section only for crates with commits in
their own directory, and no configuration option exists to change that. The guard requires every
crate to document the version it ships. The three crates that fail are exactly the three with zero
commits since v0.1.3.
It recurs, and that is measured rather than predicted. In the previous window all eight crates had at least two commits, so every one got a section and the guard passed. It has simply never been exercised against "a crate did not change" — and it fires on every release where one hasn't.
Two traps worth keeping from the measurement itself. git tag -l 'v*' matches nothing in this
repo, because git_tag_name = "{{ package }}/v{{ version }}" puts the package first; anchoring a
range on that glob yields a degenerate range and a confident zero for every crate. The counts here
were cross-checked against each crate's own tag, and all eight v0.1.3 tags resolve to the same
commit, so the two anchors are equivalent.
The fix documents the non-change instead of narrowing the guard. A consumer upgrading auth 0.1.3 → 0.1.4 would otherwise find no record at all and could not tell "nothing changed" from "someone forgot". Narrowing would also have undone U-538's hardening of that same walk, which was written for precisely this neighbourhood.
Ordering mattered more than speed. release-plz does not force-push its branch: it opens a new timestamped branch and PR each run and abandons the old one — #278 and #286 are two PRs for the same version, the first closed in favour of the second. A hand-edit on the open PR would have been stranded on an abandoned branch and the replacement would have gone red again with nobody watching. So the fix lands on main and lets regeneration carry it.
Two testing notes that generalise. First, the fix's own PR is green at 0.1.3 and that green
proves nothing, because every changelog already carries its section — the real proof was reproducing
the failure at the PR head and clearing it there. Second, the script was tested twice: once
standalone and once extracted from the parsed YAML, because escaping differs between the two. It
did differ — a backtick pair inside a double-quoted string would have executed pr by command
substitution in the runner but never in the standalone copy.
Postscript — the linter was right about the family, not the line
actionlint's shellcheck pass rejected the generator twice with SC2016, "expressions don't expand
in single quotes", pointing at the two lines that write the stub's markdown code spans. The code was
correct — a backtick inside single quotes is a literal, which is precisely why single quotes were
used — so a # shellcheck disable=SC2016 would have been defensible and one line long.
It was the wrong fix, because the suppression would have covered the adjacent form too. In a
double-quoted string or an unquoted heredoc those backticks are command substitution, not text: a
later contributor adding another $VERSION to that paragraph would reasonably switch to ", and the
step — which runs with a push token against the release branch — would then execute
version = { workspace = true } and crates/<name>/ as commands. The warning named the family; this
line happened to be its safe member.
The fix costs the same and removes the family: hold the backtick in BT and pass it as a printf
argument, so it never sits inside a quoted string under any quoting style. actionlint goes from two
findings to zero, and the dangerous refactor becomes impossible by construction rather than merely
absent today.
Verification was done against the parsed YAML, not the file — this is the second defect in this
block that exists only after YAML parsing, the first being a backtick that would have invoked the pr
paginator. The extracted script was re-run against an eight-crate fixture (six missing a 0.1.4
section, two already written by release-plz): six stubbed, two skipped, and the generated prose
asserted byte-identical to before the change. A canary defining version() and crates() as failing
shell functions stayed silent, which is what makes "nothing executed" a measurement rather than a
reading of the diff.
#371 — re-pinning the mutation ratchet after #341, and a ledger that was stale since #331
The 2026-09-28 scheduled mutants.yml run (36417581090, sha 5a6dfd8) failed. The cause was
not a survivor. #341 removed code from publish_identity_proven_offline, and with it five
CAUGHT mutants: context.rs went 139 -> 134 and the scope went 351 -> 346, while log.rs (65),
receipt.rs (9) and store.rs (138) did not change. The same edit moved three cited lines up by
32: if !matches.is_empty() from :1238 to :1206, rec.expires_at > Utc::now() from :642 to
:600, and the budgeted timeout if !should_refill from :1292 to :1260. The run itself was
complete: 346 / 214 caught / 7 missed / 124 unviable / 1 timeout, with end_time present,
in 143.7 minutes against the 180-minute cap. Its missed.txt holds the same seven mutants
the list already named. Diffed against the u552 ledger by name with line:col stripped, the
change is -5 caught and nothing else.
The re-pin uses that run's own report. The outcomes.json is committed unchanged as
docs/mutation-runs/run36417581090-scope-346-outcomes.json and is now
MUTANTS_PRIOR_LEDGER. MUTANTS_SURVIVORS is that run's missed.txt verbatim, with the
existing reasons kept, and MUTANTS_EXPECTED_SCOPE is 346. u552 is marked SUPERSEDED, not
VOID, because it was a correct measurement of its tree.
The finding worth keeping: the prior ledger was already stale before #341. u552 records
the store.rs survivor at store.rs:1306:35. MUTANTS_SURVIVORS has said store.rs:1317:35
since #331 (5cceb1a), when U-574's comment rewrite moved the line and the list was updated
without the ledger. The classifier's freshness gate (classify_removed_survivors.py) would
have refused to pair any drifted line against u552. Replaying it locally gives
STALE PRIOR LEDGER: 3 of 7 (the two #341 moves plus the #331 one). Nothing caught it at PR
time, because that gate only runs inside the scheduled job, and only when a survivor
disappears.
So this unit moves the same rule to PR time. every_committed_survivor_is_in_the_prior_ledger
(crates/acdp-registry-server/tests/conformance_gate.rs) parses MUTANTS_SURVIVORS with the
workflow's own strip rules and asserts that each line is a scenario.Mutant.name in the
declared ledger. Against u552 it fails and names store.rs:1317:35. It would have failed
#331. Scope count and line drift are still detected only by the cron. Catching them at PR time
needs a path-gated job that installs cargo-mutants and runs --list, which is follow-up work.
#373 — lifecycle state with [lifecycle] off: refuse to start, not strip on read
RFC-ACDP-0013 §6 says a registry that does not advertise acdp-registry-lifecycle MUST NOT emit
lifecycle_events or retracted. Both stores project that state on every read path with no flag
check, so a deployment that enabled lifecycle, retracted something, then turned the flag off served
it while advertising neither. CONFIGURATION.md documented it as a deviation.
Two fixes were on the table. Threading the flag into every read path and stripping (about ten paths
per backend, plus the SDK's own current/search, which read store status) was rejected on cost,
and more because it silently un-retracts: withdrawn contexts would come back as active, searchable
/current heads, which reverses a withdrawal nobody authorised. The binary now asks the store at
startup, after migrate() and before binding, and refuses to start in that one combination, naming
both ways out: re-enable the flag, or purge (irreversible, documented with SQL).
The probe is a defaulted ExtendedRegistryStore::has_lifecycle_state (default Ok(false), for the
memory backend, which carries nothing across a restart), overridden by SQLite and Postgres. It is
called once, at the top of the generic serve_with_store, so all three backends go through one call
site. It checks both sources. A republished context has retracted = 0 but keeps its events, so
checking the flag alone would miss it. A flag set with no event row (a partial restore, hand-edited
SQL) is missed by checking the events alone. A test pins each source on its own.
tests/lifecycle_refuse_start.rs does the real restart: it spawns the shipped binary with the flag
on, publishes and retracts over HTTP, restarts it with the flag off (exits non-zero, ERROR logged,
nothing listening), then with the flag on again (GET, status=retracted search, lineage, and the
/current 404 are all unchanged). With the gate disabled, the flag-off child keeps running and the
test fails at that step.
2026-10-04 — mutants ratchet re-pinned at scope 358 (final re-pin of issues #371-#376)
Run 37222567772 on main 27f9875 measured the post-#373/#374/#375/#376 tree: 358 mutants, 225
caught / 7 missed / 125 unviable / 1 timeout, 108.7 min (71 min under the 180 cap). The scope
had grown 346 -> 358 (context.rs 134 -> 144, store.rs 138 -> 140; log.rs 65 and receipt.rs 9
unchanged), all of the twelve caught or unviable, and the seven survivors are the same mutants
on new lines (context.rs :83/:84/:609/:1222, log.rs :132, store.rs :1007/:1330; the timeout is
context.rs :1258). The run's outcomes.json is committed byte for byte as
docs/mutation-runs/run37222567772-scope-358-outcomes.json, MUTANTS_SURVIVORS is its
missed.txt verbatim, and MUTANTS_EXPECTED_SCOPE is 358.
Approach: re-pin from the finished run rather than dispatch a fresh one (about 1 h 50 m),
which is valid only because every later edit to the four scoped files was a line-neutral
comment change (context.rs stayed 2324 lines). Checked on the merge base: cargo mutants --list produced exactly the run's 358 names, and git diff of the four files since 27f9875
held only // lines. The whole-workspace denominator (1427 at a66e1f2) was not re-measured.
2026-10-05 — mutants ratchet re-pinned at scope 354 (final close-out)
Run 37248684286, a workflow_dispatch on main 09615e9, measured the post-#401 tree: 354
mutants, 221 caught / 7 missed / 125 unviable / 1 timeout, 101.2 min on cargo-mutants' own
clock (101.5 min job wall clock, 79 min under the 180 cap). The job failed only because the
pins still said 358. #401 replaced #375's lifecycle charge pre-flight with the SDK's
prove/commit split, and context.rs went from 144 to 140 mutants (log.rs 65, receipt.rs 9 and
store.rs 140 unchanged). Diffed against the run37222567772 ledger by name with line:col
stripped, the change is purely subtractive: four caught mutants left and none arrived. They
are lifecycle_actor_signature_verifies -> bool with true and with false (the function
is gone), the pre-flight's && -> || in lifecycle_transition, and one
delete match arm LifecycleEventType::Retracted (the separate did:key and did:web
retract/republish dispatches became one LifecycleEndpoint match). The seven survivors and
the one timeout are the same mutants on the same lines, so MUTANTS_SURVIVORS is unchanged
apart from its comments. The run's outcomes.json is committed byte for byte as
docs/mutation-runs/run37248684286-scope-354-outcomes.json, and MUTANTS_EXPECTED_SCOPE is
354.
The run is valid for current main because the four scoped files are identical between
09615e9 and fdd156a (empty git diff). It already includes the schema-validation
conformance tests from #400, which W2 projected at about 1 s per mutant and a worst case of
about 13% cap headroom. The measured headroom is 44%. The whole-workspace denominator (1427
at a66e1f2) was not re-measured.