macula_subscriber_sources (macula v18.2.0)
View SourceWho may deliver to a subscription (macula#147, WP2).
A subscriber declares the sources it accepts facts from, and its own SDK refuses every fact from anywhere else. This is self-protection, not a grant: nothing is minted, published, delegated or administered, because no third party ever relies on another party's declaration.
The declaration is members | {org, Org} | {nodes, Ids}, or open as an explicit opt-out. There is no default: a subscription that names none is refused at subscribe. The threat model and the note that decided what the model left open are on https://github.com/macula-io/macula/issues/147 (sections 2, 5, 6 and 7 of the model).
Where the check runs
At the pool, on the publisher the delivering link verified, once per publication and per matching subscription -- two subscriptions on one topic may declare different sources. {nodes, Ids} costs nothing: it compares the verified publisher against the list, and a node id is not a credential, so it never expires.
members and {org, Org} are a record lookup, and a DHT read never runs in the pool process: lookup/3 runs in a worker, and the pool keeps only the verdict cache and the in-flight set. A verdict is cached under {Realm, Source, Publisher} and is never honoured past the record that produced it.
The bounds (threat model 6.3)
Node keys are free, so an attacker can present every fact from a fresh publisher and cost the subscriber one lookup each. Every bound here fails closed, and each is counted separately -- refused is a verdict, dropped is a budget, held is waiting -- because an operator can tell an attack from a misdeclaration only if the three never merge.
Summary
Types
The sources that rest on a record, and so on a lookup: everything else a declaration may be.
Functions
Whether a {nodes, Ids} source admits Publisher. No lookup, no cache, no expiry: a node id is not a credential, so this answer changes only when a release changes the list (§6.2).
The check's bounds, from the pool's subscribe_sources option. An option that names a bound this module does not know, or a bound that is not a positive integer, does not start the pool: a check that silently ran on the wrong bound looks exactly like one that ran on the right one.
The verdict this key holds at Now. An entry past its own expiry is a miss and is dropped, so a record that has expired is never honoured. A hit is marked as used, which is what eviction and the refresh go by.
The cache key of a verdict: one realm, one source, one publisher. A verdict for one source is never a verdict for another.
Record a verdict for Key, honoured until Until, but never past the cache's own TTL and never past the record it came from. Expired entries of the realm are dropped first, and if the realm is still at its cap its least recently verified entry goes -- so the cap bounds live memory, and the lookup window bounds how often that can happen.
Resolve a subscription's declared sources against the realm keys the pool pinned, before the subscription exists. {nodes, Ids} needs no key; members and {org, Org} rest on a record verified under the realm key, so in a realm whose key the pool never pinned they are refused rather than served open.
Resolve a publisher against a source that rests on a record. Runs in the pool's worker, never in the pool: both reads are DHT calls, and a pool that waited on one would stall delivery for every topic on it.
Whether a cached verdict still in use is due to be refreshed before it expires, so a steady publisher never waits on a lookup.
The refresh of Key did not answer. Its entry keeps serving, and its refresh moment moves to the entry's own expiry: a source that keeps failing costs one lookup per life of the entry, not one per fact, and the lookup that replaces it goes through the window like any other.
Count one lookup against Realms window. Over the cap the fact is dropped and counted without a lookup; the window resets on its own, so this is a rate and not a lifetime budget.
Types
-type bounds() :: #{max_publishers := pos_integer(), held_per_key := pos_integer(), lookups_per_window := pos_integer(), window_ms := pos_integer(), cache_ttl_ms := pos_integer(), refresh_margin_ms := non_neg_integer(), refusal_ttl_ms := pos_integer(), lookup_timeout_ms := pos_integer()}.
-type cache() :: #{key() => {verdict(), non_neg_integer(), non_neg_integer(), non_neg_integer()}}.
-type key() :: {realm(), record_source(), node_id()}.
-type node_id() :: <<_:256>>.
-type outcome() :: {admit, non_neg_integer()} | {refuse, term()} | {unavailable, term()}.
-type realm() :: <<_:256>>.
-type record_source() :: members | {org, binary()}.
-type source() :: record_source() | {nodes, [node_id(), ...]} | open.
The sources that rest on a record, and so on a lookup: everything else a declaration may be.
-type verdict() :: admit | {refuse, term()}.
Functions
Whether a {nodes, Ids} source admits Publisher. No lookup, no cache, no expiry: a node id is not a credential, so this answer changes only when a release changes the list (§6.2).
The check's bounds, from the pool's subscribe_sources option. An option that names a bound this module does not know, or a bound that is not a positive integer, does not start the pool: a check that silently ran on the wrong bound looks exactly like one that ran on the right one.
-spec cache_get(cache(), key(), integer()) -> {ok, verdict(), non_neg_integer(), cache()} | {miss, cache()}.
The verdict this key holds at Now. An entry past its own expiry is a miss and is dropped, so a record that has expired is never honoured. A hit is marked as used, which is what eviction and the refresh go by.
-spec cache_key(realm(), record_source(), node_id()) -> key().
The cache key of a verdict: one realm, one source, one publisher. A verdict for one source is never a verdict for another.
-spec cache_put(cache(), key(), verdict(), non_neg_integer() | undefined, bounds(), integer()) -> cache().
Record a verdict for Key, honoured until Until, but never past the cache's own TTL and never past the record it came from. Expired entries of the realm are dropped first, and if the realm is still at its cap its least recently verified entry goes -- so the cap bounds live memory, and the lookup window bounds how often that can happen.
Resolve a subscription's declared sources against the realm keys the pool pinned, before the subscription exists. {nodes, Ids} needs no key; members and {org, Org} rest on a record verified under the realm key, so in a realm whose key the pool never pinned they are refused rather than served open.
There is no default: undefined is refused, and a subscription whose sources could not be resolved is never registered.
-spec lookup(record_source(), context(), node_id()) -> outcome().
Resolve a publisher against a source that rests on a record. Runs in the pool's worker, never in the pool: both reads are DHT calls, and a pool that waited on one would stall delivery for every topic on it.
members is the publisher's realm member endorsement, read from its slot under the key the pool pinned -- the member itself, never a delegate, because check_member/3 requires member_node to be the publisher. {org, Org} is macula:org_member/4,5, which verifies the org directory and the publisher's delegation and answers the earlier expiry of the two.
-spec refresh_due(non_neg_integer(), integer()) -> boolean().
Whether a cached verdict still in use is due to be refreshed before it expires, so a steady publisher never waits on a lookup.
The refresh of Key did not answer. Its entry keeps serving, and its refresh moment moves to the entry's own expiry: a source that keeps failing costs one lookup per life of the entry, not one per fact, and the lookup that replaces it goes through the window like any other.
-spec window_allows(#{realm() => {integer(), pos_integer()}}, realm(), integer(), bounds()) -> {ok, #{realm() => {integer(), pos_integer()}}} | over.
Count one lookup against Realms window. Over the cap the fact is dropped and counted without a lookup; the window resets on its own, so this is a rate and not a lifetime budget.