Mpc
Run a SPARQL query across multiple mutually-distrusting RDF data holders (federated MPC) — per-holder local sub-evaluation, crypto-free disclosed-key global-IRI joins, and an honest-majority Shamir secret-sharing backend for secure cumulative aggregates + hidden-value (private-key) joins. Use for confidential federated SPARQL / secret-shared aggregates / private set-intersection joins over RDF. EARLY/RESEARCH, native-only; honest-majority with RS-consistency-checked reconstruction (tamper DETECTION + abort, and robust correction where redundancy allows) — semi-honest is the floor when no redundancy exists (e.g. the degree-2t equality open at n=2t+1, detection-only); NOT dishonest-majority / not full malicious security. Collaborative ZK proof of correctness+attestation is a stub.From its SKILL.md
npx -y skills add sparq-org/sparq --skill mpcAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
82.3 KB, as published. Nobody here has run it
sparq-mpc — MPC over federated SPARQL
sparq-mpc lets a set of mutually-distrusting holders, each owning private RDF named graphs, jointly answer ONE SPARQL query over the union of their data while minimising what each holder reveals. Today it delivers a real per-holder local evaluator, a crypto-free join on disclosed global IRIs, and an honest-majority Shamir secret-sharing backend that powers secure cumulative aggregates and a hidden-value (private-key) join. The reconstruction path is honest-majority with tamper DETECTION + abort — and robust correction where the (n, t) redundancy allows — over RS-consistency-checked opens; the degree-2t equality open is detection-only above n = 2t + 1, and semi-honest is the floor when a given configuration carries no redundancy. It is not dishonest-majority and not full malicious security; each backend reports its exact level via BackendInfo.malicious_security.
<!-- ANCHOR_END: scaffold-caveat -->Maturity (read first). EARLY / RESEARCH, native-only (deliberately not in the wasm build). The Shamir layer's reconstruction is RS-consistency-checked: it detects (and, with enough redundancy, robustly corrects) actively-tampered shares and reports the exact guarantee via
BackendInfo.malicious_security— but the degree-2tequality open in the hidden-value join is not hardened atn = 2t+1(see Gotchas). The primitives run as an in-process simulation (one process plays all parties) for correctness/cost counting, AND over a REAL multi-process loopback transport (transportmodule — each party its own process over127.0.0.1; all fourQueryClasscells incl. the multi-round oblivious shuffle/sort run on the wire) for MEASURED wall-clock + bytes-on-wire. The collaborative ZK proof of correctness + issuer-attestation is not implemented: those methods returnMpcError::NotYetImplementednaming their gate. No fake crypto anywhere. See Gotchas for the full envelope.
Quickstart
Cargo.toml (the crate is in-workspace, publish = false, so depend by path):
[dependencies]
sparq-mpc = { path = "crates/sparq-mpc" }
oxrdf = { version = "0.3", features = ["rdf-12"] } # for Term / Variable / Literal
Two holders each evaluate the same fragment locally over their own data, then join on a shared global IRI — no crypto needed because the join key is a disclosed IRI:
use sparq_mpc::{Holder, DisclosedKeyJoin, GlobalJoin, JoinPlan};
use oxrdf::Variable;
const PFX: &str = "@prefix ex: <http://ex/> .\n";
// Holder A: a "knows" graph. Holder B: a "name" graph. ?x is a global IRI shared across both.
let a = Holder::from_rdf("a", &format!("{PFX} ex:p1 ex:knows ex:x1 . ex:p2 ex:knows ex:x2 ."), "turtle")?;
let b = Holder::from_rdf("b", &format!("{PFX} ex:x1 ex:name \"Xena\" . ex:x2 ex:name \"Yuri\" ."), "turtle")?;
// Each holder runs ITS OWN fragment over ITS OWN graph; only the projected rows leave the holder.
let pa = a.evaluate_local("PREFIX ex: <http://ex/> SELECT ?p ?x WHERE { ?p ex:knows ?x }")?;
let pb = b.evaluate_local("PREFIX ex: <http://ex/> SELECT ?x ?n WHERE { ?x ex:name ?n }")?;
// Disclosed-key equi-join on the global IRI ?x (crypto-free; equals union-store evaluation).
let plan = JoinPlan { join_var: Variable::new_unchecked("x"), key_disclosed: true };
let joined = DisclosedKeyJoin::new().join(&[pa, pb], &plan)?;
assert_eq!(joined.rows.len(), 2); // p1-x1-Xena, p2-x2-Yuri
// joined.holder == HolderId("federation"); joined.vars == [?p, ?x, ?n]
# Ok::<(), sparq_mpc::MpcError>(())
Key APIs
Top-level re-exports (use sparq_mpc::...):
Holder— one federation participant + its privateGraph.Holder::from_rdf(id: impl Into<String>, text: &str, format: &str) -> Result<Holder, MpcError>—formatis"turtle" | "ntriples" | "nquads" | "trig".Holder::new(id, graph: sparq_core::Graph) -> HolderHolder::evaluate_local(&self, fragment_sparql: &str) -> HolderResult(= Result<PartialResult, MpcError>) — runs a SELECT fragment over the holder's own graph; raw graph never leaves.
PartialResult { holder: HolderId, vars: Vec<oxrdf::Variable>, rows: Vec<Vec<Option<oxrdf::Term>>> }— the unit of inter-holder sharing (same shape assparq_engine::QueryResult)..len(),.is_empty().GlobalJointrait +DisclosedKeyJoin—fn join(&self, partials: &[PartialResult], plan: &JoinPlan) -> Result<PartialResult, MpcError>. SPARQL compatible-mapping inner join over shared columns; independently checks the planner-named key is present (does not trust the planner for soundness).key_disclosed == falsereturnsNotYetImplemented(useHiddenValueJoininstead).JoinPlan { join_var: oxrdf::Variable, key_disclosed: bool }.MpcBackendtrait — the secret-sharing primitive seam.type Share;info() -> BackendInfo;share_private_input(&self, &Holder) -> Result<Vec<Self::Share>, MpcError>;run_secure(&self, &[Self::Share]) -> Result<Vec<Self::Share>, MpcError>;reconstruct_disclosed(&self, &[Self::Share]) -> Result<PartialResult, MpcError>.BackendInfo { name: &'static str, security: SecurityDescriptor, trust_model: TrustModel, malicious_security: MaliciousSecurity }— the guarantees a federation inspects before trusting a backend. The three-axissecuritydescriptor is the source of truth;trust_model/malicious_securityare kept as back-compat projections of it. Build viaBackendInfo::new(name, security, robust_cheaters)so the projections can't drift.BackendInfo::malicious_secure() -> boolis a coarse "hardened at all?" accessor (malicious_security != MaliciousSecurity::SemiHonestOnly—malicious_securityis theMaliciousSecurityenum, not anOption).SecurityDescriptor { adversary: AdversaryModel, output_guarantee: OutputGuarantee, threshold: CorruptionThreshold, public_verifiability: PublicVerifiability }— the three orthogonal axes a backend (or one operator) advertises.AdversaryModel { SemiHonest, Covert{..}, Malicious }(build covert viaAdversaryModel::covert(num, den));OutputGuarantee { Abort(AbortKind), Fairness, GuaranteedOutput }withAbortKind { Selective, Unanimous, Identifiable }(the honest-majority-onlyFairness/GuaranteedOutputare constructible ONLY viaOutputGuarantee::fairness(threshold)/::guaranteed_output(threshold)— Cleve's impossibility as a type-level invariant);CorruptionThreshold { DishonestMajority{t}, HonestMajority{t}, SuperHonestMajority{t} };PublicVerifiability(bool).OperatorClass { LinearAggregate, EqualityJoin, Comparison }+MpcBackend::operator_security(op) -> SecurityDescriptor— guarantees differ per operator (the Shamir degree-taggregate is robust while the degree-2tequality open is semi-honest-only atn = 2t+1), so query the per-operator descriptor, not just the headlineinfo().security.- Selection / fail-closed negotiation (sq-a6p1).
SecurityRequirement { min_adversary, min_output_guarantee, max_corruption, require_cheater_attribution, require_public_verifiability }— a federation's security floor, stated UP FRONT.req.satisfies(&BackendInfo) -> boolchecks a backend against every axis.BackendRegistry<B: MpcBackend>holds the backends a federation will run:register(B), thenselect(&req) -> Result<&B, MpcError>returns the STRONGEST registered backend meetingreqor fails closed withMpcError::NoBackendSatisfies(a dishonest-majority-malicious request over the shipped semi-honest set is truthfully REFUSED, never downgraded).select_for_operator(&req, op)matches against the per-operator guarantee. Convenience floors:SecurityRequirement::v1_honest_majority_semi_honest()and::dishonest_majority_malicious()(the request that is refused fail-closed today). TrustModel { HonestMajority, DishonestMajority }— the majority axis (back-compat projection ofCorruptionThreshold).MaliciousSecurity { SemiHonestOnly, HonestMajorityAbort, HonestMajorityRobust { max_cheaters: usize } }— the active-security axis (guarantee D), surfaced from the real RS reconstruction redundancy.SemiHonestOnly= semi-honest only, an active deviation is undetected (named so it cannot be confused withOption::None);HonestMajorityAbort= tampering is detected and the protocol aborts (no guaranteed output);HonestMajorityRobust { max_cheaters }= guaranteed-correct output even when up tomax_cheatersparties actively cheat. DerivesCopy/Eq.ShamirBackend— the only concrete backend (honest-majority Shamirt-of-n; its reconstruction reports tamper detect-and-abort or robust correction per the(n, t)redundancy viaBackendInfo.malicious_security, falling back toSemiHonestOnlyonly where no redundancy exists).ShamirBackend::new(n: usize) -> Result<ShamirBackend, MpcError>—n >= 2, thresholdt = (n-1)/2; masks come from an OS-seeded ChaCha20 CSPRNG. No seed parameter by design.ShamirBackend::new_seeded(n, seed)— test/bench only, behindcfg(test)or featureinsecure-test-rng; predictable masks, no security.parties(),threshold(),malicious_security() -> MaliciousSecurity(the active-security level for this(n, t)),reconstruct(&[Share]) -> Result<Fp, MpcError>,dealer() -> ShamirDealer.
- Dealer-less correlated-randomness SEAM (sq-yyro,
randomnessmodule) — the seam plus ONE dealer-less GENERATOR (PRSS, below); no source isdeployable().rngfixes the randomness QUALITY (CSPRNG, sq-1vt); this seam names WHO draws it.DistributedRandomnesstrait —randomness_model() -> RandomnessModel,shared_mask()/shared_nonzero_mask()(the equality mask,r ≠ 0— a forcedr = 0flips every equality verdict),vss_own_input(secret)(dealer-less VSS: each holder shares its OWN input).RandomnessModel { TrustedDealerSim, Prss, HonestMajorityCoinToss }withis_dealer_less()/deployable(). Two implementors:ShamirDealer, reportingRandomnessModel::TrustedDealerSim— the single-dealer path labelled honestly as the SIMULATION it is (the dealer knows every mask) — andprss::PrssRandomness(below).deployable()isfalsefor EVERY variant, sorequire_deployable()still refuses both fail-closed; acceptance must be tied to a validated construction (design §5 item 5), never to the self-reported label. The coin-toss source (interactive, any-n), dealer-less VSS, and ther = 0active-adversary defense remain follow-on beads behind this trait. Design:research/mpc-distributed-randomness-design.md.- PRSS correlated randomness (sq-yyro follow-on,
prssmodule) — the generator is REAL; the setup and the deployment gate are not.PrssRandomness::with_simulated_setup(n) -> Result<PrssRandomness, MpcError>builds the replicated-PRF seeds for theC(n, t)maximal unqualified sets, andshared_mask()/shared_nonzero_mask()then emit degree-tsharings viaΣ_A PRF(k_A, ctr)·f_A— non-interactive (0 online rounds,C(n, t)local PRF calls per element), with each party's share touching only the seeds that party holds (f_A(x_i) = 0exactly wheni ∈ A; a checked invariant). Alsowith_seeded_setup(n, seed)(test/bench only, behindcfg(test)/insecure-test-rng),parties(),threshold(),seed_count(),seeds_per_party(),MAX_PRSS_SEEDS(= 128;n ≥ 10is refused —C(10,4) = 210— pointing at the coin-toss fallback). What it does NOT give: the setup is a simulated one-time trusted setup, not dealer-less key agreement (design §6 Q3); the crate is an in-process simulation, so structure is demonstrated, not isolation; ther ≠ 0rejection evaluatesrcentrally — a simulation artefact a real deployment must replace with a distributed zero-test (design §1/§5 item 4), so it stays semi-honest only;vss_own_inputreturnsNotYetImplemented(PRSS is not an input-sharing protocol); security is computational (PRF-based), not information-theoretic; and the source is still notdeployable()(sq-qhy4external sign-off pending). - Cheater attribution (sq-6u6b). The RS reconstruction surfaces which shares were tampered, with two distinct soundness levels. On the success path use
sparq_mpc::reconstruct_robust_attributed(&[Share], degree) -> Result<RobustReconstruction, MpcError>—RobustReconstruction { secret: Fp, cheaters: Vec<u64> }lists the corrected shares' evaluation points, which is exact (the agreeing degree-degreemajority uniquely pins the cheaters). On the abort pathMpcError::Tampered { cheaters }is best-effort: the off-curve set of the minimum-disagreement reference fit — sound (names only real cheaters) in the first abort band ofe+1errors, heuristic beyond it where the honest set is genuinely unidentifiable. Detection itself is always sound; only abort-path blame is heuristic, so aTamperedname is NOT proof of guilt.
- PRSS correlated randomness (sq-yyro follow-on,
HiddenValueJoin::new(backend: ShamirBackend)+join(&self, left: &HiddenKeyedRows, right: &HiddenKeyedRows) -> Result<PartialResult, MpcError>— joins on a private key via secret-shared equality; output schema isleft.payload_vars ++ right.payload_vars, the key is never projected/reconstructed.HiddenValueJoin::batched_join(&left: &BatchedHiddenInput, &right, bound) -> Result<(PartialResult, ObliviousOutputCost), MpcError>(sq-khf9) — ranges over a row COLUMN per holder under aRowBindingand routes the OUTPUT through the oblivious shuffle + padded-prefix reveal (L1 bounded toB, output L2 destroyed). Still OPENS the per-pair match bit (decision-time L2 unchanged).HiddenValueJoin::fully_oblivious_batched_join(&left, &right, bound) -> Result<(PartialResult, ObliviousOutputCost), MpcError>(sq-xhaw — fully-oblivious) — same asbatched_joinbut the per-pair match bit is computed as a secret-shared 0/1 bycompare::secure_equal_to_bitand fed as aMatchBit::SecretSharedselector, so nothing is opened per pair — the decision-time L2 match-graph leak is closed. Cost:O(COMPARE_BITS)secure-mult rounds per pair (vs one masked open). Honest-majority, semi-honest only (sq-qhy4 external sign-off pending).HiddenKeyedRows { holder: HolderId, payload_vars: Vec<Variable>, rows: Vec<(Fp, Vec<Option<Term>>)> }— caller encodes each private key intoFpviaencode_term(must be injective on the join domain; see Gotchas + the encoder API below).encode_term(&Term) -> Fp(sq-dl81) — the documented, collision-resistantTerm -> Fpjoin-key encoder:reduce_mod_p(SHA-512(DOMAIN_TAG ‖ ntriples(term))). Output is statistically uniform overF_p(random-oracle model), so two distinct terms collide only with the birthday probability≈ q² / 2^62overqdistinct terms (50% nearq ≈ 2^30.5, i.e.≈ 2.2×10⁻¹¹atq = 10⁴— far above the ≤10⁴-row hidden-join regime). Variant-disambiguating (an IRI<x>and a literal"x"never collide); datatype/language tags participate in the key. The in-circuit proof that the opened key equalsencode_term(term)is the M4 collaborative-proof job — this encoder is its on-ramp.KeyEncoder(sq-dl81) — a statefulencode_termwrapper for an exact injectivity guarantee over a CONCRETE key set:KeyEncoder::new()thenencode(&Term) -> Result<Fp, EncodeError>. Re-encoding the SAME term returns the same key (recurring equi-join key, not a collision); two DISTINCT terms hashing to the sameFpreturnEncodeError::Collision(Box<Collision { key, first, second }>)— fail-closed BEFORE any key is secret-shared, since a collision is a false-match soundness break the hidden join cannot otherwise see.len()/is_empty()report the distinct-key countq.DOMAIN_TAGis the versioned domain-separation prefix.- Oblivious set-returning output path (
oblivious_joinmodule) — closes theHiddenValueJoinoutput leaks L1 (true result cardinality) and L2 (per-pair match graph / key fan-out) by padding to a public bound + an oblivious shuffle, instead of opening a per-pair match bit. Built on theoblivious::shufflesubstrate.Candidate { payload: Vec<Option<Term>>, matched: MatchBit }andMatchBit::{ Public(bool), SecretShared(Vec<Share>) }— one join candidate + its match decision (public/disclosed-key bit, or a secret-shared 0/1 bit).oblivious_set_output(backend, candidates, payload_arity, bound) -> Result<(Vec<OutputSlot>, ObliviousOutputCost), MpcError>— reveals exactlybound(B)OutputSlot::{ Row(..), Dummy }slots in oblivious-shuffled order; matched rows survive, non-matches/padding becomeDummy. The parties learn onlyBand≤ B(not the true count, not which slots matched). Fails closed ifbound < candidates.len()(never truncates a match).oblivious_join_output(backend, candidates, out_vars, bound) -> Result<(PartialResult, ObliviousOutputCost), MpcError>— thePartialResult-shaped wrapper (dummies filtered,federation-attributed, canonical order).oblivious_set_output_hidden_keys(backend, left_keys, right_keys, bound)— the truly-private-key all-pairs variant; realised (sq-xhaw) — the secure equality-to-SHARED-bit gate (sq-rrz4/sq-dvuc) it once cited has landed, so it computes each per-pair match bit secret-shared viacompare::secure_equal_to_bit(never opened) and runs the transform. Honest-majority, semi-honest only.
- Oblivious shuffle + sort substrate (sq-18lk,
obliviousmodule) — the single highest-leverage hidden-regime primitive (ORQ SOSP'25): DISTINCT, ORDER BY, GROUP BY-over-hidden-key, MIN/MAX, OPTIONAL/MINUS, the set-returning oblivious-join output path, and ~linear sort-merge joins all reduce to oblivious shuffle + sort. The substrate theoblivious_joinoutput path (above) consumes. Native-only; in-process multi-party simulation; communication cost is MODELLED, not measured (ShuffleCost/SortCostare derived fromn, never hard-coded). All operands areSecretColumn=Vec<Vec<Share>>(a vector of full Shamir sharings, one per row).shuffle(dealer: &mut ShamirDealer, col: SecretColumn) -> Result<(SecretColumn, ShuffleCost), MpcError>— REAL and sound today. Permutes the column by a uniformly-random secret permutation (Fisher–Yates over the dealer's masking CSPRNG, rejection-sampled to kill modulo bias — fails closed forn > P) routed through a full-support AS-Waksman network (WaksmanNetwork; NOT biased-Beneš). The output order is information-theoretically independent of the input order to any≤ t-collusion: the topology is data-independent and the control bits stay secret (PRSS-generated in a real deployment). In the cleartext-control simulation a switch is a local swap of two share-vectors — zero multiplications, stays degreet, no degree reduction — which is why it is buildable on the current single-product Shamir backend.sort_by<C: Comparator>(col: SecretColumn, cmp: &C) -> Result<SortByResult, MpcError>(SortByResult = (SecretColumn, AccessPattern, SortCost)) — sorts ascending via a Batcher odd-even mergesort network (SortingNetwork,O(n log² n)compare-exchange gates inO(log² n)layers). The returnedAccessPattern=Vec<(usize, usize)>is the(i, j)compare-exchange sequence, a function ofnalone — the load-bearing obliviousness substrate (data-independent; asserted so in tests). TheComparatorseam decides each swap; its security is the comparator's responsibility. A comparator over genuinely-SECRET keys needs the degree-reduction-gated secure comparison (sq-rrz4 / sq-dvuc) — the only in-crate secret-keyComparatoris the test-only insecureSimulatedSecretComparator(cfg(test)/insecure-test-rng, reconstructs to compare, LEAKS — stands in so the network can be exercised).sort_with_keys(col: SecretColumn, keys: Vec<Fp>) -> Result<SortWithKeysResult, MpcError>(SortWithKeysResult = (SecretColumn, Vec<Fp>, AccessPattern, SortCost)) — the sound, shippable DISCLOSED-regime ORDER BY / GROUP BY path: sorts by public sort keys, keeping each key aligned with its sharing through every swap. Secret VALUES stay secret-shared and are obliviously permuted; only the public keys drive comparisons, so no secure comparator is needed. Stable (swaps only on strict greater-than). Fails closed (Protocol) ifcol.len() != keys.len().Comparatortrait (must_swap(&self, col: &SecretColumn, i, j) -> Result<bool, MpcError>),SortingNetwork/WaksmanNetwork/Switch(the data-independent network builders, exposed for inspection/testing),ShuffleCost { items, switches, depth_rounds }andSortCost { items, compare_exchanges, depth_rounds }(the modelled-cost reports). The downstream operators (oblivious sort-merge join sq-ujz8, hidden GROUP BY/DISTINCT/MIN-MAX) are their own beads — this module is the substrate, not those operators.
- Secure comparison / threshold (sq-rrz4,
comparemodule) — secret-shared>overFpthat opens only the boolean verdict bit, never the operands or their difference (the disclosure-minimising counterpart toreconstruct_disclosed, which opens the integer). Bit-decomposition MSB-first comparison; chains multiplications viamul_shares_raw+ the BGWdegree_reduce(sq-dvuc), so it is a genuine depth-O(L)mult chain. Honest-majority, semi-honest — NOT malicious (reported asOperatorClass::Comparison→SemiHonestOnly); for the malicious-with-abort variant of this chain seeauth_comparebelow. Operands must be< 2^60(COMPARE_MAX_EXCLUSIVE);n >= 2t+1; both fail-closed. Round-per-bit (LAN-fine, WAN-poor); the constant-round Rabbit/DGK/edaBits family is future work.secure_greater_than(dealer: &mut ShamirDealer, a: Fp, b: Fp) -> Result<Vec<Share>, MpcError>— a degree-tsharing of the bita > b; operands secret-shared internally and never reconstructed.secure_threshold(dealer, secret: Fp, threshold: Fp) -> Result<Vec<Share>, MpcError>— the £100k path: threshold is PUBLIC (its bits are constants, no extra mult round), returns the sharing ofsecret > threshold.secure_equal_to_bit(dealer, a: Fp, b: Fp) -> Result<Vec<Share>, MpcError>(sq-xhaw) — a degree-tsharing of the equality bit1{a == b}, computed as∏_k [a_k == b_k]over bit-decompositions (an AND-tree of secure mults), never opened. Unlikesecure_equal(which opens the masked product per pair — the L2 leak), this keeps the verdict secret-shared, so it can drive an oblivious select with no per-pair open. Feedsfully_oblivious_batched_join/oblivious_set_output_hidden_keys.open_verdict(backend: &ShamirBackend, verdict: &[Share]) -> Result<bool, MpcError>— opens ONLY the verdict bit (refuses a non-0/1 reconstruction).disclose_threshold_verdict(backend, sum_shares: &[Share], public_threshold: u64) -> Result<PartialResult, MpcError>— the four-flatmates path end-to-end: from a secret-shared sum, discloses a one-cell?over_thresholdboolean partial — the exact sum is never in the output. The sum is bit-decomposed in-MPC (neverreconstruct(sum_shares); sq-g7t5). [OPUS-4.8] sq-bgsn lifts this to the Rabbit-style full-field decomposition (secure_bit_decompose_rabbit, eprint 2021/119): a full-field mask (r ∈ [0, 2^61), no party knows it) is added and only the (near-)uniformc = (sum + r) mod pis opened, then the sum is recovered EXACTLY through the modular wrap (sum = c − r + w·p,w = 1{c < r}) — no statistical slack, so the supported magnitude is the fullsum < 2^RABBIT_VALUE_BITS = 2^60(up from the masked-open path's2^20), with an in-protocol range proof (fail-closed; sq-nx0s). Honest-majority, semi-honest only.
- Malicious-with-abort comparison (sq-ka8m,
auth_comparemodule) — the honest-majority malicious-with-abort twin of thecomparechain (designresearch/mpc-malicious-security-design.mdHole 4); pending external sign-off (sq-qhy4). Carries an IT-MACm_x = α·x(under a session-global, secret-shared[α]no party knows) through the WHOLE decompose+compare chain — every secret AND / bit-equality is a MAC-carryingMacSession::auth_mul(design §2.4 route (a): a SECOND independent mult-then-reduce computing[α·z] = reduce([α·x]·[y]), which MAC-covers thedegree_reducere-sharing, Hole 2) — and then runs ONE batched random-challenge MAC-check (MacSession::mac_check, §2.5) over the verdict before opening it: opens a leakage-freeσ = Σ χ_j·[m_{y_j}] − (Σ χ_j·y_j)·[α]and aborts withMpcError::MacCheckFailediffσ ≠ 0. A tamper in any gate (a forged share / a wrong re-sharing) is caught with probability≈ 1 − 2^{−61}overF_p— at the minimaln = 2t+1where Reed–Solomon redundancy is zero (soundness comes from the secretα, not RS over-determination). Tier: AXIS-1Malicious, AXIS-2Abort(Unanimous)(detect-and-abort, NOT identifiable abort, NOT GOD), AXIS-3HonestMajority(degrades / refuses ifn ≤ 2t); NOT dishonest-majority (route (b)/SPDZ, sq-j5ok, is the continuation). The exact integer operands are NEVER opened — only the 1-bit verdict, only after the check passes. Operands< 2^60(COMPARE_BITS);n >= 2t+1; both fail-closed. Round-per-bit, ~2× the semi-honest mult cost (the documented authentication price, design §5; no preprocessing under route (a)). Pending external sign-off (sq-qhy4).malicious_greater_than(backend: &ShamirBackend, a: Fp, b: Fp) -> Result<bool, MpcError>— the MAC-checked verdicta > bover two SECRET operands; aborts (MacCheckFailed) on any tampered gate, never returns a wrong boolean.malicious_threshold(backend: &ShamirBackend, secret: Fp, threshold: Fp) -> Result<bool, MpcError>— the £100k path with a PUBLIC threshold (its bits are constants → no mult on the threshold side); MAC-checked before open.open_auth_verdict(backend: &ShamirBackend, verdict: &AuthenticatedShare) -> Result<bool, MpcError>— opens ONLY the verdict's value sharing (the MAC is consumed by the check, never opened); refuses a non-0/1 reconstruction.- Underlying primitives (on
MacSession, reusable by other authenticated chains):auth_mul(&[[x]], &[[y]]) -> [[x·y]](§2.4 MAC-carrying multiplication),mac_check_and_open(&[[[y_1]]..]]) -> Result<Vec<Fp>, MpcError>(sq-km34.4 — the §2.5 batched check at the open boundary. ONEσopen authenticates the WHOLE batch, so an all-pairs hidden join's|L|·|R|equality opens share a single check and the malicious upgrade's marginal round cost isO(1)per opened batch, not per opened value; it RETURNS the values it verified, so the caller acts on exactly what the check covered and does not re-open them, and onσ ≠ 0it returnsMpcError::MacCheckFailedand NO value — aborting before an inconsistent result is acted on. The challengesχ_jare derived by Fiat–Shamir over the already-fixed share vectors — domain-separated SHA-512 over(n, t, k, {[y_j], [α·y_j]})folded intoF_p— so "derived after the shares are fixed" is structural rather than a call-ordering convention. Scope (honesty): that transcript is the COMPLETE share vectors, which only the in-process trusted-dealer simulation holds — so this is NOT a coin thenparties jointly derive, and it does NOT remove the trusted dealer; a distributed public coin needs a commit-then-challenge / coin-toss protocol that is not implemented. Nor is the check grinding-proof: with the MACs left unchanged,σ = 0iffΣ_j χ_j·δ_{y,j} = 0, which is testable WITHOUTα, so≈ 1 − 2^{−61}is the bound against a deviation fixed beforeχis known and a transcript-grinding adversary faces≈ 2^61work — a computational, not information-theoretic, bound (design §2.5; pendingsq-qhy4)),mac_check(..) -> Result<(), MpcError>(the same check as a pure gate, opened values discarded; empty batch is a no-op pass),auth_const_sharing(c: Fp) -> [[c]](an authenticated public constant),authenticate_existing(&[Share]) -> Result<[[v]], MpcError>(sq-6fv7 — lift an EXISTING value sharing[v]into[[v]] = ([v], [α·v]=reduce([α]·[v]))without reconstructingv; the entry the malicious disclose path uses for the federation's existing sum). Scope (honesty): this lands the malicious-with-abort comparison chain over secret operands (pendingsq-qhy4external sign-off).
- Free authenticated linear ops (sq-km34.1/.2,
authenticatedmodule, design §2.3) —[[x]] = ([x], [α·x])= anAuthenticatedShareunder a sessionMacKey [α]. The IT-MAC is linear in the value, so add/scale/sub/add-constant authenticate for FREE (zero extra rounds — the honest-majority sweet spot):auth_add(&[[x]], &[[y]]) -> Result<[[x+y]], _>,auth_sub(..) -> Result<[[x−y]], _>,auth_scale(&[[x]], c: Fp) -> [[c·x]](public scalar),auth_add_constant(&[[x]], c: Fp, &MacKey) -> Result<[[x+c]], _>(the public-constant MAC termc·[α]comes from the shared[α]).auth_sum(&[AuthenticatedShare]) -> Result<[[Σx]], _>folds a slice withauth_add— the MAC-carrying, zero-round analogue of the backend's plainrun_securecumulative-SUM aggregate (empty input is a fail-closedProtocolerror). All addends must be under the SAME sessionαand on the same party points. Scope (honesty):auth_sumonly propagates the MAC relation (α·Σx) for same-session, honestly formed inputs — it never invokesmac_checkitself; tamper detection requires the caller to include the result in a successful §2.5 batchedmac_checkbefore opening or relying on it, and the malicious-with-abort claim for this chain remains research-grade (internally re-audited, EXTERNAL accredited-cryptographer sign-off PENDINGsq-qhy4). - IT-MAC-hardened threshold disclosure over an EXISTING sum (sq-6fv7,
auth_disclosemodule) — [SONNET-4.6] EXPERIMENTAL, tamper-evident only — it does NOT withstand an actively-deviating party; see the tier correction below. The IT-MAC twin ofdisclose_threshold_verdict, closing the sq-ka8m residual: the federation £100k path runs over an EXISTING[sum]via an in-MPC decomposition (not cleartext bit-sharing) — the masked-open one originally, the authenticated Rabbit one since sq-km34.6 — whose THREE opens were still semi-honest. This routes all three through the §2.5 MAC-check: the existing sum is authenticated (authenticate_existing), the mask bits come from an AUTHENTICATED square protocol (thec = a²open MAC-checked per bit), the masked openc = sum + ris MAC-checked before its bits are read, and the boolean verdict is MAC-checked before open — so a tamper on any of the three (a forged share / a wrong re-sharing) aborts fail-closed withMpcError::MacCheckFailedat the minimaln = 2t+1. [SONNET-4.6] Tier correction — this is NOT AXIS-1Malicioustoday.MacSession::auth_mulADOPTS a value tamper on its second operand, and the range proof's zero-test mask sits in exactly that slot: zeroing it makes the zero-test answer "yes, zero" for any input, switching the range proof off so a corrupted decomposition yields a wrong verdict instead of an abort (witnessa_zeroed_zero_test_mask_defeats_the_range_proof_and_flips_the_verdict). The delivered tier is honest-majority tamper-EVIDENT-with-abort on the opened values — AXIS-2Abort(Unanimous), AXIS-3HonestMajority— a real but PARTIAL hardening over the semi-honest twin. Do not deploy it as the integrity tier untilMacSessiongains multiplication-gate verification binding BOTH operands (Chida-et-al. style). Confidentiality (only the verdict bit is disclosed) is unaffected. Also pendingsq-qhy4external sign-off. The exact sum is NEVER opened — only the sign-independenta², the statistically-maskedsum + r, and the 1-bit verdict (the latter two only after their checks pass).public_threshold < 2^60([SONNET-4.6] sq-km34.6 routed this path onto the AUTHENTICATED Rabbit decomposition, so the masked-open path's2^20cap is gone; the masked-open helpers are now test-only) andn >= 2t+1are fail-closed; the sum's magnitude is checked in-protocol by the sq-nx0s range proof, now MAC-checked end to end (sq-m4zi/sq-e7ma,auth_verify_sum_in_range): its two zero-tests each form[[v·r]]viaauth_muland MAC-check the product before its open is read, so a tampered zero-test open (which could otherwise flip "was it zero?" to smuggle an out-of-range sum past the guard) also aborts withMpcError::MacCheckFailed. So all opens on the tamper-evident disclose path — thea², thec = sum+r, the two range-proofv·rproducts, and the verdict — are MAC-checked.experimental_tamper_evident_disclose_threshold_verdict(backend: &ShamirBackend, sum_shares: &[Share], public_threshold: u64) -> Result<PartialResult, MpcError>— the MAC-checked four-flatmates disclose: from a secret-shared sum, a one-cell?over_thresholdboolean partial; aborts on a tampered OPEN. [SONNET-4.6] It does NOT abort on every deviation — see the tier correction above; "never returns a wrong verdict" is disproved by the zeroed-zero-test-mask witness. The drop-in IT-MAC-hardened counterpart todisclose_threshold_verdict. [SONNET-4.6] Renamed in review round 2 frommalicious_disclose_threshold_verdict: the old name advertised an integrity tier the code demonstrably misses, and a doc caveat on amalicious_*-named drop-in is not containment. No alias is kept under the old name. The break is NOT a regression from the sq-km34.6 Rabbit lift —auth_secret_is_zerois unchanged from the pre-liftorigin/main, whose masked-open range proof fed it the same adopted second-operand mask twice — so reverting would remove the witness, not the break; the real fix is multiplication-gate verification inMacSession.
- Bounded property-path operator — a fixed-
kSPARQL property path?a (p){k} ?b(and bounded{m,k}) realised by unrolling the PUBLIC boundkinto a finite disjunction of fixed-length BGP chains (research/mpc-bounded-property-path-design.md). Two regimes:bounded_path::eval_bounded_path_disclosed(edges: &DisclosedEdges, form: &PathForm) -> Result<PartialResult, MpcError>(sq-py8h.1) — the DISCLOSED-key regime: every endpoint AND intermediate?z_iis a disclosed global IRI, so the chain is a crypto-free fold ofDisclosedKeyJoin+ union + dedup (no MPC round runs).PathForm::{ exact, range, repeat_step, sequence }builds the form;PathStep::{ predicate, alternation }a hop; refuses any unroll exceedingMAX_UNROLL_CHAINS(public, pre-allocation).hidden_path::eval_exact_k_chain_hidden(backend: &ShamirBackend, edges: &HiddenEdges, k: usize, bound: usize) -> Result<PartialResult, MpcError>(sq-py8h.2 — the cryptographic core) — the HIDDEN-intermediate exactly-kchain: the intermediate node values are PRIVATE (HiddenNode { key: Fp, term: Term }/HiddenEdge { subject, object }/HiddenEdges::new(Vec<HiddenEdge>)). Each internal hop equalitye_i.object == e_{i+1}.subjectis a secret-sharedcompare::secure_equal_to_bitbit (never opened — closes the per-hop L2 leak at the decision); thek − 1hop bits are AND-chained (mul_shares_raw+degree_reduce, the sq-dvuc keystone) into a per-tuple chain bit, OR-folded per distinct DISCLOSED endpoint pair (set semantics — realized length never revealed), and the connected-bit drivesoblivious_set_outputas aMatchBit::SecretSharedselector (padded+shuffled to the publicB). Intermediate node keys are never reconstructed.eval_exact_k_chain_hidden_slots(..)returns the rawBshuffledOutputSlots +ObliviousOutputCost(the transcript view). Fail-closed:k >= 1,n >= 2t+1,|E|^k <= MAX_CHAIN_TUPLES, andB >= candidate count. Honest-majority, semi-honest only (sq-qhy4 external sign-off pending). Hidden-key endpoint DISTINCT (sq-py8h.4) and the planner guard (sq-py8h.5) build on this.hidden_path::eval_bounded_path_hidden(backend: &ShamirBackend, edges: &PredicatedEdges, form: &HiddenBoundedPath, bound: usize) -> Result<PartialResult, MpcError>(sq-py8h.3 — bounded HIDDEN union) — the bounded repetition{1,k}/{0,k}/p?({0,1}) + per-hop alternation(p_1|…|p_a)over the SAME secret edge set, built as a union of fixed-length predicate chains (HiddenBoundedPath::{ range, exact, optional, alternation };PredicatedEdge { predicate: String, subject, object }/PredicatedEdges::new(Vec<PredicatedEdge>)tag each edge with its PUBLIC predicate). Every (length, alternation-branch, edge-tuple) chain bit is OR-folded per distinct DISCLOSED endpoint pair into one secret-shared connected-bit (secret_or=1 − (1−x)(1−y), onemul_shares_raw+degree_reduceeach), so a pair reached by several lengths/branches appears exactly once and the realized length is never revealed (design §1.2 length-revealing prohibition). For{0,k}/p?the length-0 reflexive diagonal(x,x)over the disclosed node domain is OR-folded in with a constant shared1(design §2.3). The per-hop predicate gate is a fully PUBLIC decision (mismatch → constant shared0, no crypto).eval_bounded_path_hidden_slots(..)is the transcript view. Fail-closed: non-empty alternation,min <= max,n >= 2t+1, total tuple workΣ_ℓ (a^ℓ·|E|^ℓ) <= MAX_CHAIN_TUPLES(PUBLIC, pre-crypto),B >= candidate count. Same security tier (honest-majority, semi-honest only).hidden_path::planner::plan_hidden_bounded_path(req: &HiddenPathRequest, edge_count: usize) -> Result<BoundedPathPlan, MpcError>(sq-py8h.5 — planner guard + cost-model wiring) — the planner-facing layer (no crypto; closed-form over PUBLIC params).HiddenPathRequest::{ range, unbounded }(itsmax: PathUpperBound::{ Finite(k), Open }can REPRESENT an open bound, unlike the boundedHiddenBoundedPath). (a) a statically-large unroll (Σ_ℓ a^ℓ·|E|^ℓover-cap oru64-overflow) is REJECTED withMpcError::Protocolat plan time; (b) anOpenupper bound ((p)+/(p)*/{m,}) is REFUSED fail-closed viaMpcError::NoBackendSatisfies— never approximated by a defaultk(that would be a wrong answer the verifier could not recompute; the bound must be explicit + public); (c) the returnedBoundedPathPlan { form, edge_count, projected_tuples, secure_equalities, warn }carries.comm_counter(parties, bound) -> CommCountermodelling the operator's cost (thek×-join secure equalities + theO(k)AND/OR degree-reduction rounds + theB-slot oblivious output), which the matrix runner reads for itsQueryClass::HiddenBoundedPathcell (bench::check_bounded_pathis the co-gated correctness anchor).warnflags an admissible-but-heavy projection (within reach of the cap).hidden_distinct::distinct_hidden_pairs(backend: &ShamirBackend, pairs: &[SecretEndpointPair], bound: usize) -> Result<PartialResult, MpcError>(sq-py8h.4 — hidden-key endpoint DISTINCT, the gated sub-piece) — collapse duplicate secret endpoint pairs (SecretEndpointPair { a_key: Fp, b_key: Fp, a_term, b_term }— both endpoints PRIVATE keys, dup'd across input rows; distinct from the sq-py8h.3 OR-fold which collapses multiplicity ACROSS chain lengths over a DISCLOSED key). Construction: oblivious-sort the rows by their secret composite key over the data-independentoblivious::SortingNetwork, deciding each compare-exchange with a never-opened secure comparator (compare::secure_greater_than+secure_equal_to_bit, lexicographic onathenb) driving an arithmetic conditional swapx' = x + b·(y−x)of the key limbs (mul_shares_raw+degree_reduce); then an adjacent-equality scan (secure_equal_to_bitper limb, ANDed → secretkeep = 1 − dup, first occurrence kept); then oblivious compaction by feeding each row as aMatchBit::SecretShared(keep)Candidateintooblivious_set_output(padded+shuffled to the publicB, duplicates open toDummy). No key is ever opened; the multiplicity structure and input→output linkage are hidden;distinct_hidden_pairs_slots(..)/distinct_hidden_pairs_oblivious(..)are the transcript views;DistinctCost { rows, sort_compare_exchanges, adjacent_equalities, output_bound }is the modelled (not measured) cost. Fail-closed:n >= 2t+1,N <= MAX_DISTINCT_ROWS,B >= N, every key< 2^COMPARE_BITS. Honest-majority, semi-honest only (sq-qhy4 external sign-off pending).
- End-to-end federated pipeline driver (sq-6y92,
pipelinemodule) — the GLUE that composesholder→ secret-share → disclosed-keyjoin→ secure-threshold → reconstruct →ProofStatementinto ONE worked four-flatmates federated response (architecture §4.3 steps 1–6). Composes the EXISTING primitives only — invents no crypto;proof.provestays the honestNotYetImplementedstub.run_federated(backend: &ShamirBackend, flatmates: &[Flatmate], query: &FederatedQuery) -> Result<FederatedResponse, MpcError>— runs all six steps. Precondition:backend.parties() == flatmates.len()(each flatmate is one honest-majority compute party) and a non-empty federation, both fail-closedProtocolerrors.Flatmate { holder: Holder, disclosed_fragment: &str, issuer_key: String }— one participant: its wallet, the SELECT fragment it discloses locally (projecting the global-IRI join key; the PRIVATE salary is NOT in it), and the issuer key collected into the statement's key-setK.FederatedQuery { join_var: oxrdf::Variable, federated_query: &str, threshold: u64 }— the global-IRI join key, the full federated query (used verbatim as the union-store differential query +ProofStatement.query), and the public threshold for the secure comparison.FederatedResponse { disclosed_result: PartialResult, over_threshold: bool, verdict_partial: PartialResult, routing: Vec<OperatorRouting>, statement: ProofStatement }— the disclosed join result (byte-equal to the plaintext union-store eval), the> thresholdverdict bit (only the bit is opened; the exact sum is never reconstructed across the boundary), the explicit per-operator routing, and the assembledProofStatement(realdisclosed_result, no proof)..verdict() -> bool.OperatorRouting { operator: String, routing: Routing }+Routing::{ Disclosed, Hidden(OperatorClass) }— the explicit per-operator disclosed-vs-hidden decision (RQ2a): the membership join isDisclosed(crypto-free), the salary aggregate isHidden(LinearAggregate), the threshold isHidden(Comparison).federation_holder_id() -> HolderId— the syntheticfederationid the disclosed result is attributed to.
- Network tier — REAL multi-process transport (sq-tg6b + sq-bdbv,
transportmodule) — the measured-cost counterpart of the in-process counting tier. Each party is its own OS process exchanging the actual protocol messages over loopback TCP (127.0.0.1); a starCoordinatordeals shares + collects opens, exactly as the in-process dealer does but over a socket. Native-only (sockets/processes have no browser story;sparq-mpcis excluded fromsparq-wasm). The transport types are generic over anyRead + Write, so a Unix-domain stream — or atc netem-shaped loopback (Tier 3, privileged;netprofiles+scripts/mpc-netem.sh) — slots in unchanged. Honesty: the in-process tier emits MODELLED comms; this tier emits MEASURED wall-clock + bytes-on-wire, and the two must AGREE on the protocol RESULT (the load-bearing cross-tier anchor asserted in tests + by thempc_net_benchdriver).run_cell_networked(query_class: QueryClass, backend: &ShamirBackend, dealer: &mut ShamirDealer, values: &[u64], scale: usize) -> Result<NetworkCell, MpcError>— drives one cell over loopback (thread-per-party in-crate; real process-per-party via thempc_net_bench/mpc_partyexamples) and returns the MEASUREDNetworkCell { result, wall_clock, bytes_sent, bytes_recv, rounds, .. }.- The four wire-driven
QueryClasscells now run on the wire. (The fifth class,HiddenBoundedPath(sq-py8h.5), has no live-wireCoordinatordriver — its cost is wired into the ANALYTIC in-process matrix viaBoundedPathPlan::comm_counter, andrun_cell_networkedREFUSES it with an honestNotYetImplementedrather than fabricate a wire run.)CumulativeSumAggregate+HiddenValueEquiJoinare single-round (deal → local op → open).ObliviousShuffle(AS-Waksman switches) +ObliviousSort(Batcher compare-exchanges) are MULTI-round (sq-bdbv): theCoordinatordrives oneMessage::Swap { a, b, swap }per network gate (StepCode::SwapNetworkAndOpen), soroundsequals the network's gate count. The shuffle samples a fresh secret permutation (oblivious::sample_random_permutation) routed throughWaksmanNetworkfor the control bits; the sort uses the disclosed-key (sort_with_keys) regime — only the public comparator outcome's boolean crosses the wire, the switch topology is data-independent. The reportedresultfor shuffle/sort is the multiset-invariant column sum (the cross-tier anchor that no row was lost/duplicated/corrupted on the wire); the full permuted-column / ascending-order checks live in the in-crate tests that callCoordinator::shuffle/Coordinator::sortdirectly. Parties hold only their own share-column and apply local swaps — never reconstruct, never learn the permutation as a whole. Coordinator::{ aggregate, hidden_join, shuffle, sort },run_party,Channel,Message,StepCode,NetworkCell— the transport surface (a hand-written length-prefixed codec, noserde).
Fp— field element overF_p,p = 2^61 - 1.Fp::new(u64),Fp::value() -> u64,Fp::zero(),Fp::one().Share { x: u64, y: Fp }; field/share helpers insparq_mpc::shamir(add_shares,sub_shares,mul_shares_raw,add_constant,scale,reconstruct_degree).ShamirDealer::degree_reduce(&[Share])reduces a degree-2tproduct back to degree-tso multiplications chain (depth>1).MpcError::{ LocalEval { holder, message }, Protocol(String), NotYetImplemented { what, gated_on }, Tampered { detail, cheaters }, NoBackendSatisfies { requirement, considered }, MacCheckFailed { detail } }— the honest error channels:NotYetImplementedfor deferred crypto,Tamperedfor a detected active deviation on a redundant (RS) reconstruction,NoBackendSatisfiesfor a fail-closed selection refusal, andMacCheckFailed(sq-ka8m) for the batched IT-MAC check (σ ≠ 0) catching a tamper on an authenticated path — distinct fromTamperedbecause it works at the minimaln = 2t+1(soundness from the secretα, not RS redundancy).CollaborativeProof<B: MpcBackend>,Attestation,ProofStatement,Proof,AttestationShare— interface only; every method is a stub returningNotYetImplemented.sparq_mpc::federated_binding(sq-34ml) — the two NON-research prerequisites of the buildable M4-v1 verifier-side-attestation interim (research/mpc-m4-distributed-sig-feasibility.md§2/§4), split out of the unsolved Q1 spike. Layout + out-of-circuit binding only — it is NOT a proof and closes NO ZK-audit item (see the limitation below).FieldWord(opaque 32-byte big-endian public-input word;from_be_bytes/as_be_bytes/from_hex/digest),VerifierChallenge::{fresh, fresh_from, from_word, as_word},ValidityWindow::{new, contains},FreshnessBinding::tag,key_set_digest(&[String]),SeenChallenges+InMemorySeenChallenges,check_freshness(statement, expected, now, seen),HolderSegment::new(holder, commitments, rows, row_capacity, attribution),FederatedStatement::{new, commitment_digest, freshness_binding, public_input_word_count, reconstruct_public_inputs, bind_public_inputs}, and the fail-closedBindingError(impl From<BindingError> for MpcError→Protocol, neverNotYetImplemented).SecureRng/MpcRng— the CSPRNG masking seam (you rarely touch these directly).
Common recipes
1. Secure cumulative aggregate over private values (the "four flatmates" sum)
Each holder secret-shares one private integer; the sum is computed over shares (free local addition) and only the total is reconstructed — no individual value is revealed.
use sparq_mpc::{Holder, ShamirBackend, MpcBackend};
const PFX: &str = "@prefix ex: <http://ex/> . @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n";
let alice = Holder::from_rdf("alice", &format!("{PFX} ex:a ex:salary \"30000\"^^xsd:integer ."), "turtle")?;
let bob = Holder::from_rdf("bob", &format!("{PFX} ex:b ex:salary \"45000\"^^xsd:integer ."), "turtle")?;
let backend = ShamirBackend::new(2)?; // n parties; honest-majority
let mut shares = backend.share_private_input(&alice)?; // shares Alice's ex:salary
shares.extend(backend.share_private_input(&bob)?); // ... and Bob's
let summed = backend.run_secure(&shares)?; // cumulative sum over shares (0 rounds)
let out = backend.reconstruct_disclosed(&summed)?; // -> PartialResult: one ?cumulative integer (75000)
# Ok::<(), sparq_mpc::MpcError>(())
share_private_inputexpects the holder's data to yield exactly one row, one integer column forSELECT ?salary WHERE { ?p ex:salary ?salary }; anything else is aProtocolerror (it never guesses). To disclose a threshold likesum > £100kwhile revealing ONLY the boolean (not the exact total), use the secure comparison (disclose_threshold_verdict, recipe 2) instead ofreconstruct_disclosed— the latter opens the integer for an out-of-crypto recompute.
2. Threshold verdict only — sum > £100k without revealing the sum (the four-flatmates disclosure-minimisation)
The secure comparison opens only the boolean verdict — that one bit is the only value that ever leaves the computation; the integer total is never an output. Reuse the secret-shared sum from recipe 1.
use sparq_mpc::{ShamirBackend, MpcBackend, disclose_threshold_verdict, Fp};
let backend = ShamirBackend::new(5)?; // n >= 2t+1 (comparison is a mult chain)
let salaries = [30_000u64, 28_000, 26_000, 24_000]; // sums to 108_000
let shared: Vec<_> = salaries.iter().map(|&s| backend.dealer().share(Fp::new(s))).collect();
let summed = backend.run_secure(&shared)?; // secret-shared cumulative sum
let out = disclose_threshold_verdict(&backend, &summed[0], 100_000)?;
// out: one ?over_threshold = "true"^^xsd:boolean — the £108k total is never an OUTPUT.
# Ok::<(), sparq_mpc::MpcError>(())
Honesty — the sum is bit-decomposed IN-MPC (
disclose_threshold_verdict). The sum stays secret-shared end-to-end: there is no localbackend.reconstruct(sum_shares)— that shortcut was removed (sq-g7t5). [OPUS-4.8]sq-bgsnlifts the production decomposition to the Rabbit-style full-field protocol (eprint 2021/119,secure_bit_decompose_rabbit): a full-field-width mask[r](r ∈ [0, 2^61), drawn so no party knows it via the square-protocol random-bit generator) is added and only the (near-)uniformc = (sum + r) mod pis opened, then the sum is recovered exactly through the modular wrap (sum = c − r + w·p,w = 1{c < r}from a public-vs-sharedLTBits). Recovering the wrap (rather than avoiding it, as the old masked-open path did) removes the value/mask slack, so the magnitude bound is the fullsum < 2^60(RABBIT_VALUE_BITS) — a 40-bit lift over the masked-open2^20— and the open's residual leakage is the2^{-61}field-size floor (independent of magnitude). An in-protocol range proof (sq-nx0s/verify_value_in_range_rabbit) provessum ∈ [0, 2^60)from the recovered bits — an over-magnitude sum aborts fail-closed, never returns a wrong verdict. Only the verdict bit is ever an OUTPUT. Honest scope: still honest-majority, semi-honest only — thea²/c = (sum+r) mod popens and thedegree_reducere-sharings are unauthenticated (sq-qhy4external sign-off PENDING). The malicious twin (auth_disclose) still uses the lower-magnitude (< 2^20) masked-open decomposition; its Rabbit upgrade is the remaining follow-up.
2b. End-to-end federated response — the four flatmates, all six §4.3 steps in one call
run_federated composes holder → secret-share → disclosed-key join → secure-threshold → reconstruct → ProofStatement. Each flatmate discloses a DISTINCT public fact about the shared flat (joined on the global IRI ?flat) and secret-shares a PRIVATE salary; the response carries the disclosed join result, the > £100k verdict bit (the exact sum is never reconstructed), the explicit per-operator routing, and a ProofStatement whose disclosed_result equals the plaintext union-store eval. proof.prove stays the honest stub.
use sparq_mpc::{run_federated, Flatmate, FederatedQuery, Holder, ShamirBackend, Routing, OperatorClass};
use oxrdf::Variable;
const PFX: &str = "@prefix ex: <http://ex/> . @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n";
// Each flatmate: a distinct disclosed flat attribute on ex:flat (joined on ?flat) + a PRIVATE salary.
let mk = |m: &str, fact: &str, salary: u64, frag: &'static str, key: &str| -> Flatmate<'static> {
let doc = format!("{PFX} {fact} . ex:resident ex:salary \"{salary}\"^^xsd:integer .");
Flatmate { holder: Holder::from_rdf(m, &doc, "turtle").unwrap(), disclosed_fragment: frag, issuer_key: key.into() }
};
let flatmates = vec![
mk("alice", "ex:flat ex:rent \"1200\"", 30_000, "PREFIX ex: <http://ex/> SELECT ?flat ?rent WHERE { ?flat ex:rent ?rent }", "did:ex:hr#k1"),
mk("bob", "ex:flat ex:city \"Leeds\"", 28_000, "PREFIX ex: <http://ex/> SELECT ?flat ?city WHERE { ?flat ex:city ?city }", "did:ex:hr#k2"),
mk("carol", "ex:flat ex:landlord \"Acme\"", 26_000, "PREFIX ex: <http://ex/> SELECT ?flat ?landlord WHERE { ?flat ex:landlord ?landlord }", "did:ex:hr#k3"),
mk("dave", "ex:flat ex:postcode \"LS1\"", 24_000, "PREFIX ex: <http://ex/> SELECT ?flat ?postcode WHERE { ?flat ex:postcode ?postcode }", "did:ex:hr#k4"),
];
let backend = ShamirBackend::new(4)?; // n = 4 compute parties == flatmate count (fail-closed if mismatched)
let q = FederatedQuery {
join_var: Variable::new_unchecked("flat"),
federated_query: "PREFIX ex: <http://ex/> SELECT ?flat ?rent ?city ?landlord ?postcode WHERE { \
?flat ex:rent ?rent . ?flat ex:city ?city . ?flat ex:landlord ?landlord . ?flat ex:postcode ?postcode }",
threshold: 100_000,
};
let resp = run_federated(&backend, &flatmates, &q)?;
assert!(resp.over_threshold); // 108k > 100k — only the BIT is opened
assert_eq!(resp.routing[0].routing, Routing::Disclosed); // membership join in the clear
assert_eq!(resp.routing[2].routing, Routing::Hidden(OperatorClass::Comparison)); // threshold in MPC
// resp.statement.disclosed_result == plaintext union-store eval of q.federated_query (the correctness anchor).
# Ok::<(), sparq_mpc::MpcError>(())
Honest-majority, semi-honest (the
ShamirBackendtier, unchanged).proof.provestaysNotYetImplemented— the driver assembles the statement STRUCTURE with the real disclosed result, it does NOT fake a proof.
3. Hidden-value join on a private key (circuit-PSI core)
Join two holders on a key WITHOUT revealing the key — only the matched payload columns are disclosed.
use sparq_mpc::{HiddenValueJoin, HiddenKeyedRows, ShamirBackend, HolderId, encode_term};
use oxrdf::{NamedNode, Term, Variable, Literal};
let lit = |s: &str| Some(Term::Literal(Literal::new_simple_literal(s)));
let iri = |s: &str| Term::NamedNode(NamedNode::new(s).unwrap());
// PRIVATE keys are RDF terms encoded into Fp by the collision-resistant
// `encode_term` (domain-separated SHA-512 → field; injective up to the birthday
// bound). Equal terms ⇒ equal keys ⇒ a match; nothing else can match.
let bob = iri("http://example.org/people/bob");
let left = HiddenKeyedRows { holder: HolderId::new("L"), payload_vars: vec![Variable::new_unchecked("name")],
rows: vec![(encode_term(&iri("http://example.org/people/alice")), vec![lit("Alice")]),
(encode_term(&bob), vec![lit("Bob")])] };
let right = HiddenKeyedRows { holder: HolderId::new("R"), payload_vars: vec![Variable::new_unchecked("city")],
rows: vec![(encode_term(&bob), vec![lit("Leeds")]),
(encode_term(&iri("http://example.org/people/zoe")), vec![lit("Hull")])] };
let backend = ShamirBackend::new(3)?; // n=3, t=1 supports the 1 equality multiplication (2t+1<=n)
let joined = HiddenValueJoin::new(backend).join(&left, &right)?;
// joined.vars == [?name, ?city]; one row Bob-Leeds; non-matching keys dropped; keys never reconstructed.
// For an EXACT injectivity guarantee over a concrete key set, route each key
// through a `KeyEncoder` instead — it fails closed on a false-match collision.
# Ok::<(), sparq_mpc::MpcError>(())
4. Multi-holder chain join on disclosed global IRIs
DisclosedKeyJoin folds pairwise; chain joins by feeding the result back in with the next shared variable.
use sparq_mpc::{DisclosedKeyJoin, GlobalJoin, JoinPlan};
use oxrdf::Variable;
let v = |n: &str| Variable::new_unchecked(n);
// pa: ?p ?x , pb: ?x ?y , pc: ?y ?n (each a PartialResult from evaluate_local)
let ab = DisclosedKeyJoin.join(&[pa, pb], &JoinPlan { join_var: v("x"), key_disclosed: true })?;
let abc = DisclosedKeyJoin.join(&[ab, pc], &JoinPlan { join_var: v("y"), key_disclosed: true })?;
# Ok::<(), sparq_mpc::MpcError>(())
5. Inspect a backend's guarantees before trusting it
A federation should refuse a backend whose guarantees don't match its threat model.
use sparq_mpc::{ShamirBackend, MpcBackend, TrustModel, MaliciousSecurity};
let info = ShamirBackend::new(3)?.info();
assert_eq!(info.trust_model, TrustModel::HonestMajority);
// At n=3,t=1 there is one redundant share → tampering is detected & aborts
// (but a cheater can still force an abort: no guaranteed output). With more
// redundancy (n>=4) it becomes HonestMajorityRobust { max_cheaters }.
assert_eq!(info.malicious_security, MaliciousSecurity::HonestMajorityAbort);
assert!(info.malicious_secure()); // coarse "hardened at all?" bit
# Ok::<(), sparq_mpc::MpcError>(())
State the requirement UP FRONT and let a fail-closed registry match it — an over-strong request is truthfully REFUSED, never silently downgraded:
use sparq_mpc::{ShamirBackend, BackendRegistry, SecurityRequirement, MpcError};
let mut registry: BackendRegistry<ShamirBackend> = BackendRegistry::new();
registry.register(ShamirBackend::new(3)?);
// A requirement the shipped (honest-majority, semi-honest) backend meets → returns it.
let backend = registry.select(&SecurityRequirement::v1_honest_majority_semi_honest())?;
assert_eq!(backend.parties(), 3);
// A dishonest-majority-malicious request has NO satisfying backend → fail-closed.
let refused = registry.select(&SecurityRequirement::dishonest_majority_malicious());
assert!(matches!(refused, Err(MpcError::NoBackendSatisfies { .. })));
# Ok::<(), sparq_mpc::MpcError>(())
6. Reproducible tests/benches (predictable masks — never production)
[dev-dependencies] # or [dependencies] only if you understand the risk
sparq-mpc = { path = "crates/sparq-mpc", features = ["insecure-test-rng"] }
let backend = sparq_mpc::ShamirBackend::new_seeded(3, 0xBEEF)?; // deterministic masks; NO security
# Ok::<(), sparq_mpc::MpcError>(())
7. Handle deferred crypto honestly
The collaborative proof and the hidden-key path of DisclosedKeyJoin are gated; match the error rather than assuming success.
use sparq_mpc::MpcError;
match some_result {
Err(MpcError::NotYetImplemented { what, gated_on }) =>
eprintln!("deferred: {what} (gated on {gated_on})"), // e.g. mentions M3/M4, Q1, ZK #3..#12
Err(MpcError::Protocol(m)) => eprintln!("precondition: {m}"),
Err(MpcError::LocalEval { holder, message }) => eprintln!("holder {holder} eval failed: {message}"),
Ok(_partial) => { /* real result */ }
}
Gotchas / feature flags / prerequisites
- Native-only, not in wasm.
sparq-mpcis intentionally absent fromsparq-wasm's dependency graph (cargo tree -p sparq-wasmmust not show it). The browser bundle carries zero MPC/crypto surface. insecure-test-rngfeature (OFF by default). GatesShamirBackend::new_seededandrng::InsecureTestRng(a deterministic SplitMix64). The masks it produces are predictable — enabling it in a deployment reintroduces the very confidentiality weakness the CSPRNG default fixes. Use it only for reproducible tests/benchmarks. Default builds physically cannot construct a predictable masking RNG. The isolated glue testcrates/sparq-mpc/tests/oblivious_join_determinism.rs(whole file#![cfg(feature = "insecure-test-rng")]) leans on this: a fixed(n, seed)yields a bit-identicalVec<OutputSlot>(including the oblivious-shuffled order). That reproducibility is a property of the seeded test RNG only — the production path draws OS-seeded ChaCha20 and is NOT reproducible, and determinism is not a security property. The transport-codec round-trip / malformed-frame tests and the collaborative-proof deferral tests (tests/transport_codec.rs,tests/proof_deferred.rs) need no RNG and run in both feature states.- Malicious-security is now SURFACED, not blanket-absent. Confidentiality holds against
<= tcolluding honest-but-curious parties. Against an actively-deviating party,ShamirBackendreports the precise guarantee viaBackendInfo.malicious_security(ShamirBackend::malicious_security()): the WI-1 RS-checked / Berlekamp–Welch reconstruction detects tampered shares and aborts when there is redundancy (n > t+1, always true for the honest-majorityt), and robustly corrects up tomax_cheaters = ⌊(n−t−1)/2⌋cheaters when redundancy allows (n >= 4). Boundaries that are NOT hardened (do not over-trust): the degree-2tequality/mult open in the hidden-value join has no RS redundancy atn = 2t+1(the common odd-ncase, e.g. n=3,5,7) — a tampered product share there is undetectable; a fix needs an information-theoretic MAC (deferred, bead sq-6d6g). Dishonest-majority remains future work behind the sameMpcBackendtrait. - In-process simulation for the PROTOCOL crypto; a real loopback transport for the COST. The protocol crypto (the dealer/
HiddenValueJoinplays all parties to deal shares and open results) runs in one process — cleartext inputs are passed to the simulator only to be shared internally. Thetransportmodule (sq-tg6b + sq-bdbv) adds a REAL multi-process star-coordinator over loopback TCP for MEASURED cost (all four cells, incl. the multi-round oblivious shuffle/sort), but it remains a star-coordinator measurement harness, not a full party-mesh deployment, and the netem LAN/WAN shaping (Tier 3) needs a privileged host.disclose_threshold_verdictno longer reconstructs the sum — that local shortcut was removed (sq-g7t5); the sum is bit-decomposed in-MPC. [OPUS-4.8] sq-bgsn lifted the production path to the Rabbit-style full-field decomposition (eprint 2021/119,secure_bit_decompose_rabbit): a full-field mask[r](square-protocol, no party knows it) is added, only the (near-)uniformc = (sum + r) mod pis opened, and the sum is recovered exactly through the modular wrap (sum = c − r + w·p,w = 1{c < r}). With an in-protocol range proof (sq-nx0s), only the verdict bit is ever an OUTPUT. sq-mnv5 deployment residuals (sq-qhy4external sign-off pending): (1) wider magnitude — CLOSED by sq-bgsn on the semi-honest production path (the Rabbit decomposition recovers the wrap, so the bound is the full< 2^60, up from< 2^20); [SONNET-4.6] sq-km34.6 lifted the IT-MAC twinauth_discloseonto the same authenticated Rabbit construction, so it too supports< 2^60; (2) malicious security — PARTLY CLOSED by sq-ka8m, and [SONNET-4.6] LESS closed than previously written:auth_muladopts a second-operand value tamper, which is exploitable end to end on the disclose path (see the tier correction on theauth_disclosebullet above), so neither twin is AXIS-1Malicioustoday — the malicious-with-abort comparison chain over secret operands now threads IT-MACs (MacSession::auth_mul, design §2.4 route (a)) through every gate and MAC-checks the verdict before open (MacSession::mac_check, §2.5), aborting on a tampered OPEN at the minimaln = 2t+1([SONNET-4.6] not on any tamper — the second-operand adoption above applies to this chain too) — seeauth_compare(malicious_greater_than/malicious_threshold). The end-to-enddisclose_threshold_verdictdecomposition opens (a²,c = sum+r, the range-proof zero-testv·rproducts, and the verdict) are now ALL routed through the MAC-check in the IT-MAC-hardened twinauth_disclose::experimental_tamper_evident_disclose_threshold_verdict(sq-6fv7 + sq-m4zi/sq-e7ma) — the semi-honestdisclose_threshold_verdictitself stays semi-honest by design (the cheap zero-round honest path). Both remainsq-qhy4external-sign-off-pending. - Field & range.
Fpis overp = 2^61 - 1. Keep values (salaries, counts, key encodings) well under2^61so sums never wrap.Fp::inv(0)panics (only nonzero differences are inverted internally). - Shamir headroom. A single multiplication (the equality test) needs
n >= 2t+1.ShamirBackend::newpickst = (n-1)/2, so the happy path holds;HiddenValueJoinerrors withProtocolif you somehow under-provision.reconstructneeds>= t+1distinct-xshares. - Hidden-join key encoding — use
encode_term, andKeyEncoderif you need an EXACT guarantee.HiddenKeyedRows.rowscarryFpkeys; equality inFpstands in for term equality and is sound only if the encoding is injective over the key domain.encode_term(sq-dl81) makes that a quantified birthday event (≈ q²/2^62, negligible in the ≤10⁴-row regime) rather than an unstated assumption; for an exact guarantee on a concrete key set, drive each key through aKeyEncoder, which detects any false-match collision and fails closed before sharing. What is still deferred to the collaborative proof (M4) is the in-circuit proof that the opened key equalsencode_termof the holder's real term (the encoding-correctness proof) —encode_termis its on-ramp, not a substitute. Blank-node labels are keyed verbatim (no cross-graph identity); canonicalise first if you need it. Thereduce_mod_p(SHA-512(DOMAIN_TAG ‖ ntriples(term)))construction is byte-stable: a known-answer test (term_encode::tests::sha2_byte_stability_known_answers, sq-jkcj) pins the exactencode_termfield elements (plus the bare SHA-256/512 empty-string vectors), so thesha20.10→0.11 API bump — and any future drift in the tag/truncation/reduction or oxrdf's N-Triples rendering — fails closed. That is a dependency-maintenance regression guard, not a new soundness/privacy property. - Hidden join cost.
HiddenValueJoinis naiveO(|L|·|R|)all-pairs (one secure equality test per pair). No cuckoo/oblivious-hashing PSI optimisation. Honest envelope: minutes-to-tens-of-minutes for ~10³–10⁴ rows/holder on a LAN — not sub-second; do not extrapolate beyond it. - Hidden-join leakage tiers (L1/L2) — choose the right entry point.
HiddenValueJoin::joinopens a per-pair match bit and emits the exact match count, leaking the bipartite match graph / key fan-out (L2 at the decision) and the true result cardinality (L1).batched_join(sq-khf9) routes the OUTPUT through pad-to-B+ oblivious-shuffle, closing L1 (bounded toB) and the result-set L2 — but it STILL opens the per-pair match bit, so decision-time L2 is unchanged.fully_oblivious_batched_join(sq-xhaw) closes that last leak: the per-pair match bit is computed secret-shared (secure_equal_to_bit) and never opened, so nothing leaks per pair — atO(COMPARE_BITS)secure-mult rounds/pair (vs one masked open). Residual leakage (honest, all tiers): the parties learn onlyB, but the authorized result recipient still learns the true count by filtering dummies — a padded-bound scheme, not differential privacy. Hiding the count from the recipient too is the separate DP cardinality mode (bead sq-shk5). All hidden-join tiers are honest-majority, semi-honest only (sq-qhy4 external sign-off pending). - Disclosed-key join is a faithful SPARQL inner join, not a naive key-merge. It enforces agreement on every shared variable (compatible-mapping semantics) and does not trust the planner-named key for soundness; a key absent from any partial is a
Protocolerror. Output rows are canonicalised (order-independent multiset). - The M4-v1 federated binding layer binds nothing YET (sq-34ml).
federated_bindinggives the buildable verifier-side-attestation interim its two non-research prerequisites: an explicit out-of-circuit freshness/replay transcript (FreshnessBinding/check_freshness— ZK audit #4 stops being automatic once the issuer signature is checked beside the proof instead of inside it) and the federated/multi-sourcereconstruct_public_inputsbyte layout spanning every holder's commitments/rows/attribution (#1 generalised to N sources). It is a deterministic byte layout plus a fail-closed transcript check, not a proof: it closes no ZK-audit item, and the whole M4 milestone stays hard-gated on the single-prover ZK-foundation remediation (#3/#4/#5/#6/#8/#9/#12) —CollaborativeProof::prove/verifystill returnNotYetImplemented.bind_public_inputsbecomes load-bearing only once a collaborative proof exists to supply thepublic_inputsit byte-compares against. The interim it targets also gives up source-unlinkability (the verifier learns which issuer key signed each graph, not merely "some key inK") and a single succinct signature↔witness binding. No soundness or privacy property is asserted as achieved; the ZK verifier this layout targets is internally re-audited but NOT externally audited (sq-qhy4), and the MPC substrate is honest-majority semi-honest only. Two layout details are load-bearing and tested: holder segments are canonically ordered (sorted byHolderId, duplicates refused) so prover and verifier serialise identical bytes, and each segment carries explicitk_i/r_iarity words — without them a segment body (2k + 3r + 1words, no internal boundary marker) is ambiguous, and a prover could present three committed graphs as six while still byte-matching (federated_layout_binds_segmentationexhibits the exact colliding pair). - Collaborative proof is a stub.
CollaborativeProof,Attestation,ProofStatementare interface + docs only; every method returnsNotYetImplemented. They are hard-gated on the single-prover ZK soundness remediation (issuer-signature / replay / FILTER-binding / attribution / revocation) and the open research question of verifying a signature over a secret-shared witness (Q1, milestone M4). Do not assume any correctness/attestation guarantee from this crate yet — only confidentiality of the secret-shared inputs under the semi-honest model.
See also
sparq-fedplan-mpc(opt-in crate,fedplan-mpcfeature OFF by default) — the untrusted-planner → MPC-routing seam that produces theVec<OperatorRouting>this crate's pipeline today receives hand-written. Phase 1 (bead sq-2q1x) landed the per-sourceSourcePrivacyDescriptor(default-deny — a predicate is private unless explicitly markedDisclosability::Public). Phase 2 (bead sq-fix4) landed the source-selection adapterselect_private_sources: it runssparq-fedplan::select_sources, then prunes any source that has declared it will not participate (participates == Some(false)), surfacing the retained per-pattern candidate set + an audit trail (pruned). The only prune rule is participation/authorisation — a source holding a private predicate stays a candidate (its in-clear-vs-MPC routing is the Phase-3 decision), so the adapter is recall-safe; a duplicate descriptor for one source id is refused (SeamError::DescriptorMismatch). Phase 3 (bead sq-i1wh2) lands the disclosed/hidden routing passroute_operators(&selected, &privacy, &operators, policy): perQueryOperator(a label + anOperatorClass+ itsOperands — aGlobalIrior aPredicate), it routesDisclosedwhen every operand is disclosable in the clear andHidden(class)otherwise (default-deny; the most-private contributing source wins per operand), emitting theVec<OperatorRouting>the pipeline consumes. TheRoutingPolicyknob isDefault(disclose global-IRI operands, per convention #6 — reproduces the hand-written four-flatmates routing exactly, the load-bearing differential) orStrict(the §5 "hide even a public term" knob — hides a global IRI whose predicate a contributing source marked private). It is routing plumbing — not a cryptographic guarantee: it performs no MPC, opens nothing, makes no privacy/soundness claim, and computes only the proposed partition. Phase 4 (bead sq-pwr.2) lands the leakage-envelope assembly + dual ratification.assemble_leakage_envelope(&routing, &operators, &selected)derives, from the Phase-3 routing, a declaredLeakageEnvelopethat honestly enumerates what the plan reveals — the operator structure (count/class/label, perOperatorDisclosure), the disclose/hide partition, the disclosed operands eachDisclosedoperator exposes in the clear (distinct_disclosed_operands()), and the participating sources — over-counting, never under-counting the leak; a routing/operator mismatch is fail-closed (SeamError::DescriptorMismatch, phaseLeakageEnvelope).ratify_envelope(&env, &holders, verifier_budget)then runs the dual gate, returning aRatificationOutcome: each holder fail-closed-rejects a plan disclosing one of its own private predicates (constraint C-B —HolderRejected, the most-private holder wins, the data owner's veto is primary), AND the verifier rejects an over-leaking envelope whose distinct-disclosed-operand count exceedsverifier_budget(VerifierRejected;None= no cap). It is leakage-accounting + a plan-time policy gate — not a cryptographic enforcement (not a runtime guarantee a malicious holder/verifier honours it): it runs no MPC and makes no soundness/privacy claim. Honest leakage: the plan reveals the operator structure, the disclose/hide partition, the disclosed operands, and source participation — but not hidden-operand values or result cardinalities. Phase 6 (beads sq-pwr.3 + sq-xkrt) lands the FedUP-style (WWW'24) result-aware source-combination pruneprune_source_combinations(&bgp, &sources, &selected). A federated query executes a conjunction of patterns and the seam's secure-join cost grows with the number of source combinations (one source per pattern) it considers; this pass surfaces which combinations provably contribute no answer so the seam routes fewer toward MPC. Two recall-safe, summary-expressible rules. Rule C1 — unsatisfiable-conjunct collapse: if any pattern's Phase-2 candidate list is empty, that conjunct is a proved-empty relation (by the upstream HiBISCuS recall-safety + the recall-safe participation prune), so the whole conjunction is unsatisfiable (∅ ⋈ R = ∅) and every combination is dead. Rule C2 — dead same-source star pairing (the FedUP quotient-summary/provenance rule, and the one that fires on the live path): a source's served characteristic sets (scs:) partition its subjects by their exact predicate set, so if two patterns share a subject-position join variable and no characteristic set of a source carries both their predicates, no subject of that source satisfies both conjuncts — assigning both patterns to that source is dead, and so is every combination containing that pairing. The source stays a valid candidate for each pattern individually (a cross-source combination is untouched); a candidate is removed only when the peer pattern's sole candidate is that same source, and when that leaves a pattern with no live source the BGP collapses like C1. Rule C2 fires only behind a completeness guard —Σ_{C ∋ p} subjects == void:distinctSubjectsover sets with distinct keys — which a truncated (long-tail-elided, persparq-introspect'smax_char_sets) or unknown-distinctSubjectssummary fails, as does a repeated exact-predicate-set key (two entries under one key are not a partition, so their sums prove nothing), so an unprovable case declines rather than over-prunes (the load-bearing recall-safety test). It returns aPrunedCombinations { per_pattern (carried through unchanged — advisory + auditable, nothing silently removed), bgp_satisfiable, empty_patterns, dead_patterns, dead_pairings, components (the BGP join-graph connected components, deterministic), pruned };is_bgp_dead()/surviving_combination_patterns()/combination_is_dead(&assignment)are the convenience accessors (the last returnsfalse— "keep it" — for anything it cannot prove). The value-overlap / bound-IRI-propagation prune is deliberately declined as not recall-safely expressible from the publicSourceDescriptorsummary (a single-IRImay_hold_authoritytest, no authority-set enumerator; a bound IRI position is already pruned per-source byselect_sourcesRule 2, and a join variable binds data-dependent values unknown at plan time) — recorded as a negative result so no contributor builds an unsound prune. It is selection plumbing — not a cryptographic guarantee: it performs no MPC, reads only the already-public Phase-2 selection, and makes no privacy/soundness claim; a selection whose pattern ids are not a permutation of0..bgp.patterns.len()(wrong count, out of range, or repeated — leaving a pattern undescribed) is fail-closed (SeamError::DescriptorMismatch, phaseSourceCombination). Phase 5 (bead sq-1fo4) lands the untrusted-plan binding seam — its CANONICALISATION half ONLY.commit_plan(&selected, &routing, &envelope)reduces the produced plan — the Phase-2 per-pattern source assignment, the Phase-3 disclosed/hidden route, and the Phase-4 declared participation set — to a deterministic, domain-separatedPlanCommitmentwhoseplan_digest()is a singlesparq_mpc::FieldWord(the same SHA-512, length-prefixed,< 2^248public-input wordfederated_bindinguses, under a freshsparq-fedplan-mpc/plan-binding/*/v1\0tag family), i.e. a value shaped to become a public input once a collaborative proof exists to carry it. It binds plan content, not the planner's enumeration order (per-pattern source lists and disclosed-operand lists sorted; patterns ordered by index), and deliberately excludes the Phase-2estimated_cardinality(a cost estimate, not plan semantics; no canonicalf64encoding).revalidate_plan(&claimed, &observed)compares a claimed commitment against an independently recomputed one and fails closed, returningPlanRevalidation::Divergedwith a structuredPlanDivergencelist naming the dropped source (SourceDropped) or re-routed operator (OperatorRerouted) rather than a bare mismatch; a digest mismatch with no field-level difference still yieldsDigestMismatch, soBoundis never returned on a digest mismatch. A routing/envelope disagreement (operator count, label, disclose/hide route, hidden-operator class), a hidden operator whose envelope lists disclosed operands (it runs under MPC and discloses nothing), an envelope participation set that is not exactly the distinct source set the selection assigns (an omitted or an extra participant), a non-permutation pattern-index set, or a duplicated source id in one pattern is refused at commit time (SeamError::DescriptorMismatch, phasePlanBinding). This is a canonicality + transcript-integrity check, NOT a soundness mechanism — a malicious planner computes the plan and its commitment, so one that drops a source can simply commit to the plan it actually ran and pass; it catches accidental divergence (a plan mutated between ratification and execution, a planner/verifier disagreement, a truncating transport). The soundness half —bind_plan_to_proof, the step that would discharge constraint C-A — is DEFERRED: it carries its real signature and returnsSeamError::Deferrednaming its gates, because there is no collaborative proof to bind to (sparq-mpc::proofis an honestNotYetImplementedstub),sq-qhy4+sq-9hrnare pending, and the attachment point is design-record §9 Q3, still OPEN (theFederatedStatementparameter is a proposal for the maintainer, not a settled decision). ThePlanCommitmentcarries plan metadata only — source ids, operator labels, classes and disclosed predicate ids, all already declared by the Phase-4 envelope — so it widens the declared leakage by nothing. The further privacy-bearing work (Phase 5'sbind_plan_to_proofsoundness half, authenticated-input attestation) remains deferred and audit-gated (sq-9hrn/sq-qhy4). Seeresearch/mpc-untrusted-planner-routing-design.md.mpc-protocols— the SOTA / primitive background (secret sharing, garbled circuits, collaborative zk-SNARKs, authenticated-input MPC) behind the trust-model and primitive choices here.noir-circuit-patterns,noir-optimisation,verifiable-credentials-zk,sparql-formal-semantics— the single-prover ZK estate this crate will eventually compose with for the (deferred) collaborative proof.