Some facts no scanner can establish. Whose is this machine? — Fleet can see
which account is signed in (evidence of use), but assignment is something only
the holder can assert. Is this list still current? — editing it on the 3rd
does not mean anyone confirmed the other 39 entries that day. A human signing
off on data is one of the most common shapes in a compliance- or
correctness-oriented graph, and ReactiveDag.Attestation makes it a substrate
concern: records, content-addressed bases, eligibility, quorum, and gated
edges, with propagation identical to any other input.
The model in five words each
- Record — an immutable fact: who affirmed or rejected what scope of a cell's data, what it looked like (a content digest), when, and — for a rejection — why. Never updated, never deleted.
- Stance — a signer's latest record for a scope. History stays; only the latest word per signer has force.
- Scope — what was signed: one row (
{:key, k}) or the set a filter selects ({:filter, key_scope}). - Basis — a digest of the rows the scope selected at signing. The signature binds to what was there, not to the key.
- Force — whether a stance currently counts: a read-time predicate, never a stored status.
The smaller shape: sign-off as columns
ReactiveDag.Attestation is the full apparatus — records, eligibility, quorum,
tolerance, gated edges. Often you do not need it, and the piece that is
genuinely hard to get right is available on its own.
A row can carry derived data and signing state together:
attributes do
attribute :key, :string, primary_key?: true
attribute :serial, :string # derived
attribute :encrypted, :boolean # derived
attribute :basis, :string # the digest when signed
attribute :signed_by, :string
attribute :signed_at, :utc_datetime
endWhere the row and its basis come from
Both emerge from the same into: — the fold that computes the row also
digests the rows it summarised:
attributes do
attribute :key, :string, primary_key?: true
attribute :owner, :string
attribute :count, :integer
attribute :basis, :string # emerges WITH the row
attribute :signed_by, :string # written by a signature
attribute :signed_basis, :string
end
reactive do
recompute_by :owner, to: :machines, from: :owner
reduce into: fn {owner}, machines ->
%{owner: owner,
count: length(machines),
basis: Basis.digest(machines, fields: [:key, :serial])}
end
endThat they come from one place is the load-bearing part: a basis computed anywhere else could describe a different moment than the row beside it, and the whole mechanism rests on the two agreeing.
Then a signature is an ordinary write (signed_by, signed_basis), and the
comparison is a predicate:
Basis.matches?(row.signed_basis, current_rows, fields: [:key, :serial])
# …or simply row.basis == row.signed_basis, since the node keeps :basis currentWhat that buys
The recompute machinery does the rest. A machine's serial is corrected:
dirties_onmarks it, carrying the row's prior state;- only that owner's unit is claimed — other owners are untouched;
- the fold reruns, and
:basismoves with:count; basis != signed_basis, so the signature has lapsed — with nothing revoking it, and no stored copy of the data to drift.
An unrelated owner's machine changing leaves the signature standing, because
their unit was never claimed. That precision is recompute_by's doing, not the
basis's.
The signature applies only while the rows still match, so a correction, an addition or a removal lapses it automatically — no revocation bookkeeping, and nothing stored that can drift out of step with the data.
"Is this attested?" is then a predicate on a column, not a separate node — and
the dirties_on write that records a signature re-derives it like any other
change.
Versioning is why Basis is in the library rather than your app. Every
digest carries its scheme version and is compared under that version, so
changing the canonicalization cannot lapse every signature in the estate on
deploy. An unknown version never matches and never raises — a digest from a
future build degrades to "re-check", not to a crash on the read path.
Reach for the full apparatus below when you need quorum (:any, :all,
{:n_of, k}), eligibility derived from another cell, tolerance (a
signature expiring after N days), or gated edges. Those are what it adds
over a column.
Declaring a requirement
Policy is declared once, on the node that owns the raw data:
defmodule MyApp.Machines do
use Ash.Resource, data_layer: Ash.DataLayer.Simple, extensions: [ReactiveDag.Node]
reactive do
op :source
leaf? true
attestation :machine_ownership do
signers :machine_holders # an ELIGIBILITY CELL's id — see below
join &MyApp.Policy.holder?/2 # (scope, eligibility_key) -> who | nil
quorum :any # :any | :all | {:n_of, k}
tolerance days: 180 # how long a signature holds
end
end
endsigners names a cell, not a list and not a callback. Who may sign is
data — the machine's holder, the members of a role — and that data changes. As
a cell, eligibility is a real input edge of every attested view: a role
revocation propagates through the drain like any other change, and the lineage
of a green verdict shows the roles data among the things it rests on. (An
opaque callback would hide exactly that join.)
join interprets the eligibility cell's key grammar, which is yours: given a
scope and one eligibility key, it returns the identity licensed to sign, or
nil.
Consuming it: two spellings, one shape
A declared attested view — both cells exist in the graph, the raw list and the signed list, and a consumer picks per edge:
defmodule MyApp.ConfirmedMachines do
use Ash.Resource, data_layer: Ash.DataLayer.Simple, extensions: [ReactiveDag.Node]
reactive do
id :confirmed_machines
attested over: :machines, requirement: :machine_ownership
end
endA gated edge — signing as a property of the dependency:
reactive do
id :ownership_verdict
verdict? true
op :reconcile
compute MyApp.Reconcile
depends_on [:machines, {:machines, gate: :machine_ownership}]
# ^ denominator: raw ^ numerator: signed rows only
endgate: is sugar, not a second mechanism: assembly interposes an anonymous
attested cell (id machines@machine_ownership) between the raw cell and the
consumer — the same shape a declared attested node lowers to, deduped across
consumers. The Cell IR is unchanged; the graph, drain, and lineage see ordinary
cells with inputs: [raw, eligibility, store].
The denominator is never gated
Data that "requires attestation" must still flow somewhere. If every leg of a verdict consumes through the gate, an unsigned machine is simply absent — and the guarantee goes vacuously green, having swallowed its own denominator. Assembly enforces this: a verdict cell whose every evidence path passes through one requirement's blocking gate raises at graph build as structurally vacuous. Keep one leg on the raw cell; the join between the legs is what makes the shortfall visible.
Non-blocking: best effort, distinguished
Not every consumer should withhold unsigned data — a report, a metric, a downstream computation often wants the best available value while keeping signed and unsigned distinguishable. That is a mode of the view, declared where it is consumed:
ref :machines, gate: :machine_ownership # blocking (default)
ref :machines, gate: :machine_ownership, mode: :annotate # non-blocking
depends_on [{:machines, gate: :machine_ownership, mode: :annotate}]
attested over: :machines, requirement: :machine_ownership, mode: :annotateForce evaluation is identical in both modes — who signed, whether it still
counts, the three lapse predicates. The mode changes only what a
not-yet-signed row projects to: :require writes it as pending (withheld
from consumers of the signed set); :annotate writes it as unsigned — it
flows, best effort, and stays distinguishable from covered.
Two consequences:
- A rejection bites in both modes. Unsigned means nobody has vouched;
refused means someone said the data is wrong — passing that through as
best-effort would launder the objection.
refusedstaysrefused. - The vacuity lint ignores annotate views. They withhold nothing, so they cannot swallow a denominator; an all-annotate-gated verdict is legitimate.
The two modes are two projections, so a graph consuming both gets two
interposed cells (machines@machine_ownership and
machines@machine_ownership~annotate) over the same records — signing once
moves both.
What the view computes
For each row of the raw cell, an admission, projected per the view's mode:
| state | :require writes | :annotate writes | meaning |
|---|---|---|---|
| affirmed | covered | covered | in-force affirmations meet the quorum |
| pending | pending | unsigned | nobody has signed — or every signature lapsed |
| refused | refused | refused | an in-force rejection exists |
The status vocabulary is overridable per requirement (statuses:); the
defaults compose with ReactiveDag.Verdict unchanged. Affirmed rows are put
with strength: "attested" in the writer opts — the spine-only default writer
drops it; a host writer with a strength column stamps it.
Force: three ways a signature lapses
A record is immutable history; whether it counts is evaluated at read time, and can fail three independent ways:
| lapse | meaning | remedy |
|---|---|---|
:basis | the world moved — what was signed is not what is there | re-present, re-ask |
:tolerance | time passed — the assertion aged out | re-affirm |
:eligibility | authority moved — the licence to sign was withdrawn | a different signer |
All three read as pending — never as green, never silently as rejected — but the evaluation names the failed predicate per lapsed signer, because a UI must say which remedy it is asking for.
The basis is what makes this self-maintaining. It digests the selected rows'
(key, status) at signing; at evaluation the digest is recomputed from current
data. The world moving lapses coverage with no revocation bookkeeping: a
serial reused by a rebuilt host, a corrected row, a filter selecting a new
member — all simply stop matching.
Withdrawal: clearing your word
Between affirming and rejecting sits a third act: withdraw — "I no longer
vouch" (handing the machine back, leaving the team), with no claim that
anything is wrong. Attestation.withdraw/4 supersedes the signer's stance
like any record but carries no force in either direction: the scope
returns to pending (unaffirmed, re-askable), never to refused, and other
signers' affirmations are untouched. Its reason is optional — withdrawing
asserts nothing about the data, so nothing must be explained.
Rejection is sticky, and reasoned
A rejection requires a reason (reject/5 raises without one): an
affirmation asserts the data as presented, but a rejection asserts it is
wrong, and a bare "no" leaves whoever must act with nothing to fix.
Refused is deliberately not out-voted by other signers' affirmations. Under a matching basis it stays refused; what clears it is the world changing (the data is corrected → the rejection's basis lapses like anything else) or the rejector's own later affirmation (stance = latest record per signer). A system that re-asks until it gets a yes is laundering attestations, not collecting them.
Quorum
:any (one in-force affirmation), :all (every currently-eligible signer —
dual control), {:n_of, k} (four-eyes counting). Quorum is evaluated over the
currently-eligible set, and an empty eligible set never affirms:
nobody-may-sign is a gap in the eligibility data, not a satisfied quorum.
Signing, and how it propagates
{:ok, record, changed} =
ReactiveDag.Attestation.affirm("machines", {:key, "AAA111"}, "alice@u2i.com")
{:ok, record, changed} =
ReactiveDag.Attestation.reject("machines", {:key, "AAA111"}, "bob@u2i.com",
"this serial is a rebuild; the machine was recycled")
# the third act: clear your own word (reason optional — see "Withdrawal" above)
{:ok, record, changed} =
ReactiveDag.Attestation.withdraw("machines", {:key, "AAA111"}, "alice@u2i.com")The store surfaces in the graph as one leaf cell
(ReactiveDag.Attestation.leaf_cell/0, default "attestations", injected
automatically into any graph that consumes the vocabulary — an attested
view or a gate:d edge; a requirement declared but never consumed is inert
and injects nothing). Signing is therefore a
leaf write like any other: mark the returned keys dirty on that leaf, drain,
and every attested view downstream re-evaluates — structurally identical to a
scan finishing. Sub-second, and it cannot fail on a vendor being down: it
reads only your own database.
Reads: stances/1 (latest per scope × signer — what evaluation consumes) and
history/2 (the full append-only trail — what an auditor consumes).
Storage: a host-defined Ash resource
Records live in an Ash resource the host defines — the same pattern as
ash_authentication's token resource. The ReactiveDag.Attestation.Record
extension stamps the required shape (the record attributes, a :sign create,
a primary read); the host chooses repo, table, and domain, and the library
reaches the resource via config:
defmodule MyApp.Attestation.Record do
use Ash.Resource,
domain: MyApp.Attestations,
data_layer: AshPostgres.DataLayer,
extensions: [ReactiveDag.Attestation.Record]
postgres do
table "attestation_records"
repo MyApp.Repo
end
attestation_record do
who_from_actor fn actor -> to_string(actor.email) end
end
endconfig :reactive_dag, attestation_resource: MyApp.Attestation.RecordMigrations are generated (mix ash.codegen add_attestation_records), not
hand-written. And because it is an ordinary resource of yours, everything Ash
composes onto it:
who_from_actor— with an actor on the:signaction, the signer is forced from the actor. Impersonation is prevented at the write, not merely discounted at read time by the eligibility check. (affirm/rejectpassactor:through.)- Policies — signing authorization in the same framework as the rest of your app.
- Notifications — pub_sub a signing straight into your refresh.
Two invariants are enforced by the extension rather than left to convention:
- append-only — a verifier fails compilation if the resource declares any update or destroy action. Rows are never mutated; stance is a read; force is a predicate; the history is the audit trail.
- reasoned rejection — the
:signaction errors a"reject"with a blankreason, so even writes that bypassReactiveDag.Attestationobey the rule.
basis_version pins each record to the digest scheme it was signed under, and
an unknown (future) version evaluates as a basis mismatch — re-ask, never a
crash. This is what lets the canonicalization evolve without a deploy lapsing
every attestation in the estate.
Set-level scopes: signing the boundary
{:filter, key_scope} signs the set a filter selects — which is a
different claim from signing each member. "These are all of my machines" is
about the subset and its boundary: a member appearing inside the filter
changes the basis, and the completeness claim lapses, correctly, even though
every previously-signed member is untouched.
A requirement declares its set-level shape with scope:
attestation :estate_complete do
scope {:filter, {:prefix, "%"}} # ONE instance: the whole set
instance_key "estate" # the view row's key (default "all")
signers :admins
join fn _scope, admin -> admin end
tolerance days: 90
end
attestation :holdings_complete do
# one instance PER PERSON, derived from the eligibility cell itself:
# each candidate's own subset, signable only by them. Return nil (or just
# don't match) for an eligibility key that derives no instance.
scope {:filter_by, fn
"SER" <> _ = pair ->
[_serial, email] = String.split(pair, "|", parts: 2)
{email, {:segment, 2, "|", email}}
_other ->
nil
end}
signers :machine_match
join fn {:filter, {:segment, 2, "|", email}}, pair ->
if String.ends_with?(pair, "|" <> email), do: email
end
endThe attested view over a filter-shaped requirement writes one row per scope
instance (the estate row; one row per person), not one per raw row — the
completeness cell hanging off the population, exactly the carrier the
freshness ADR calls for. Unaffirmed instances write pending (unknown, never
green); a member appearing in someone's subset moves their instance's basis
and lapses their claim.
Design rationale
The machinery/policy split follows what is domain-independent: records, bases, force, quorum counting, and propagation mention no domain; which cells require attestation, who is eligible, and the tolerances are irreducibly the host's. The full design record — including why eligibility must be an edge, why refusal is sticky, and why the basis is content-addressed — lives in the host project's ADR-002 (attestation), building on its ADR-001 (freshness/tolerance).