The converger's eviction decision counts raw, unverified holding claims. A signed lie inflates the holder count and makes honest nodes delete real copies. The substrate primitive that prevents this exists, is tested, and is never called by the runtime.
The gap
swarm/runtime.rs:1200 feeds eviction from:
let observed_count = snapshot.distinct_holders(&content_id);
and distinct_holders is map.len():
fn distinct_holders(&self, content_id: &str) -> u32 {
self.inner.get(content_id).map_or(0, |m| u32::try_from(m.len()).unwrap_or(u32::MAX))
}
That count goes straight into should_eject_with_diversity(observed_count, …), which evicts once it exceeds target_holders + 15% (30 + 4 = 34 at defaults).
Meanwhile holonomic/swarm_rarity.rs already defines the gate:
pub enum HoldingClaimVerification {
PossessionVerified, // sig verified AND a possession challenge answered
SignatureOnly, // sig valid, possession unchallenged — HALF weight,
// "to bound the lying-holder force-evict surface"
Unverified, // does NOT count
}
with claim_weight(), and compute_consent_aware_rarity(content_id, symbol_id, claims_with_verification, consent) taking &[(FountainHoldingClaim, HoldingClaimVerification)]. The runtime calls the unweighted compute_rarity_score(&content_id, *sym, &all_claims) instead, and never constructs the paired form at all.
So the discount designed to bound this exact attack is inert.
The attack
Signing a FountainHoldingClaim is cheap and requires no bytes. A peer (or a handful of Sybils under one operator) claims to hold content_id X. Honest nodes' observed_count crosses target_holders + grace, their convergers fire EjectionVerdict::Eject, and they delete real copies of content the liar never had.
Cost to attacker: one signature per claim. Cost to federation: real data, and the loss compounds — each eviction lowers the true holder count while the inflated count stays high.
Note the direction. This is not a storage-flooding attack (that's the store gate, #581). It is an availability attack via forced eviction, and it runs through the one code path that deletes.
The rule this makes explicit — fail toward retention
The two error directions are not symmetric:
- Over-count → data loss. Unrecoverable if the last honest holders evict.
- Under-count → over-replication. Wasted disk, self-correcting, recoverable.
So: a claim that RAISES the holder count must clear a higher bar than one that lowers it. Eviction is the privileged operation, not admission.
Fix
- Feed the weighted count into eviction. Track
HoldingClaimVerification alongside each observed claim and pass the paired form to compute_consent_aware_rarity. Unverified contributes 0; SignatureOnly contributes half. That alone doubles the number of Sybil identities needed.
- Spot-check before deleting. When the weighted count crosses the eviction threshold, challenge a sample of claimants for a fresh symbol hash before dropping a local copy. Failed or unanswered challenges demote the claim to
Unverified. This is the PossessionVerified arm the enum already anticipates and nothing yet produces.
- Never evict the last locally-verified copy on unverified evidence. The runtime already avoids evicting the last local copy of a rare symbol via
local_symbol_rarity; extend that so unverified remote claims cannot be the sole justification for a delete.
register_observed_claim is the natural place for (1) — it currently accepts a bare FountainHoldingClaim and should record the verification state it was admitted under.
Why this and #581 are different issues
#581 governs what we accept into the store. This governs what we delete from it. They share the fountain runtime and nothing else: the store gate refuses on provenance/scope/consent before bytes land; this one hardens the count that authorises deletion. Fixing either leaves the other open.
Related: the sibling stub
The same converger hardcodes let consent = ConsentState::Active; (runtime.rs:1212), so ConsentState::Revoked — which should_eject_above_target honours above all rarity and count logic — is never signalled in production either. Both are the same shape: a well-built, well-tested substrate primitive with no production caller. Worth fixing together, since both feed the same decision.
The converger's eviction decision counts raw, unverified holding claims. A signed lie inflates the holder count and makes honest nodes delete real copies. The substrate primitive that prevents this exists, is tested, and is never called by the runtime.
The gap
swarm/runtime.rs:1200feeds eviction from:and
distinct_holdersismap.len():That count goes straight into
should_eject_with_diversity(observed_count, …), which evicts once it exceedstarget_holders + 15%(30 + 4 = 34 at defaults).Meanwhile
holonomic/swarm_rarity.rsalready defines the gate:with
claim_weight(), andcompute_consent_aware_rarity(content_id, symbol_id, claims_with_verification, consent)taking&[(FountainHoldingClaim, HoldingClaimVerification)]. The runtime calls the unweightedcompute_rarity_score(&content_id, *sym, &all_claims)instead, and never constructs the paired form at all.So the discount designed to bound this exact attack is inert.
The attack
Signing a
FountainHoldingClaimis cheap and requires no bytes. A peer (or a handful of Sybils under one operator) claims to holdcontent_idX. Honest nodes'observed_countcrossestarget_holders + grace, their convergers fireEjectionVerdict::Eject, and they delete real copies of content the liar never had.Cost to attacker: one signature per claim. Cost to federation: real data, and the loss compounds — each eviction lowers the true holder count while the inflated count stays high.
Note the direction. This is not a storage-flooding attack (that's the store gate, #581). It is an availability attack via forced eviction, and it runs through the one code path that deletes.
The rule this makes explicit — fail toward retention
The two error directions are not symmetric:
So: a claim that RAISES the holder count must clear a higher bar than one that lowers it. Eviction is the privileged operation, not admission.
Fix
HoldingClaimVerificationalongside each observed claim and pass the paired form tocompute_consent_aware_rarity.Unverifiedcontributes 0;SignatureOnlycontributes half. That alone doubles the number of Sybil identities needed.Unverified. This is thePossessionVerifiedarm the enum already anticipates and nothing yet produces.local_symbol_rarity; extend that so unverified remote claims cannot be the sole justification for a delete.register_observed_claimis the natural place for (1) — it currently accepts a bareFountainHoldingClaimand should record the verification state it was admitted under.Why this and #581 are different issues
#581 governs what we accept into the store. This governs what we delete from it. They share the fountain runtime and nothing else: the store gate refuses on provenance/scope/consent before bytes land; this one hardens the count that authorises deletion. Fixing either leaves the other open.
Related: the sibling stub
The same converger hardcodes
let consent = ConsentState::Active;(runtime.rs:1212), soConsentState::Revoked— whichshould_eject_above_targethonours above all rarity and count logic — is never signalled in production either. Both are the same shape: a well-built, well-tested substrate primitive with no production caller. Worth fixing together, since both feed the same decision.