Charter tour
Mix.install([
{:charter_agreement_protocol, "~> 0.6.0"},
{:charter_agreement_signer, "~> 0.5.0"}
])
What this notebook does
Two parties set up a charter. They publish party descriptors, agree on a charter revision and accept it. Then the issuer records a receipt for one action.
The notebook plays two roles:
- The producer. Each party signs its own artifacts with charter_agreement_signer, the way an application does.
- The counterparty. Everything after signing is verified with
charter_agreement_protocolalone. A verifier never needs the signer or any private key.
The keys are generated from fixed seeds so every run prints the same digests. They are demo keys; never use them for anything real.
Check what this runtime can verify
An application calls capabilities/0 once at boot. It reports, for each signature algorithm
in the registry, whether this Erlang runtime can verify it, and which crypto library is
linked.
alias CharterAgreementProtocol, as: CAP
alias CharterAgreementProtocol.{Digest, Limits}
CAP.capabilities().algorithms
A row with verifiable: false means this runtime could not verify that algorithm's
pinned test signature: the linked crypto library lacks the algorithm, or offers it but
the check fails. Artifacts signed with it then fail with
:algorithm_unsupported_on_substrate, which says the signature could not be checked.
Check this at boot instead of finding out on the first artifact.
A demo key handle
The signer never holds a private key. You hand it a key handle, {module, ref}, where the
module implements two callbacks:
sign/2takes the exact bytes to sign and returns the raw signature.key_identity/1returns the key id and public key in one call.
In production the module talks to an HSM or a key service. This one keeps an Ed25519 key in memory, which is fine for a demo:
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]
The cell returns only the key ids, so no private key is printed.
Each party publishes a descriptor
A party descriptor lists the keys a party signs with. Its content digest becomes the party's identifier, and charters name parties by that digest.
descriptor_claims = fn key ->
%{
"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"
}
end
{:ok, %{descriptor: issuer_descriptor}} =
CharterAgreementSigner.sign_descriptor(descriptor_claims.(issuer_key), {DemoKey, issuer_key})
{:ok, %{descriptor: acceptor_descriptor}} =
CharterAgreementSigner.sign_descriptor(
descriptor_claims.(acceptor_key),
{DemoKey, acceptor_key}
)
String.slice(issuer_descriptor, 0, 60) <> "..."
The result is a compact JWS: three base64url parts joined by dots. The signer already verified it with CAP before returning it, so a broken handle or a wrong key never produces an artifact.
protocol_revision 2 with Ed25519 is the default pair the signer mints. Pass
algorithm: "ML-DSA-65" with protocol_revision 3 for a post-quantum key instead.
The counterparty verifies both descriptors
From here on, read each cell as the other side. It received two descriptors and checks each party's key history on its own:
{:ok, issuer_chain} = CAP.verify_descriptor_chain([issuer_descriptor], Limits.default())
{:ok, acceptor_chain} = CAP.verify_descriptor_chain([acceptor_descriptor], Limits.default())
party_digest = fn compact ->
{:ok, descriptor} = CAP.decode_party_descriptor(compact, Limits.default())
CAP.descriptor_digest(descriptor)
end
issuer_digest = party_digest.(issuer_descriptor)
acceptor_digest = party_digest.(acceptor_descriptor)
%{
issuer: %{topology: issuer_chain.topology, digest: issuer_digest},
acceptor: %{topology: acceptor_chain.topology, digest: acceptor_digest}
}
:linear means each history has one path from its first descriptor to its latest. Each
digest is that party's identifier.
Write the charter revision
A charter revision holds the agreed terms. It is not signed. It is canonical JSON, and the
parties sign acceptances of its digest. encode_charter_revision/1 turns a plain claims map
into those canonical bytes and checks them with the same decoder a verifier uses.
The legal text itself stays outside the charter; the revision carries its digest. The
abp_bindings entry names the deployment the issuer's agent runs, with example digests from
the Agent Blueprint Protocol.
legal_text_digest =
:legal_text |> Digest.hash("Supplier terms\n") |> Digest.to_tagged()
deployment_digest = "sha-256:tWFr0caS0AWFJd2UcB9gZv3kNjIUP8xZ08WWM_h8xgo"
revision_claims = %{
"protocol_revision" => 2,
"revision_number" => 1,
"parties" => [
%{"party_descriptor_digest" => issuer_digest, "role" => "issuer"},
%{"party_descriptor_digest" => acceptor_digest, "role" => "acceptor"}
],
"legal_text" => %{
"content_digest" => legal_text_digest,
"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" => deployment_digest
}
],
"receipt_profile" => "com.example.charter/default",
"extensions" => %{"critical" => %{}, "optional" => %{}},
"effective_from" => "2026-08-25T12:00:00Z"
}
{:ok, revision_bytes} = CAP.encode_charter_revision(revision_claims)
{:ok, revision} = CAP.decode_charter_revision(revision_bytes, Limits.default())
charter_id = CAP.revision_digest(revision)
This is revision number 1, so it has no charter_id field. Its own digest becomes the
charter's id, and every later revision, acceptance and receipt carries that id.
Both parties accept
Before it signs an acceptance, the producer checks the claims against the artifacts the
signing party holds. You pass that view as an artifact set built with build_set/4. If the
claims contradict it, for example by naming the wrong digest, the producer refuses and the
key is never used.
{:ok, set} = CAP.build_set([revision_bytes], [], [], [issuer_descriptor, acceptor_descriptor])
acceptance_claims = fn digest, role ->
%{
"protocol_revision" => 2,
"charter_id" => charter_id,
"revision_number" => 1,
"revision_digest" => charter_id,
"party_descriptor_digest" => digest,
"party_role" => role,
"accepted_at" => "2026-08-25T13:00:00Z"
}
end
{:ok, %{acceptance: issuer_acceptance}} =
CharterAgreementSigner.sign_acceptance(
acceptance_claims.(issuer_digest, "issuer"),
{DemoKey, issuer_key},
set
)
{:ok, %{acceptance: acceptor_acceptance}} =
CharterAgreementSigner.sign_acceptance(
acceptance_claims.(acceptor_digest, "acceptor"),
{DemoKey, acceptor_key},
set
)
acceptances = [issuer_acceptance, acceptor_acceptance]
length(acceptances)
Two acceptances, one per party, both over the same revision digest.
The counterparty verifies the charter
verify_chain/5 takes everything the counterparty holds: revisions, acceptances,
descriptors and termination notices (none here). It returns chain facts, a struct that
prints as <redacted> so it never leaks into logs by accident. Read its fields directly:
descriptors = [issuer_descriptor, acceptor_descriptor]
{:ok, chain_facts} =
CAP.verify_chain([revision_bytes], acceptances, descriptors, [], Limits.default())
%{
charter_id: chain_facts.charter_id,
chain_topology: chain_facts.chain_topology,
accepted_revision_digests: chain_facts.accepted_revision_digests,
acceptances: length(chain_facts.acceptance_facts)
}
The revision is in accepted_revision_digests because both parties signed an acceptance of
its exact digest. One accepted path means the topology is :linear.
Which revision governs?
Charters change over time, so the question is always "which revision governs at this
instant?" Ask it with governing_revision/2:
%{
before_effective_from: CAP.governing_revision(chain_facts, ~U[2026-08-25 11:00:00Z]),
at_effective_from: CAP.governing_revision(chain_facts, ~U[2026-08-25 12:00:00Z])
}
Before the revision's effective_from, nothing governs, so the answer is {:ok, :none}.
From that instant on, the answer is the revision's digest. A third answer, {:ok, :contested},
appears when two accepted revisions compete. The fork repair notebook
shows that case.
The issuer records a receipt
A receipt is a signed record that one action happened under the charter. The grant,
decision and outcome members are the host's own report about that action. The "host"
scheme means the reference is the host's own record id.
The signer takes the issuer's verified chain facts so it can check the receipt against them before returning it.
receipt_claims = %{
"protocol_revision" => 2,
"charter_id" => charter_id,
"revision_number" => 1,
"revision_digest" => charter_id,
"issuing_party_role" => "issuer",
"agent_party_role" => "issuer",
"deployment_digest" => deployment_digest,
"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" => %{}}
}
{:ok, %{receipt: receipt}} =
CharterAgreementSigner.sign_receipt(receipt_claims, {DemoKey, issuer_key}, chain_facts)
String.slice(receipt, 0, 60) <> "..."
The counterparty verifies the receipt
{:ok, receipt_facts} = CAP.verify_receipt(receipt, chain_facts, Limits.default())
%{
governing_match: receipt_facts.governing_match,
chain_conflict: receipt_facts.chain_conflict,
deployment_digest_matched: receipt_facts.deployment_digest_matched,
signing_key_id: receipt_facts.signing_key_id,
outcome: receipt_facts.outcome
}
What these fields say:
governing_match: :match: the revision the receipt names is the one that governed atoccurred_at.chain_conflict: :none: the receipt names no revision the counterparty has not seen.deployment_digest_matched: true: the deployment is the one the revision binds to the issuer.signing_key_id: the id of the issuer key that signed it, as listed in the issuer's descriptor.outcome: what the issuer claims happened. CAP checks the signature on that claim, not the claim itself.
An edited receipt fails to verify
Suppose someone edits the receipt to claim the action had no effect. Decode the payload, change the outcome, and verify the edited receipt:
[header, payload, signature] = String.split(receipt, ".")
edited_payload =
payload
|> Base.url_decode64!(padding: false)
|> String.replace("effect_committed", "no_effect")
|> Base.url_encode64(padding: false)
edited_receipt = Enum.join([header, edited_payload, signature], ".")
CAP.verify_receipt(edited_receipt, chain_facts, Limits.default())
Verification returns a typed error with a stable code and the path of the part that
failed. It never returns partial facts.
What the facts do not prove
Every facts struct carries a not_verified list. It names what CAP did not check and
leaves to your application:
receipt_facts.not_verified
Every result includes these twelve atoms. Some add more: a receipt checked against a
revision alone, without the chain, also lists :signature.
CAP proved that the right keys signed these exact bytes and that the receipt fits the charter the counterparty holds. It did not prove that the action was allowed, that it really happened, that the terms were met, that the legal text is valid, or that the counterparty's view is complete. CAP never authorizes anything. Your application makes those calls.
Next: the fork repair notebook breaks a charter on purpose and repairs it.