Deterministic Byzantine-robust aggregation for federated learning and distributed systems, with verifiable receipts.
Aggregate vectors from mutually distrusting parties. Get the same bytes on every machine, and a receipt any third party can re-check offline.
use acfa_aggregate::{krum_aggregate, Contribution};
let agg = krum_aggregate(&contributions, 1)?; // f = 1 tolerated adversariesMutually distrusting parties -- each on whatever hardware they run -- submit signed updates. ACFA combines them with a robust rule so an adversarial minority cannot move the result, computes the aggregate as an exact function of the input set, and emits a receipt. Anyone, on any architecture, can re-run that receipt offline and get the identical bytes -- so the answer to "who moved the aggregate" is checkable rather than asserted.
flowchart LR
P1["Party 1 (x86_64)"]
P2["Party 2 (ARM64)"]
P3["Party 3 (big-endian)"]
PA["Adversary, up to f of n"]
P1 --> AGG
P2 --> AGG
P3 --> AGG
PA -. poison attempt .-> AGG
AGG["ACFA robust aggregation<br/>integer fixed-point (Q16.16)<br/>Krum / Bulyan / median / trimmed mean"]
AGG --> R["Aggregate + receipt<br/>byte-identical, signed"]
R --> V["Any third party, any architecture<br/>re-checks the receipt offline"]
V --> OK["Same bytes = verified:<br/>what was aggregated, and who equivocated"]
Robust aggregation rules (multi-Krum, Bulyan, coordinate median, trimmed mean) already exist in most federated learning frameworks. They bound how far a minority of adversaries can move your result. They do not tell you who moved it.
That gap is arithmetic, not cryptography. Float aggregation is order-dependent: sum the same updates in a different order and you get different bytes. So when two servers report different aggregates, you cannot distinguish a faulty server from a lying participant. The honest case is already ambiguous, so no accountability layer can sit on top of it.
ACFA computes the aggregate in integer fixed point (Q16.16), making it an exact function of the input set. After that, any disagreement is a difference in inputs or in conduct.
Identical glibc 2.41, x86_64 vs aarch64, 600,000 samples:
| divergences | |
|---|---|
raw libm (exp, cos, ln) |
293 (~1 in 2,047), each exactly 1 ULP |
| same values through the Q16.16 boundary | 0 |
The second row is not evidence that the boundary absorbed the first, and the guarantee does
not rest on it. On this corpus the right-hand column reads 0 whether or not quantisation
absorbs anything: an encoding changes only if the scaled value lies within the divergence of a
rounding boundary, the closest sample sat 20,472 ULPs away, and 0 of 600,000 were close
enough for a 1-ULP difference to change the quantised result. One expected flip would need about
6.9e10 samples, roughly 114,000x this corpus. Absorption is also the wrong word for what would
happen: at a rounding boundary a 1-ULP divergence is not absorbed but amplified to a full
Q16.16 unit -- rare and total rather than damped. Measured through the shipped kernel, on two
doubles one ULP apart that straddle a boundary:
0x3edfffffffffffff scaled 0.49999999999999994 -> ok 0
0x3ee0000000000000 scaled 0.5 -> ok 1
A one-ULP difference in, a full Q16.16 unit out. So the table's juxtaposition invites the reader toward the opposite of the mechanism.
What the two rows do show is the thing worth showing: raw libm differs across architectures, and these 293 differences did not reach the aggregate here. The guarantee itself comes from construction, not from this null result -- the aggregate is computed in integer fixed point, so it is an exact function of the input set however the inputs were produced.
Numbers and method in build/DETERMINISM-RESULTS.md; the
smaller first probe that motivated it is in
research/xarch-libm-divergence.md.
CI blocks any push where eight architectures fail to produce a byte-identical receipt: x86_64 Linux, aarch64 Linux, Apple Silicon and x86_64 Windows on real hardware, plus i386 and armv7 (32-bit), ppc64le, and s390x (big-endian) under emulation.
The big-endian row is the one that matters. A machine that lays out integers the other way
round is where byte-identity breaks if it is going to. x86_64 little-endian and s390x
big-endian produce the same SHA-256 over the full five-scenario fingerprint,
4664c321388267507c825b8e1b5ef6c2c082879bb871d2c0fff557d514b2fedf. Method and reproduction in
build/ARCHITECTURE-COVERAGE.md.
flowchart TB
IN["One input set<br/>(order-independent)"]
IN --> A1["x86_64 Linux"]
IN --> A2["aarch64 / Apple Silicon"]
IN --> A3["Windows x86_64"]
IN --> A4["i386 / armv7 (32-bit)"]
IN --> A5["ppc64le"]
IN --> A6["s390x (big-endian)"]
A1 --> H["Identical SHA-256 receipt<br/>4664c321...14b2fedf"]
A2 --> H
A3 --> H
A4 --> H
A5 --> H
A6 --> H
H --> CI["CI blocks any push where<br/>the 8 targets disagree"]
Releases are signed annotated git tags vMAJOR.MINOR.PATCH on main, each with a matching
GitHub Release. From v0.3.0 the same version appears in all three Rust crate manifests and in
the Python adapter, so Cargo.toml identifies the release; before v0.3.0 it did not, and
CHANGELOG.md records that caveat rather than quietly correcting it.
| Version | Date | What it is |
|---|---|---|
| v0.4.0 | unreleased | Closes the cross-context forgery (#79): the signed preimage now binds the context and node id (ACFA-R1 → ACFA-R2). First release in which the fingerprint moves; v1 receipts still decode and verify |
| v0.3.0 | 2026-08-20 | Ships the paper's Lemma 12 no-flip certificate; redacted receipts (zero-plaintext audit artefact); a caller-supplied verifier work budget; round retirement to conviction witnesses |
| v0.2.0 | 2026-08-19 | First production-signed release; full audit closed; adapter published to PyPI |
| v0.1.0 | 2026-08-17 | First public tag |
v0.4.0 IS NOT RELEASED. The crate manifests declare 0.4.0 and the section above describes
it, but no v0.4.0 tag exists on the remote — git ls-remote --tags returns v0.1.0, v0.2.0,
v0.3.0. A build of current main is therefore not a released artefact and cannot be verified
as one by the process described above. Tracked as #100.
To identify a build, check the fingerprint, not the version string.
cd build/layer2-receipt && cargo run -q --release --example digestThe comparable digest is 4664c321388267507c825b8e1b5ef6c2c082879bb871d2c0fff557d514b2fedf on
every supported architecture, including big-endian s390x. It changes only on a deliberate,
documented wire-version bump, and has done so exactly once: v0.1.0–v0.3.0 printed
bd13ba3209a940b2025368a63c546ffd59e2580a1b8aa7128cc9b423d1957e40, and v0.4.0 moved it by
binding the context and node id into the signed preimage (ACFA-R1 → ACFA-R2). A value
matching neither means you are not running a released tree.
Full history, including what is still open at each release: CHANGELOG.md.
What the format promises, and what it does not: COMPATIBILITY.md.
The Rust crates are not published to crates.io -- install them from this repository (below). The Python adapter is published to PyPI as acfa-flower.
Rust 1.87+ (is_multiple_of). Layer 1 has zero dependencies.
The CLI tools, no checkout needed:
cargo install --git https://github.com/mgillr/acfa-rs acfa-aggregate # acfa-agg
cargo install --git https://github.com/mgillr/acfa-rs acfa-receipt # acfa-verifyAs a dependency. Cargo finds each crate by name inside the repository, so git alone is
correct here; adding path alongside it is a manifest error:
[dependencies]
acfa-aggregate = { git = "https://github.com/mgillr/acfa-rs" }
acfa-receipt = { git = "https://github.com/mgillr/acfa-rs" }
acfa-finality = { git = "https://github.com/mgillr/acfa-rs" }Python (from PyPI):
pip install acfa-flower # the adapter + numpy
pip install "acfa-flower[flower]" # add the Flower strategy (pulls flwr)The adapter shells out to acfa-agg for the byte-identical kernel, so install the CLI too
(the cargo install above) and it is found on PATH; set ACFA_AGG_BIN to point at a specific
binary. There is no pure-Python fallback: a second implementation could silently disagree,
which is the failure the fixed-point kernel exists to remove.
To install the adapter straight from the repository instead (e.g. an unreleased commit):
pip install "git+https://github.com/mgillr/acfa-rs#subdirectory=adapters/flower"git clone https://github.com/mgillr/acfa-rs
cd acfa-rs/build/layer2-receipt
cargo run -q --release --example issue -- --pki > trusted.pki
cargo run -q --release --example issue > receipt.acfa
cargo run -q --release --bin acfa-verify -- receipt.acfa --pki trusted.pki --f 1VERIFIED
rule Krum
NOT PINNED -- verified against the receipt's OWN claimed rule; pass --rule to require the rule you expect
context 0000000000000000000000000000000000000000000000000000000000000000
NOT PINNED -- verified against the receipt's OWN claimed context; pass --ctx to require the event you expect
frac bits 16
round 1
state root 31a584d8788f4b52cf47b4db208a96098e71ff5152306d34ff2ae42929562071
output root 8d43057fcae20ef0a765604b6a212225236b6d25cc51452b8a15a2a24ffc9a14
aggregate 3 values, first [4, 2, 5]
admitted [1, 2, 3, 4, 5]
convicted []
bound n>=req met (5 admitted, 5 required) -- POPULATION only, not a safety verdict
no-flip NOT CERTIFIED -- the selection boundary is too close
margin 0 <= threshold 392 (4 x beta 98)
The boundary is an EXACT TIE. No amount of extra
resolution closes it -- the irreducible residual.
This is NOT evidence of a flip. It means quantisation
COULD have changed who was selected, and the margin
condition cannot rule it out. An exact tie (margin 0)
is the residual no condition can ever cover.
Checked against the identities in trusted.pki and f = 1.
This establishes that the issuer computed honestly over the set shown.
It does NOT establish that the issuer showed every entry it held --
compare the state root against an independently obtained one for that --
pass it as --expect-state-root <64-hex> and a mismatch exits 1.
Meeting the population bound is NOT a safety guarantee. A colluding
within-norm adversary can be selected while the bound holds and move
the aggregate; that is a property of the imported rule. See the
within-norm characterisation in the aggregation crate's test suite.
That block is the tool's real stdout, pasted from a run of the three commands above, not a
summary of it. Three things in it are easy to misread as errors and are not. rule and
context say NOT PINNED because you passed neither --rule nor --ctx: the verifier checked
the receipt against the rule and event the receipt ITSELF claims, which is self-consistency,
and pinning them is how you turn that into a check against what you expected. no-flip says
NOT CERTIFIED because this example's five contributions put the Krum selection boundary at an
exact tie (margin 0), and no margin condition can clear a tie -- it is not evidence that a
flip happened. The exit code is still 0.
The roots are fixed, not per-run: the issue example mints its keys from a seed, so
regenerating trusted.pki and receipt.acfa reproduces the same receipt and the same two
roots. Measured over three regenerations here.
--pki is required. A receipt carries its own identity set, so checking it against itself
proves nothing: mint five keys and every signature in the resulting receipt is valid.
cargo run -q --release --example issue -- --forged-pki > forged.acfa
cargo run -q --release --bin acfa-verify -- forged.acfa --pki trusted.pki --f 1 # exit 1
cargo run -q --release --bin acfa-verify -- forged.acfa # exit 3Exit codes: 0 verified, 1 invalid, 2 unparseable, 3 self-consistent only.
use acfa_aggregate::{krum_aggregate, bulyan_aggregate, coord_median_trim, Contribution};
let cs = vec![
Contribution { tie_key: b"node-1".to_vec(), v: vec![65536, 131072] }, // Q16.16
Contribution { tie_key: b"node-2".to_vec(), v: vec![65600, 131000] },
];
let agg = krum_aggregate(&cs, 1)?; // needs n >= 2f+3
let agg = bulyan_aggregate(&cs, 1)?; // needs n >= 4f+3, refuses belowtie_key breaks exact score ties. It must be stable per contributor and is never
interpreted. Out-of-range and non-finite values are refused, not saturated.
Averaging floors, so the aggregate is biased DOWNWARD and the bias accumulates. Every
rule that averages loses a fraction of an LSB per round, always in the same direction:
(n-1)/2n LSB, measured at 400 trials per row against the closed form (n=2: -0.231,
n=5: -0.396, n=16: -0.472). Round-to-nearest would cancel over many rounds; floor does
not, so over training it drifts linearly rather than averaging out. This is deliberate and
not tunable here -- the rounding rule is wire contract, because the vendored Python
reference floors (//) and byte-identity with it is the property this kernel exists to
provide. Round-half-to-even would be both deterministic and unbiased, and changing to it
costs the reference pin. Sizes and the drift-over-rounds table:
adapters/flower/README.md.
Did fixed-point arithmetic change WHO was selected? Byte-identity says every replica computed
the same selection; it does not say that selection is the one un-quantised gradients would have
produced. multi_krum_certified answers that per round, checkably:
use acfa_aggregate::multi_krum_certified;
let (selection, cert) = multi_krum_certified(&cs, 1)?; // same selection as multi_krum
if let Some(c) = cert {
println!("no-flip certified: {} (margin {} vs {})", c.certified, c.margin, c.threshold);
}This is Lemma 12 of the paper in its observable form. In raw Q16.16 units the grid step is exactly
1, so it reduces to integer arithmetic -- delta_star = 2*l1_max + 3*d,
beta = (n-f-2)*delta_star, certified iff margin > 4*beta -- no float anywhere, so two replicas
that agree on the selection agree on the certificate bit for bit.
Sound, and in the regime that matters it fires rarely. It can decline to certify a stable
configuration but can never certify an unstable one, and an adversary inflating a contribution
enlarges beta, making certification harder -- hostile input withholds a certificate and cannot
forge one. But beta is a worst case assuming every one of the d coordinates errs maximally and
in the worst direction, so at high dimension with clustered contributions -- the realistic
federated case -- the measured certification rate is approximately zero. A tighter probabilistic
rule is open work. None means no certificate is available (empty round, kernel refusal, the
select-all band, or Bulyan) and is not the same as "not certified".
Two tracked limits: the shipped 4*beta is twice what soundness requires (a direct argument on
the observed gap gives 2*beta; the looser constant ships because the code must not enforce a
threshold the published lemma does not state), and because l1_max is a global maximum one
large-magnitude outlier raises beta for the whole round and can deny certification to everybody
-- availability-only, never forgeable (#72).
Receipts:
use acfa_receipt::{Receipt, Policy, Rule, State};
let receipt = Receipt::issue(&state, ctx, round, &pki, f, Rule::Krum);
let bytes = acfa_receipt::encode_checked(&receipt)?; // refuses an f the wire cannot carry
// Verification needs a policy you obtained independently, not the receipt's own.
let verified = receipt.verify(&Policy::new(trusted_pki, f))?;
println!("{:?} admitted, {:?} convicted", verified.admitted, verified.convicted);Verification is bounded by a work budget, and it is fail-closed. Verifying does work
proportional to the total tensor coordinates carried, and neither the contribution-count cap nor
the derivable-proof bound bounds that product -- measured, a 32 MiB receipt bought 16 s of CPU and
returned Ok with every guard passing. Policy carries a coordinate budget defaulting to
DEFAULT_MAX_VERIFY_COORDINATES, and the refusal names the count it declined, so the number to
raise is in the error:
let policy = Policy::new(trusted_pki, f).with_max_coordinates(4_000_000);check_self_consistent takes the same default -- diagnosis is still a door that accepts a file
from a stranger.
Redacted receipts: full accountability, zero plaintext. A receipt carries every participant's
raw update, which is what makes it re-executable and also why it cannot be shown to anyone the
participants do not already trust with their gradients. redact() drops the vectors and keeps
everything that authenticates or commits:
let redacted = receipt.redact();
let bytes = acfa_receipt::wire::encode_redacted(&redacted); // distinct magic, ACFA-X2
let v = redacted.verify(&Policy::new(trusted_pki, f))?; // same admitted set, same convictionsLossless for verification, because a signature is over contrib_msg(ctx, params, rnd, node_id, tensor_hash) and a leaf
hashes the tensor hash, never the tensor -- and EquivProof was already plaintext-free. So
authentication, the state root, admission and conviction all survive at full strength with the same
answers. What it cannot do is re-execute the aggregate, which genuinely needs the vectors.
This is redaction. It is NOT secure aggregation and NOT differential privacy. No formal privacy
guarantee: it withholds vectors from the artefact while the aggregator saw everything, and
tensor_hash is a binding commitment, not a hiding one, so a recipient who can guess a plausible
update confirms it by hashing. A fixed 32-byte hash replaces 4 + 8d tensor bytes, so the artefact
shrinks only for d >= 4.
Retiring settled rounds. State::merge bounds the live contribution set, so a replica that
never retires anything reaches that bound as a function of elapsed rounds and then fails every
later merge permanently. prune_through(round) retires a round's contributions to conviction
witnesses -- (ctx, params, rnd, node_id, tensor_hash, sig), about 149 bytes -- exactly enough to still detect
and still prove equivocation, so detection strength is unchanged rather than traded. It bounds the
constant, not the growth: retention is still linear in rounds.
from acfa_flower import AcfaStrategy, Rule
strategy = AcfaStrategy(rule=Rule.KRUM, f=1, min_fit_clients=5)Drop-in for FedAvg as wiring. Sampling, config and evaluation are inherited.
num_examples is ignored: FedAvg weights by it, it is an unverifiable self-report, and
weighting a robust rule by it hands back the guarantee.
It is not a drop-in for FedAvg's BEHAVIOUR on non-IID data. Every robust rule here
selects by distance from the other clients, and a client whose data comes from a different
distribution is far from the majority for the same reason an attacker is -- the rule cannot
tell them apart. Measured with zero adversaries, one client drawn from N(3,1) and the
rest from N(0,1), KRUM retains 3% / -0% / 0% of that client's proportional share at 10 /
20 / 40 clients, against MEAN's ~100% control; the effect does not wash out as the cohort
grows. That is inherent to distance-based robust aggregation rather than a defect here, but
it changes who the model learns from. Full table and the milder rules:
adapters/flower/README.md.
This adapter suits models up to roughly a hundred thousand parameters. Values cross to the kernel as hex ASCII over stdin -- a structural 2.13x wire cost, arithmetic on the format rather than something to tune away. Measured at 30.9 ms per 1,000 parameters with n=10 clients, that extrapolates to ~6 minutes per round for ResNet-18 (11.7M params) and ~57 minutes for BERT-base (110M), and the cost grows with the client count too. The Rust kernel itself is fast and the determinism guarantee is unaffected -- this is the text boundary, not the aggregation. Above that size, call the kernel directly instead of through this adapter. Measurements: adapters/flower/README.md.
Direct call:
from acfa_flower import aggregate, Rule
result = aggregate(client_updates, rule=Rule.KRUM, f=1, tie_keys=client_ids)$ printf 'rule krum\nf 1\n01 3ff0000000000000\n02 3ff199999999999a\n03 3ff3333333333333\n04 3ff4cccccccccccd\n05 4024000000000000\n' | acfa-agg
ok 75366Values cross as IEEE-754 bits, output is Q16.16. rule is one of krum, bulyan,
median, trimmed, mean.
Relevant if you are building on async gossip with no coordinator:
Order invariance. The aggregate is a function of the multiset, not the delivery sequence. Replicas that received the same contributions in different orders, with duplicates, compute identical bytes. No agreement protocol needed to get agreement on the result.
Content-addressed state. Contributions and equivocation proofs are a grow-only product CRDT -- an OR-Set crossed with a G-Set -- merging by union over Merkle-addressed leaves. Merge is commutative, associative and idempotent, so the state converges without ordering.
Attribution. An identity that signs two different values for the same round produces a self-authenticating proof. Any node holding the PKI verifies it offline, with no quorum and no appeal to who reported it. Honest nodes derive the proof themselves on observing both halves, so suppression does not work.
Fail-visible finality (acfa-finality). A round certificate is f+1 signatures over
the admitted membership, equivocation cut and aggregate root, with membership pinned by
authenticated broadcast rather than a wall clock. If the synchrony bound breaks, or the round
budget is under-provisioned below 2 tau while it holds, two disjoint honest groups can each
certify a different cut. At n >= 3f+2 that requires no Byzantine participant, so nobody is
attributable and a naive design fails silently. Here the fork is the evidence: two valid
conflicting certificates cannot coexist under the assumption, so their coexistence proves it
broke. Nodes halt, publish the pair, reconcile from the last uniquely-certified round.
Every command below was run against this tree.
Federated learning with an audit trail. Sites train locally, share only updates, raw data never moves. Later a regulator asks which contributions produced the deployed model; the receipt answers from the artefact alone.
cd build/layer1-aggregate && cargo run -q --release --example uc1_poisoned_flhosp-E [-45.0, 60.0, -30.0] <-- compromised
FedAvg (plain mean) [-8.278, +12.318, -5.916]
multi-Krum (ACFA) [+0.905, +0.395, +0.105]
Multi-party model merging. Several organisations merge deltas and each must sign off on one result. Each reproduces the same bytes independently.
cd build/layer2-receipt && cargo run -q --release --example uc2_merge_orderReproducible robustness research. New aggregation rules are compared across papers whose implementations quietly disagree. Here the Python reference and the Rust production code are held to byte-identical agreement in CI, so a new rule ships with golden vectors and the claim is machine-checked.
python3 build/layer2-receipt/tests/golden/generate_l2.py | diff - build/layer2-receipt/tests/golden/vectors_l2.json && echo identicalCross-language pipelines. A Python researcher produces an aggregate a Rust deployment reproduces exactly, because both are held to the same vectors.
Fork diagnosis. When a decentralised run ends in two conflicting states, acfa-finality
gives the round it happened in and the two signed certificates proving it, including the case
where no participant misbehaved and timing simply broke.
Offline audit. An auditor with no access to any participant re-derives the figure from the receipt. No network, no clock, no live system.
Not implemented here: merging receipts across independently operated instances, and paying for verified contributions on-chain. Both follow from the primitives; neither is in this repository.
GitHub runner, single-threaded, cargo run --release --example scale:
| participants | dimension | receipt | verify |
|---|---|---|---|
| 10 | 100 | 9.8 KiB | 1.5 ms |
| 100 | 100 | 90 KiB | 21 ms |
| 25 | 10,000 | 2.0 MiB | 111 ms |
Verify costs roughly what issue costs: it re-executes the rule instead of checking a claimed
answer. Multi-Krum is O(n^2 d), so participant count dominates, not dimension.
- A valid receipt proves honest computation over the set it showed you. It does not prove the
issuer showed you everything it held. Compare the state root against an independently
obtained one for that:
acfa-verify --expect-state-root <64-hex>performs the comparison and exits 1 on a mismatch. This is the only check that addresses withholding -- a withheld entry leaves no trace in the receipt that omits it, so the root has to come from elsewhere. n >= 2f+3is a population bound, not a safety guarantee. At n=10, f=3, where it holds, a colluding adversary near the honest mean is selected in 30/30 trials and moves the aggregate 1.74x past the honest floor while staying inside the honest spread. Bulyan does not help (1.56x vs Krum's 1.61x at n=15) and refuses belown >= 4f+3;coord_median_trimis worse at 2.21x. Property of the imported rules. The field is namedpopulation_bound_metaccordingly. Seebuild/layer1-aggregate/tests/within_norm.rs.- The Lemma 12 no-flip certificate can be denied by a single participant, though never
forged by one.
betais scaled by the largest pairwise L1 distance in the admitted set, so one contribution with large raw magnitude enlarges the threshold for the whole round and can push every configuration out of the certified tier -- for everybody, not just itself. This is availability-only: inflating that distance can only make certification harder, so a hostile input withholds a certificate and can never produce a false one. The obvious fix -- scoping the maximum to the pairs that actually enter the boundary scores -- is unsound, because a Krum score is a minimum over m-subsets and the perturbation is free to change which pairs those are, so the bound must cover every candidate pair. Documented rather than patched, tracked as issue #72. - The shipped certification threshold is the paper's
4*beta_hat, which is twice what soundness requires: a direct argument on the observed gap gives2*beta_hat, verified and searched over 1.78M preimages in the contested band with zero flips. The looser constant ships because the code must not enforce a threshold the published lemma does not state; the tightening is an erratum candidate against the paper. How much it would actually gain is distribution-dependent and was measured, not assumed: between 1.00x and 11.75x depending on the margin distribution, and NO GAIN AT ALL (0.0% -> 0.0%) in the realistic high-dimensional clustered case. An earlier version of this note said it "doubles" the certification rate; three people repeated that and none of us had measured it. - Q16.16 fixes range at +/-2^15, resolution 2^-16. Out-of-range values are refused, not saturated, because saturation would make the result depend on which replica saturated first. Rescale upstream with a factor both parties hold.
wire::encodeis infallible by signature and writes the fault boundfas au32, so ausizebeyond2^32truncates silently and the bytes decode to a different receipt. Usewire::encode_checked, which refuses instead.encodeis kept unchanged because 33 call sites depend on its signature; closing that is an API decision, not a defect fix.- Sybil resistance is delegated to the PKI.
- Krum is Euclidean and admits coordinate-concentrated attacks inside the honest spread.
Bulyan defends that shape specifically, at
n >= 4f+3. - No independent security review. Three bugs were found and fixed during development: a receipt that verified against its own carried identity set, an unbounded allocation from an attacker-controlled length prefix, and a default tie-break key derived from arrival order. See SECURITY.md.
Byzantine Accountability Without Consensus
(arXiv:2607.10305, July 2026). Copy in paper/acfa.pdf, citation metadata
in CITATION.cff.
The reference implementation is vendored at reference/acfa.py with its
hash pinned. CI regenerates both layers' golden vectors from it and requires byte-identity, so
the Rust is checked against a second, independently written implementation.
One divergence is deliberate, and it is worth stating precisely because it changes the
selected SET, not merely a count. The reference's Bulyan stage-1 loop draws at most n-f-2
candidates while theta = n-2f; those differ exactly when f < 2, so at f in {0, 1} the
reference stops one or two candidates short of its own theta. A shorter selection is not just
fewer indices -- it is a DIFFERENT candidate set feeding stage 2, so at f = 1 this
implementation and a paper-faithful clamped selection admit different members, not only a
different number of them (measured to differ on a substantial fraction of random f = 1
trials). This implementation refuses below n >= 4f+3 and otherwise draws exactly theta; the
suite asserts the divergence and asserts it is still present, so a corrected reference fails
CI. No result in the paper is affected: its one Bulyan experiment runs at n = 16, f = 3,
where the two agree, re-run unmodified to its published numbers.
build/layer1-aggregate aggregation rules, fixed point, zero deps, acfa-agg binary
build/layer2-receipt signatures, equivocation proofs, wire format, acfa-verify binary
build/layer2-finality round certificates, deadline cut, halt-and-reconcile
adapters/flower Flower strategy
reference/ the paper's reference implementation, hash-pinned
Layer 1 decides what a set of vectors aggregates to. Layer 2 decides who is in the set, and passes contribution leaves to Layer 1 as an opaque tie-break key. The coupling runs one way, so Layer 1 is usable alone.
CONTRIBUTING.md. Claims carry the command that produced them and its output.
Apache-2.0. No patents filed or planned -- and the licence's section 3 patent grant makes that binding on every contributor rather than a statement of intent.
acfa-aggregate has no dependencies. acfa-receipt and acfa-finality use Ed25519
via ed25519-dalek, curve25519-dalek and subtle, which are BSD-3-Clause: their
notices are reproduced in NOTICE, which you must keep when redistributing
a built binary. Nothing in the transitive tree is copyleft.
build/layer1-aggregate the deterministic kernel, zero dependencies
build/layer2-receipt contribution set, equivocation proofs, receipts, acfa-verify
build/layer2-finality round certificates, deadline cut, halt-and-reconcile
adapters/flower Flower strategy, shells out to acfa-agg for identical bytes
reference/acfa.py the paper's kernel, vendored and hash-pinned
paper/acfa.pdf arXiv:2607.10305
tools/ checks that hold the documentation to the code
Everything here is checked on every push, and you can run the same checks:
cargo test --release # in any of the three build/ crates
python3 tools/readme-commands.py # runs every command this file documents
python3 tools/coverage-claim-check.py # holds the coverage table to the workflow
shasum -a 256 -c SHA256SUMS # in reference/, pins the vendored kernelThe first two exist because a reader runs the commands before they run anything else.
tools/readme-commands.py executes every command this file documents against a real git
remote: the dependency block as an actual manifest, both cargo install lines, the pip
install, and the quickstart's exit codes, plus a check that every path a documented command
touches is present. tools/coverage-claim-check.py does the same for the architecture
table, requiring each row it claims to appear in the workflow. Documentation that does not
work is a build failure here, not a bug report.
History starts at the first commit of this tree. Working notes and screening material are not part of the release and are not carried over.