Fork repair
Mix.install([
{:charter_agreement_protocol, "~> 0.6.0"},
{:charter_agreement_signer, "~> 0.5.0"}
])
What this notebook does
This notebook breaks a charter on purpose and then repairs it. Both parties accept two different revisions that share the same revision number, so the charter forks. CAP reports the fork as evidence and refuses to pick a side. The parties then accept a repair revision that replaces both branches, and a single revision governs again.
Artifacts are signed with
charter_agreement_signer, as in the
charter tour, with one exception. The fork needs a party that signs
something CAP's producer refuses to build, so one helper below signs those acceptances by
hand with :crypto. That is the only raw signing in the notebook, and it exists only to
play the misbehaving party.
The same flow runs as a script in examples/supplier_fork_demo.exs in the repository. Demo
keys only; never use them for anything real.
Two parties and their descriptors
The same demo key handle as the tour: a module with sign/2 and key_identity/1 over an
in-memory Ed25519 key.
alias CharterAgreementProtocol, as: CAP
alias CharterAgreementProtocol.{Acceptance, Canonicalization, Digest, Limits}
defmodule DemoKey do
@behaviour CharterAgreementSigner
def new(kid, seed_byte) do
{public, private} = :crypto.generate_key(:eddsa, :ed25519, :binary.copy(<<seed_byte>>, 32))
%{kid: kid, public: public, private: private}
end
@impl true
def sign(message, key), do: {:ok, :crypto.sign(:eddsa, :none, message, [key.private, :ed25519])}
@impl true
def key_identity(key), do: {:ok, {key.kid, key.public}}
end
issuer_key = DemoKey.new("issuer-key", 1)
acceptor_key = DemoKey.new("acceptor-key", 2)
[issuer_key.kid, acceptor_key.kid]
Each party signs a descriptor for its key, and we keep its digest, which is the party's identifier:
publish = fn key ->
claims = %{
"protocol_revision" => 2,
"descriptor_number" => 1,
"verification_keys" => [
%{
"key_id" => key.kid,
"algorithm" => "Ed25519",
"public_key" => Base.url_encode64(key.public, padding: false),
"status" => "active"
}
],
"attestation_hints" => [],
"extensions" => %{"critical" => %{}, "optional" => %{}},
"effective_from" => "2026-08-25T10:00:00Z"
}
{:ok, %{descriptor: compact}} = CharterAgreementSigner.sign_descriptor(claims, {DemoKey, key})
{:ok, decoded} = CAP.decode_party_descriptor(compact, Limits.default())
%{key: key, compact: compact, digest: CAP.descriptor_digest(decoded)}
end
issuer = Map.put(publish.(issuer_key), :role, "issuer")
acceptor = Map.put(publish.(acceptor_key), :role, "acceptor")
parties = [issuer, acceptor]
descriptors = [issuer.compact, acceptor.compact]
{:ok, issuer_chain} = CAP.verify_descriptor_chain([issuer.compact], Limits.default())
issuer_chain.topology
Three revisions: a genesis and two competing successors
revision builds one charter revision with encode_charter_revision/1 and reads back its
digest with revision_digest/1. Only the revision number, the legal text and the links to
earlier revisions change between calls.
revision = fn number, legal_text, links ->
claims =
Map.merge(links, %{
"protocol_revision" => 2,
"revision_number" => number,
"parties" => [
%{"party_descriptor_digest" => issuer.digest, "role" => "issuer"},
%{"party_descriptor_digest" => acceptor.digest, "role" => "acceptor"}
],
"legal_text" => %{
"content_digest" => :legal_text |> Digest.hash(legal_text) |> Digest.to_tagged(),
"media_type" => "text/plain",
"uri_hint" => "https://example.com/supplier-charter.txt"
},
"precedence_declaration" => "legal_text_governs",
"attribution_declaration" => %{"basis" => "bound_deployments"},
"termination_rules" => %{"reason_codes" => ["mutual", "breach"]},
"abp_bindings" => [
%{
"party_role" => "issuer",
"blueprint_id" => "example.demo/echo",
"release_number" => 1,
"content_digest" => "sha-256:b1Aw4cU5AbV9k8bdbZkRCsySDHGpTAwB-aQm57Wh7B8",
"deployment_digest" => "sha-256:tWFr0caS0AWFJd2UcB9gZv3kNjIUP8xZ08WWM_h8xgo"
}
],
"receipt_profile" => "com.example.charter/default",
"extensions" => %{"critical" => %{}, "optional" => %{}}
})
{:ok, bytes} = CAP.encode_charter_revision(claims)
{:ok, decoded} = CAP.decode_charter_revision(bytes, Limits.default())
digest = CAP.revision_digest(decoded)
%{
bytes: bytes,
decoded: decoded,
digest: digest,
number: number,
charter_id: Map.get(links, "charter_id", digest),
prev: Map.get(links, "prev_revision_digest")
}
end
genesis = revision.(1, "Supplier terms\n", %{"effective_from" => "2026-08-25T12:00:00Z"})
successor_of_genesis = %{
"charter_id" => genesis.digest,
"prev_revision_digest" => genesis.digest,
"effective_from" => "2026-08-25T12:00:01Z"
}
usd = revision.(2, "Supplier terms, USD settlement\n", successor_of_genesis)
eur = revision.(2, "Supplier terms, EUR settlement\n", successor_of_genesis)
%{usd: usd.digest, eur: eur.digest}
The USD and EUR revisions have the same charter, the same predecessor and the same revision number, but different terms, so their digests differ. A party may accept only one of them.
Both parties accept genesis and USD
acceptance_claims builds the claims one party signs for one revision. accept has both
parties sign through the signer, against the view they hold:
acceptance_claims = fn rev, party ->
claims = %{
"protocol_revision" => 2,
"charter_id" => rev.charter_id,
"revision_number" => rev.number,
"revision_digest" => rev.digest,
"party_descriptor_digest" => party.digest,
"party_role" => party.role,
"accepted_at" => "2026-08-25T13:00:00Z"
}
if rev.prev, do: Map.put(claims, "prev_revision_digest", rev.prev), else: claims
end
accept = fn rev, view ->
for party <- parties do
{:ok, %{acceptance: compact}} =
CharterAgreementSigner.sign_acceptance(
acceptance_claims.(rev, party),
{DemoKey, party.key},
view
)
compact
end
end
{:ok, view} = CAP.build_set([genesis.bytes], [], [], descriptors)
genesis_acceptances = accept.(genesis, view)
{:ok, view} = CAP.build_set([genesis.bytes, usd.bytes], genesis_acceptances, [], descriptors)
usd_acceptances = accept.(usd, view)
length(genesis_acceptances ++ usd_acceptances)
Four acceptances: both parties accepted genesis, then both accepted USD.
The producer refuses to equivocate
Now the EUR revision arrives. The issuer's view already holds its own acceptance of USD at revision number 2. Ask CAP's producer for the signing input of an EUR acceptance anyway, and try the same through the signer:
{:ok, view} =
CAP.build_set(
[genesis.bytes, usd.bytes, eur.bytes],
genesis_acceptances ++ usd_acceptances,
[],
descriptors
)
eur_claims = acceptance_claims.(eur, issuer)
%{
producer:
CAP.acceptance_signing_input(%{"kid" => issuer_key.kid, "claims" => eur_claims}, view),
signer: CharterAgreementSigner.sign_acceptance(eur_claims, {DemoKey, issuer_key}, view)
}
Both refuse with :signing_refused, and the signer stops before it calls the key handle.
Accepting EUR would mean the same party signed two different revisions at the same number,
which is equivocation. The producer never builds that signature.
A misbehaving party signs anyway
CAP cannot stop a party that skips the producer and signs the bytes itself. This helper does
exactly that: it builds the JWS header and payload by hand and signs with :crypto. It is
the only raw signing in this notebook, and it exists only to play the misbehaving party.
sign_without_producer = fn claims, key ->
header = %{"alg" => "Ed25519", "kid" => key.kid, "typ" => "cap+acceptance"}
{:ok, header_bytes} = Canonicalization.encode_plain(header)
{:ok, payload_bytes} = Canonicalization.encode_plain(claims)
message =
Base.url_encode64(header_bytes, padding: false) <>
"." <> Base.url_encode64(payload_bytes, padding: false)
signature = :crypto.sign(:eddsa, :none, message, [key.private, :ed25519])
message <> "." <> Base.url_encode64(signature, padding: false)
end
eur_acceptances =
for party <- parties, do: sign_without_producer.(acceptance_claims.(eur, party), party.key)
length(eur_acceptances)
Both parties now hold signed acceptances of USD and of EUR. The header is the same
Ed25519 header the producer would have written; the only thing wrong is what was signed.
Each acceptance verifies on its own
Verify the issuer's USD and EUR acceptances one at a time. Facts structs print as
<redacted>, so read the fields you need:
{:ok, usd_facts} =
CAP.verify_acceptance(hd(usd_acceptances), usd.decoded, issuer_chain, Limits.default())
{:ok, eur_facts} =
CAP.verify_acceptance(hd(eur_acceptances), eur.decoded, issuer_chain, Limits.default())
for facts <- [usd_facts, eur_facts] do
Map.take(facts, [:party_role, :revision_number, :revision_digest, :signing_key_id])
end
Each acceptance is valid alone: the right key signed it, over a real revision. The problem
only shows when you hold both. Acceptance.equivocation/2 turns the pair into evidence:
{:ok, evidence} = Acceptance.equivocation(usd_facts, eur_facts)
Map.take(evidence, [:kind, :party_role, :revision_number, :revision_digests, :winner])
winner: nil is deliberate. CAP keeps both signed positions as evidence and never picks
one.
The chain is contested
Verify everything the counterparty holds, then ask which revision governs once both successors are in effect:
fork_revisions = [genesis.bytes, usd.bytes, eur.bytes]
fork_acceptances = genesis_acceptances ++ usd_acceptances ++ eur_acceptances
{:ok, contested} =
CAP.verify_chain(fork_revisions, fork_acceptances, descriptors, [], Limits.default())
%{
chain_topology: contested.chain_topology,
accepted_revision_digests: contested.accepted_revision_digests,
fork_evidence: Enum.map(contested.fork_evidence, & &1.kind),
governing: CAP.governing_revision(contested, ~U[2026-08-25 12:00:01Z])
}
The topology is :forked, and all three revisions count as accepted. fork_evidence holds
one :sibling_revisions entry for the two revisions at number 2, and one
:equivocal_acceptances entry for each party that signed both. Two accepted revisions
compete at that instant, so the governing answer is {:ok, :contested}. CAP does not break
the tie by digest order or by time. An application should not pick a branch on its own
while the view is contested.
A receipt inside the fork
The issuer records an action under the USD revision while the fork is open. The signer checks the receipt against the issuer's contested view, and the counterparty verifies it against the same chain:
receipt_claims = fn rev ->
%{
"protocol_revision" => 2,
"charter_id" => genesis.digest,
"revision_number" => rev.number,
"revision_digest" => rev.digest,
"issuing_party_role" => "issuer",
"agent_party_role" => "issuer",
"deployment_digest" => "sha-256:tWFr0caS0AWFJd2UcB9gZv3kNjIUP8xZ08WWM_h8xgo",
"grant" => %{"scheme" => "host", "id" => "order-1042"},
"invocation_id" => "123e4567-e89b-42d3-a456-426614174000",
"decision" => "accepted",
"outcome" => "effect_committed",
"occurred_at" => "2026-08-25T12:00:01Z",
"recorded_at" => "2026-08-25T12:00:02Z",
"extensions" => %{"critical" => %{}, "optional" => %{}}
}
end
{:ok, %{receipt: usd_receipt}} =
CharterAgreementSigner.sign_receipt(receipt_claims.(usd), {DemoKey, issuer_key}, contested)
{:ok, receipt_facts} = CAP.verify_receipt(usd_receipt, contested, Limits.default())
Map.take(receipt_facts, [:governing_match, :chain_conflict, :outcome])
The receipt itself verifies. governing_match is :undetermined because no single
revision governs at occurred_at, so CAP cannot say whether USD was the right one.
chain_conflict: :none can look wrong inside a fork, but that field answers a narrower
question: does the receipt name a revision this verifier has never seen? The USD revision
is in the counterparty's chain, so the answer is no. The fork itself is reported by
chain_topology and fork_evidence above.
To see the other answer, give the counterparty a view that never saw EUR, and hand it a receipt that names EUR:
{:ok, %{receipt: eur_receipt}} =
CharterAgreementSigner.sign_receipt(receipt_claims.(eur), {DemoKey, issuer_key}, contested)
{:ok, usd_only_view} =
CAP.verify_chain(
[genesis.bytes, usd.bytes],
genesis_acceptances ++ usd_acceptances,
descriptors,
[],
Limits.default()
)
{:ok, unseen_facts} = CAP.verify_receipt(eur_receipt, usd_only_view, Limits.default())
Map.take(unseen_facts, [:governing_match, :chain_conflict])
Now chain_conflict is :fork_evidenced. The receipt names a revision at a number this
verifier already has an accepted revision for, so a fork exists somewhere outside this
view. governing_match is :mismatch because in this view USD governs, and the receipt
names EUR.
The repair
The repair is a new revision both parties accept that covers every competing branch:
each one is either its ancestor through prev_revision_digest or listed in its
supersedes. Here it builds on USD and names both competing revisions in
supersedes, and the parties sign it through the signer against the full contested
view:
repair =
revision.(3, "Supplier terms, countersigned repair\n", %{
"charter_id" => genesis.digest,
"prev_revision_digest" => usd.digest,
"supersedes" => Enum.sort([usd.digest, eur.digest]),
"effective_from" => "2026-08-25T12:00:02Z"
})
{:ok, view} =
CAP.build_set(fork_revisions ++ [repair.bytes], fork_acceptances, [], descriptors)
repair_acceptances = accept.(repair, view)
{:ok, repaired} =
CAP.verify_chain(
fork_revisions ++ [repair.bytes],
fork_acceptances ++ repair_acceptances,
descriptors,
[],
Limits.default()
)
{:ok, governing_after} = CAP.governing_revision(repaired, ~U[2026-08-25 12:00:02Z])
%{
governing_after: governing_after,
equals_repair: governing_after == repair.digest,
superseded: repaired.superseded_revision_digests,
fork_evidence_kept: Enum.map(repaired.fork_evidence, & &1.kind)
}
From the repair's effective_from on, the repair governs. The chain still holds the fork
evidence and both superseded revisions. Nothing is rewritten. The later revision takes
precedence.
What just happened
- The producer and the signer refused to sign an equivocating acceptance.
- A party that signed anyway produced acceptances that verify one at a time, and CAP turned the pair into equivocation evidence with no winner.
- The chain reported
:contested, and a receipt inside the fork verified withgoverning_match: :undetermined. - A revision both parties accepted, superseding both branches, made one revision govern again.
CAP reported each of these as evidence. What to do about a fork is your application's call.