Design issue. Edge has a serve gate and no store gate, and every rule below is a store-side rule. Filing the rules and the gap together because the gap is why they don't hold today.
The asymmetry
blob_swarm::scope::admit_blob_serve(scope_native, arrival, content) decides who may READ what we hold. It's good: it judges on the address the frame physically arrived on, never on anything the requester claimed, and refuses ScopeUndeterminable rather than defaulting to public.
Nothing decides what may ENTER our store. A grep for a store-side admission gate returns exactly one function — the serve one. So:
"No one ever stores anything they did not consent to store, or that is not from a community they joined, or is not from someone they trust."
…is presently a property of nothing. It is not weakly enforced; it is unenforced, because the decision point does not exist.
Three reasons the store gate can't be bolted on afterwards
1. Storing IS announcing. persist's put_blob auto-emits a holds_bytes:sha256:* holder attestation. You cannot quietly hold federation-tier content — the act of storing publishes that you hold it, and list_holders is how the swarm finds you. (store_blob_local is the no-announcement variant, and that asymmetry is itself a projection decision: store-and-announce vs store-silently are different consents.)
2. The fountain swarm pushes. In a pull fetch, consent is implicit in the request — you asked. The converger rebalances symbols toward you to hold target_holders copies. Nobody asked. So the store decision must be explicit and must precede the bytes.
3. Eviction is a retention bound, not an admission bound. The converger evicts above target_holders + grace. If admission is ungated, a node accepts everything and evicts later — meaning it did store content it had no right to, briefly, and announced it. Admission and retention must be separately gated; the copy ceiling does not double as a consent gate.
The rules
Three independent axes. All must hold — each refuses on its own, and none implies another.
Axis 1 — Provenance: is this sender approved for this tier?
| tier |
who may cause us to store |
public / federation |
an allowlist. Blessed CI runners initially. |
community |
current members of a community we have joined |
family |
current members of that family |
self |
the owner's own nodes |
The load-bearing point: a valid signature is not authorization. Verification proves who authored the bytes; it says nothing about whether they may consume our disk. Edge has already paid for this confusion in another form — CIRISEdge#564, where the envelope's on_behalf_of_key_id proved authorship of a string and was being read as authorship of the message. Same class. Federation tier is an allowlist, not "anyone whose signature verifies."
Axis 2 — Scope: are we in the audience the content declares?
Reuse the row-plane predicate, do not write a second one. bridge::audience_withholds / cohorts_of / principal_of already answer "am I in this row's audience", backed by persist's shares_cohort_with against persist's own rosters (which is the property that makes it unforgeable by the sender). A blob's ContentScope must resolve through the same path.
A second implementation of "may I hold this" would drift from the first — and per the standing lesson, a cross-repo/cross-plane risk wants the duplication removed, not guarded by a test.
Axis 3 — Local consent: did this operator agree to hold this class at all?
Independent of both above. A blessed CI runner's manifest satisfies Axes 1 and 2 and must still be refused if the operator has not consented to host public blobs. Consent is the operator's, and the substrate is not entitled to infer it.
This axis has a live hole. swarm/runtime.rs:1212:
// Determine consent state. v5.2.0 defaults to Active —
// revocation routing rides the inbound dispatch path
// (when a `consent:state:revoked` envelope arrives, edge
// calls into `register_revocation` on the runtime; that
// wiring lands in v5.3.0 when the consent envelope shape
// is normative). For the v5.2.0 cut, the converger acts on
// the substrate-tier verdicts driven by `holders_observed` alone.
let consent = ConsentState::Active;
The v5.3.0 wiring never landed. So the converger's only consent input is hardcoded to the permissive value. compute_consent_aware_rarity handles Revoked → hard-delete correctly and is thoroughly unit-tested; it is simply never told. "Consent revoked ⇒ hard delete" is dead code in production.
What to build
admit_blob_store(...) -> StoreAdmission — the missing gate, evaluated before bytes are accepted, on every inbound path: the swarm's converger, BlobChunkBody responses, and any direct put. Mirrors admit_blob_serve's shape (typed refusals, fail-closed on undeterminable), and returns a store-and-announce / store-local-only / refuse disposition, since persist's two write doors make that a real trichotomy.
- Wire consent revocation — close the v5.3.0 stub so Axis 3 has an input. Until then, do not claim the rule holds.
- Federation allowlist — the blessed-CI-runner set, as data, sourced from the same attestation plane as everything else rather than config. (Which authority blesses a CI key is a Registry/Server question, not edge's; edge consumes the roster.)
- Per-axis refusal reasons, so a refusal says which rule refused. A single
PolicyDenied would make the three axes indistinguishable in the field, and they have different remedies: join the community, get blessed, or grant consent.
Deliberately not proposed
Fountain coding for the manifest cut. A few-KB public immutable manifest does not need rateless coding to survive; the plain chunk path plus these gates is the whole job. Fountain earns its keep on large, scarce, or scope-private content — and the copy bounds only become meaningful once admission is gated, per reason 3 above.
Related: CIRISPersist#821 (serve-granularity + symbol-representation questions), CIRISServer#25 (the manifest sequencing).
Design issue. Edge has a serve gate and no store gate, and every rule below is a store-side rule. Filing the rules and the gap together because the gap is why they don't hold today.
The asymmetry
blob_swarm::scope::admit_blob_serve(scope_native, arrival, content)decides who may READ what we hold. It's good: it judges on the address the frame physically arrived on, never on anything the requester claimed, and refusesScopeUndeterminablerather than defaulting to public.Nothing decides what may ENTER our store. A grep for a store-side admission gate returns exactly one function — the serve one. So:
…is presently a property of nothing. It is not weakly enforced; it is unenforced, because the decision point does not exist.
Three reasons the store gate can't be bolted on afterwards
1. Storing IS announcing. persist's
put_blobauto-emits aholds_bytes:sha256:*holder attestation. You cannot quietly hold federation-tier content — the act of storing publishes that you hold it, andlist_holdersis how the swarm finds you. (store_blob_localis the no-announcement variant, and that asymmetry is itself a projection decision: store-and-announce vs store-silently are different consents.)2. The fountain swarm pushes. In a pull fetch, consent is implicit in the request — you asked. The converger rebalances symbols toward you to hold
target_holderscopies. Nobody asked. So the store decision must be explicit and must precede the bytes.3. Eviction is a retention bound, not an admission bound. The converger evicts above
target_holders + grace. If admission is ungated, a node accepts everything and evicts later — meaning it did store content it had no right to, briefly, and announced it. Admission and retention must be separately gated; the copy ceiling does not double as a consent gate.The rules
Three independent axes. All must hold — each refuses on its own, and none implies another.
Axis 1 — Provenance: is this sender approved for this tier?
public/federationcommunityfamilyselfThe load-bearing point: a valid signature is not authorization. Verification proves who authored the bytes; it says nothing about whether they may consume our disk. Edge has already paid for this confusion in another form — CIRISEdge#564, where the envelope's
on_behalf_of_key_idproved authorship of a string and was being read as authorship of the message. Same class. Federation tier is an allowlist, not "anyone whose signature verifies."Axis 2 — Scope: are we in the audience the content declares?
Reuse the row-plane predicate, do not write a second one.
bridge::audience_withholds/cohorts_of/principal_ofalready answer "am I in this row's audience", backed by persist'sshares_cohort_withagainst persist's own rosters (which is the property that makes it unforgeable by the sender). A blob'sContentScopemust resolve through the same path.A second implementation of "may I hold this" would drift from the first — and per the standing lesson, a cross-repo/cross-plane risk wants the duplication removed, not guarded by a test.
Axis 3 — Local consent: did this operator agree to hold this class at all?
Independent of both above. A blessed CI runner's manifest satisfies Axes 1 and 2 and must still be refused if the operator has not consented to host public blobs. Consent is the operator's, and the substrate is not entitled to infer it.
This axis has a live hole.
swarm/runtime.rs:1212:The v5.3.0 wiring never landed. So the converger's only consent input is hardcoded to the permissive value.
compute_consent_aware_rarityhandlesRevoked→ hard-delete correctly and is thoroughly unit-tested; it is simply never told. "Consent revoked ⇒ hard delete" is dead code in production.What to build
admit_blob_store(...) -> StoreAdmission— the missing gate, evaluated before bytes are accepted, on every inbound path: the swarm's converger,BlobChunkBodyresponses, and any direct put. Mirrorsadmit_blob_serve's shape (typed refusals, fail-closed on undeterminable), and returns a store-and-announce / store-local-only / refuse disposition, since persist's two write doors make that a real trichotomy.PolicyDeniedwould make the three axes indistinguishable in the field, and they have different remedies: join the community, get blessed, or grant consent.Deliberately not proposed
Fountain coding for the manifest cut. A few-KB public immutable manifest does not need rateless coding to survive; the plain chunk path plus these gates is the whole job. Fountain earns its keep on large, scarce, or scope-private content — and the copy bounds only become meaningful once admission is gated, per reason 3 above.
Related: CIRISPersist#821 (serve-granularity + symbol-representation questions), CIRISServer#25 (the manifest sequencing).