Powered by AppSignal & Oban Pro

Fork repair

docs/notebooks/fork-repair.livemd

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 with governing_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.