Skip to content

Blob projection rules: edge has a serve gate and no store gate — three axes (provenance / scope / consent), and the consent input is hardcoded Active #581

Description

@emooreatx

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

  1. 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.
  2. Wire consent revocation — close the v5.3.0 stub so Axis 3 has an input. Until then, do not claim the rule holds.
  3. 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.)
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions