Explicit composite RFC 9421 verification policy.
new/1 builds the policy that RequestSeal.verify_quorum/3 evaluates:
a mode, a counting unit, 1 to 16 signer slots (each with its own
RequestSeal.Policy), and the handling of unexpected and invalid labels.
Labels never count. Every mode requires all configured required slots and
at least one qualifying signature. :all requires at least one required
slot; :any requires one eligible signer; thresholds count distinct
qualifying units. Every signature must meet its own slot's complete policy.
Each slot receives at most one signature; each label and each identity
class occupies at most one slot across every counting unit. Observations of
one label merge all its known identities and any unknown observation into
one class. Unknown observations share a pool. A label is known if any
eligible verification supplies a known identity. Required slots must be
filled in every mode; an unfilled bound slot fails its binding. No coverage
union is performed.
verify_quorum/3 requires replay: :not_required in every slot policy.
A required replay policy returns :invalid_policy, layer :input, before
any callback or store claim, including in optional or unmatched slots.
Composite replay requires one claim after the complete quorum, bindings,
and negotiation pass; per-label claims can consume nonqualifying candidates.
Required replay is rejected until that composite replay design exists.
Slot content checks use the message's retained body only. Quorum
verification accepts no :representation or :digest_state option;
streamed or unavailable content cannot qualify when a digest is required.
Assignment maximizes counting units, then filled slots, in canonical slot-id and candidate-label order, preferring challenge labels. Declaration and wire order cannot change the chosen set; output lists retain configured slot order. Within the configured input limits and with existing covered bytes preserved, adding an unrelated optional signature preserves a feasible quorum except:
unexpected: :rejectrejects a new label with no selector-eligible slot.invalid: :rejectrejects a new eligible label that fails every slot policy.- A newly verified label spanning identity classes merges them and can reduce the count or reject the quorum. This includes a label observed as unknown under one slot and known under another: it bridges the shared unknown pool into the known class, reducing capacity for unknown-only labels as well.
An unfillable required slot returns :quorum_not_met before binding checks,
even when a binding also fails. Once required slots can be filled, an unfillable
bound slot or inconsistent binding returns :binding_unsatisfied. After a valid
assignment is found, an unmet mode or threshold returns :quorum_not_met.
Principal and role counting bindings establish no attribution. Key
identities stay internal. Unknown key equivalence cannot count in key-unit
quorums; optional unknown identities are ignored, while a required slot with
only unknown candidates rejects with :ambiguous_key_identity.
Principal/role policies conservatively assign the shared unknown pool to at
most one slot.
Bindings search the complete eligible candidate pool, including alternatives
with the same key. Nested bindings require both inner dictionary members;
same-parameter bindings require equal, non-nil values. Negotiation searches
the verified, selector-eligible pool after key-unit filtering. A challenge
label can fulfill negotiation without counting as a unit; it appears in
qualifying and signatures only when assigned. A tampered challenge fails.
Verification performs at most 64 × 16 label-slot attempts. Without bindings,
key, principal, and shared-principal role units use polynomial matching.
Other assignments use exact search with a 131,072-node budget. On exhaustion,
a best-so-far assignment is returned only if it satisfies the mode, required
slots, and bindings; otherwise the result is :limit, layer :input.
Selector-free bound slots populated by trusted signers can exhaust that budget;
the bound prevents unbounded search and never produces partial success.
Summary
Functions
Construct a bounded composite policy without invoking a callback.
Types
Functions
@spec new(map()) :: {:ok, t()} | {:error, RequestSeal.Error.t()}
Construct a bounded composite policy without invoking a callback.
Parameters
attrs- map with all five required keys and any of the two optional keys. Unknown keys reject.:mode-:all,:any, or{:threshold, n}withnfrom 1 to:max_signatures.:allrequires at least one required slot.:unit-:key,:principal, or{:role, shared_principal: boolean}.:slots- list of 1 to 16 slot maps, described below.:unexpected-:rejector:ignore, for a label with no selector-eligible slot.:invalid-:rejector:ignore, for an eligible label that fails every slot policy.:bindings- optional, default[], at most 64 unique entries:{:nested, outer: id, inner: id}requires the outer to cover both the inner's actual"signature-input";key="<inner-label>"and"signature";key="<inner-label>"dictionary members;{:same_parameter, name, ids}(nameis"nonce","tag", or"created"; 1 to 16 unique slot ids) requires identical, present values across the selected slots. Bindings must reference configured slots and cannot bind a nested signature to itself.:max_signatures- optional, default 16 (1 to 64); thresholds cannot exceed this bound.
Each slot requires a unique atom :id (not nil, true, or false), a
valid RequestSeal.Policy as :policy, and Boolean :required. Optional
:label (a valid signature label) and :tag (1 to 256 bytes) selectors
default to nil (any); :principal (1 to 256 bytes) and :role (an atom)
default to nil. Principal counting requires a principal in every slot. Role
counting additionally requires a role in every slot. With
shared_principal: false, one principal counts toward only one role.
Slot policy :content must be :not_required or a Content-Digest policy
(kind: :content); representation digest policies reject, including in
optional or unmatched slots.
Returns
{:ok, %RequestSeal.Quorum{}}- slots normalized with nil:label,:tag,:principal, and:roledefaults. Inspection shows onlymode,unit, andmax_signatures.{:error, %RequestSeal.Error{}}- always reason:invalid_quorum, layer:input, for unknown units, malformed choices, duplicate or invalid bindings, or a non-map argument. Meanings:RequestSeal.Error.
Examples
iex> {public, _secret} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, key} = RequestSeal.PublicKey.import({:ed25519, public}, :raw)
iex> {:ok, policy} =
...> RequestSeal.Policy.new(%{
...> algorithms: ["ed25519"],
...> components: ~s[("@method" "@path")],
...> key_resolver: fn _ -> {:ok, %{algorithm: "ed25519", key: key}} end,
...> freshness: %{clock: fn -> System.system_time(:second) end, max_age: 300, skew: 5, require_expires: false},
...> content: :not_required,
...> replay: :not_required
...> })
iex> slots = [
...> %{id: :author, policy: policy, required: true, label: "author", principal: "writer@example.org", role: :author},
...> %{id: :editor, policy: policy, required: true, label: "editor", principal: "editor@example.org", role: :editor}
...> ]
iex> {:ok, quorum} =
...> RequestSeal.Quorum.new(%{
...> mode: :all,
...> unit: {:role, shared_principal: false},
...> slots: slots,
...> unexpected: :ignore,
...> invalid: :reject,
...> bindings: [{:nested, outer: :editor, inner: :author}]
...> })
iex> {quorum.mode, quorum.max_signatures, Enum.map(quorum.slots, & &1.tag)}
{:all, 16, [nil, nil]}
iex> {:error, error} =
...> RequestSeal.Quorum.new(%{
...> mode: :all,
...> unit: :key,
...> slots: [%{id: :author, policy: policy, required: false}],
...> unexpected: :ignore,
...> invalid: :ignore
...> })
iex> {error.reason, error.layer}
{:invalid_quorum, :input}