Manufacture an equivocation, prove the contested view, repair it

This notebook breaks a bilateral charter on purpose: one party signs two different revisions at the same revision number. CAP must then report — never adjudicate — the equivocation evidence, the forked chain, the contested governing view, and an action receipt whose governing match is undetermined. Both parties then countersign a supersession repair, and the governing view becomes unique again.

This mirrors examples/supplier_fork_demo.exs in the repository, step for step, with real Ed25519 signatures. Demo keys only — never trust anchors.

Setup

Same helpers as the charter tour:

Mix.install(
  [
    {:charter_agreement_protocol, path: Path.expand("../..", __DIR__)}
  ],
  consolidate_protocols: false
)
alias CharterAgreementProtocol.{Acceptance, Base64Url, Canonicalization, Digest, Limits}
alias CharterAgreementProtocol, as: CAP

defmodule Fork do
  def tagged_value(value) when is_map(value),
    do: {:object, Enum.map(value, fn {k, v} -> {k, tagged_value(v)} end)}

  def tagged_value(value) when is_list(value), do: {:array, Enum.map(value, &tagged_value/1)}
  def tagged_value(value) when is_binary(value), do: {:string, value}
  def tagged_value(value) when is_integer(value), do: {:integer, value}

  def canonical!(plain) do
    {:ok, bytes} = Canonicalization.encode(tagged_value(plain))
    bytes
  end

  def tagged(domain, bytes), do: domain |> Digest.hash(bytes) |> Digest.to_tagged()

  def sign(signing_input, private) do
    signature = :crypto.sign(:eddsa, :none, signing_input.message, [private, :ed25519])
    {:ok, compact} = CAP.assemble_compact(signing_input, signature)
    compact
  end

  def descriptor(seed_byte, kid) do
    {public, private} = :crypto.generate_key(:eddsa, :ed25519, :binary.copy(<<seed_byte>>, 32))

    claims = %{
      "protocol_revision" => 1,
      "descriptor_number" => 1,
      "verification_keys" => [
        %{"key_id" => kid, "algorithm" => "Ed25519",
          "public_key" => Base64Url.encode(public), "status" => "active"}
      ],
      "attestation_hints" => [],
      "extensions" => %{"critical" => %{}, "optional" => %{}},
      "effective_from" => "2026-08-25T10:00:00Z"
    }

    {:ok, signing_input} = CAP.descriptor_signing_input(%{"kid" => kid, "claims" => claims})
    compact = sign(signing_input, private)
    {:ok, decoded} = CAP.decode_party_descriptor(compact, Limits.default())

    %{compact: compact, digest: CAP.descriptor_digest(decoded), kid: kid, private: private}
  end

  def revision(number, legal_text, issuer, acceptor, overrides) do
    claims =
      %{
        "protocol_revision" => 1,
        "revision_number" => number,
        "parties" => [
          %{"party_descriptor_digest" => issuer.digest, "role" => "issuer"},
          %{"party_descriptor_digest" => acceptor.digest, "role" => "acceptor"}
        ],
        "legal_text" => %{
          "content_digest" => tagged(:legal_text, legal_text),
          "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" => %{}}
      }
      |> Map.merge(overrides)

    bytes = canonical!(claims)

    %{
      bytes: bytes,
      claims: claims,
      digest: tagged(:charter_revision_content, bytes),
      charter_id: Map.get(claims, "charter_id", nil)
    }
  end

  def acceptance(revision_fixture, descriptor, role) do
    claims = %{
      "protocol_revision" => 1,
      "charter_id" => revision_fixture.claims["charter_id"] || revision_fixture.digest,
      "revision_number" => revision_fixture.claims["revision_number"],
      "revision_digest" => revision_fixture.digest,
      "party_descriptor_digest" => descriptor.digest,
      "party_role" => role,
      "accepted_at" => "2026-08-25T13:00:00Z"
    }

    claims =
      if revision_fixture.claims["revision_number"] == 1,
        do: claims,
        else: Map.put(claims, "prev_revision_digest", revision_fixture.claims["prev_revision_digest"])

    protected = canonical!(%{"alg" => "EdDSA", "kid" => descriptor.kid, "typ" => "cap+acceptance"})
    payload = canonical!(claims)
    message = Base64Url.encode(protected) <> "." <> Base64Url.encode(payload)
    signature = :crypto.sign(:eddsa, :none, message, [descriptor.private, :ed25519])
    message <> "." <> Base64Url.encode(signature)
  end
end

:ok

The fork: one party signs two sibling revisions

issuer = Fork.descriptor(1, "issuer-key")
acceptor = Fork.descriptor(2, "acceptor-key")
descriptors = [issuer.compact, acceptor.compact]

genesis =
  Fork.revision(1, "Supplier terms\n", issuer, acceptor, %{
    "effective_from" => "2026-08-25T12:00:00Z"
  })

left =
  Fork.revision(2, "Supplier terms — USD settlement\n", issuer, acceptor, %{
    "charter_id" => genesis.digest,
    "prev_revision_digest" => genesis.digest,
    "effective_from" => "2026-08-25T12:00:01Z"
  })

right =
  Fork.revision(2, "Supplier terms — EUR settlement\n", issuer, acceptor, %{
    "charter_id" => genesis.digest,
    "prev_revision_digest" => genesis.digest,
    "effective_from" => "2026-08-25T12:00:01Z"
  })

left.digest != right.digest

Same charter, same predecessor, same revision number, different bytes. Both parties now sign acceptances for all three revisions:

contested_revisions = [genesis, left, right]

contested_acceptances =
  Enum.flat_map(contested_revisions, fn one ->
    [Fork.acceptance(one, issuer, "issuer"), Fork.acceptance(one, acceptor, "acceptor")]
  end)

length(contested_acceptances)

Equivocation evidence, with no winner

{:ok, descriptor_chain} = CAP.verify_descriptor_chain([issuer.compact], Limits.default())
{:ok, revision} = CAP.decode_charter_revision(left.bytes, Limits.default())

{:ok, facts_left} =
  CAP.verify_acceptance(Enum.at(contested_acceptances, 2), revision, descriptor_chain, Limits.default())

facts_left
{:ok, revision_right} = CAP.decode_charter_revision(right.bytes, Limits.default())

{:ok, facts_right} =
  CAP.verify_acceptance(Enum.at(contested_acceptances, 4), revision_right, descriptor_chain, Limits.default())

{:ok, evidence} = Acceptance.equivocation(facts_left, facts_right)

%{kind: evidence.kind,
  winner: evidence.winner,
  revision_digests: evidence.revision_digests}

The same Party Descriptor and role signed revision number 2 twice, over different digests. winner is nil — CAP retains both signed positions as evidence and refuses to pick.

The contested view

{:ok, contested} =
  CAP.verify_chain(
    Enum.map(contested_revisions, & &1.bytes),
    contested_acceptances,
    descriptors,
    [],
    Limits.default()
  )

{:ok, :contested} =
  CAP.governing_revision(contested, ~U[2026-08-25 12:00:01Z])

Two accepted branches are eligible at that instant. No digest ordering, no freshness policy, no tie-break — the view is contested, full stop.

A receipt inside the fork

receipt_claims = %{
  "protocol_revision" => 1,
  "charter_id" => genesis.digest,
  "revision_number" => 2,
  "revision_digest" => left.digest,
  "issuing_party_role" => "issuer",
  "agent_party_role" => "issuer",
  "deployment_digest" => "sha-256:tWFr0caS0AWFJd2UcB9gZv3kNjIUP8xZ08WWM_h8xgo",
  "grant" => %{"scheme" => "bap", "id" => "grant-2026-07-27-001",
               "grant_digest" => "sha-256:5k224cZ_lMI9VoUZ_fYM31ZJAcnJiht0GYEpnhes_ZI"},
  "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" => %{}}
}

{:ok, receipt_input} =
  CAP.receipt_signing_input(%{"kid" => issuer.kid, "claims" => receipt_claims})

receipt = Fork.sign(receipt_input, issuer.private)

{:ok, receipt_facts} = CAP.verify_receipt(receipt, contested, Limits.default())

%{chain_conflict: receipt_facts.chain_conflict,
  governing_match: receipt_facts.governing_match,
  outcome: receipt_facts.outcome}

The receipt itself verifies — signature, coordinates, deployment, grant — and its governance is :undetermined because the chain view is contested. That is the honest answer for action evidence inside an unresolved fork.

The repair: countersigned supersession

repair =
  Fork.revision(3, "Supplier terms — countersigned repair\n", issuer, acceptor, %{
    "charter_id" => genesis.digest,
    "prev_revision_digest" => left.digest,
    "supersedes" => Enum.sort([left.digest, right.digest]),
    "effective_from" => "2026-08-25T12:00:02Z"
  })

repair_acceptances =
  for {descriptor, role} <- [{issuer, "issuer"}, {acceptor, "acceptor"}] do
    claims = %{
      "protocol_revision" => 1,
      "charter_id" => genesis.digest,
      "revision_number" => 3,
      "revision_digest" => repair.digest,
      "prev_revision_digest" => left.digest,
      "party_descriptor_digest" => descriptor.digest,
      "party_role" => role,
      "accepted_at" => "2026-08-25T13:00:00Z"
    }

    protected = Fork.canonical!(%{"alg" => "EdDSA", "kid" => descriptor.kid, "typ" => "cap+acceptance"})
    payload = Fork.canonical!(claims)
    message = Base64Url.encode(protected) <> "." <> Base64Url.encode(payload)
    signature = :crypto.sign(:eddsa, :none, message, [descriptor.private, :ed25519])
    message <> "." <> Base64Url.encode(signature)
  end

{:ok, repaired} =
  CAP.verify_chain(
    Enum.map(contested_revisions ++ [repair], & &1.bytes),
    contested_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}

The repair names both siblings in supersedes and both parties countersign it. The fork and equivocation evidence is retained in the facts — history is never rewritten — but current precedence is unique again: the governing digest is the repair's.

What just happened

  • Equivocation is evidence: both signed positions retained, winner nil.
  • A contested view is reported, never adjudicated — no host should act unilaterally inside one.
  • Action evidence inside a fork verifies with governing_match: :undetermined — honest, not broken.
  • The only no-tie-break repair is bilateral: a later countersigned revision superseding every contested sibling.

Your host owns every decision along the way. CAP reported the evidence.