Producing artifacts
This notebook starts with the shipped Blueprint and Deployment Manifest, changes their values, recomputes their digests, and creates a Blueprint signature with a throwaway key.
The first cell installs the published package at the release candidate requirement.
Mix.install([{:agent_blueprint_protocol, "~> 1.0.0-rc.2"}])
Locate the shipped examples from the installed package or the current source checkout.
dependency_path =
try do
Mix.Project.deps_paths() |> Map.get(:agent_blueprint_protocol)
rescue
_ -> nil
end
package_root =
[dependency_path, Application.app_dir(:agent_blueprint_protocol), File.cwd!()]
|> Enum.reject(&is_nil/1)
|> Enum.find(fn path -> File.regular?(Path.join(path, "examples/echo-blueprint.json")) end)
true = is_binary(package_root)
%{example_files_found?: true}
%{example_files_found?: true}
Change the Blueprint and compute its digest
The Blueprint value comes from the shipped example. This cell changes its release number, validates the value, recomputes the content digest, and encodes canonical bytes.
Decode the shipped value, update one member, compute its digest, and produce canonical bytes.
alias AgentBlueprintProtocol.{Blueprint, Canonicalization, Digest, Json}
put_member = fn {:object, members}, name, value ->
{:object, List.keystore(members, name, 0, {name, value})}
end
replace_path = fn replace_path, {:object, members}, [name | rest], value ->
if rest == [] do
put_member.({:object, members}, name, value)
else
{^name, nested} = List.keyfind(members, name, 0)
updated = replace_path.(replace_path, nested, rest, value)
put_member.({:object, members}, name, updated)
end
end
blueprint_path = Path.join(package_root, "examples/echo-blueprint.json")
{:ok, blueprint_value} = File.read!(blueprint_path) |> Json.decode()
{:ok, original_blueprint} = Blueprint.from_value(blueprint_value)
original_digest = original_blueprint |> Blueprint.content_digest() |> Digest.to_tagged()
blueprint_value = put_member.(blueprint_value, "release_number", {:integer, 2})
{:ok, blueprint_draft} = Blueprint.from_value(blueprint_value)
blueprint_digest = blueprint_draft |> Blueprint.content_digest() |> Digest.to_tagged()
blueprint_value =
put_member.(blueprint_value, "content_digest", {:string, blueprint_digest})
{:ok, blueprint_bytes} = Canonicalization.encode(blueprint_value)
{:ok, blueprint} = AgentBlueprintProtocol.decode_blueprint(blueprint_bytes)
{:ok, ^blueprint_bytes} = AgentBlueprintProtocol.canonical_bytes(blueprint)
%{
release_number: 2,
digest_changed: original_digest != blueprint_digest,
digest: blueprint_digest,
canonical_byte_size: byte_size(blueprint_bytes)
}
%{
digest: "sha-256:RN6KYFSvPFYnoQj5dqA8B2_tjjm2bYGfwCpSYGqmMIE",
release_number: 2,
digest_changed: true,
canonical_byte_size: 1361
}
Change the Deployment Manifest
The manifest now points to the changed Blueprint release and digest. It also changes one model-policy value before computing its own digest.
Update the shipped Deployment Manifest to bind the new Blueprint value, then recompute and verify the Deployment digest.
alias AgentBlueprintProtocol.Deployment
deployment_path = Path.join(package_root, "examples/echo-deployment.json")
{:ok, deployment_value} = File.read!(deployment_path) |> Json.decode()
deployment_value =
replace_path.(replace_path, deployment_value, ["blueprint_release", "release_number"], {:integer, 2})
|> then(&replace_path.(replace_path, &1, ["blueprint_release", "content_digest"], {:string, blueprint_digest}))
|> then(&replace_path.(replace_path, &1, ["model_policy", "max_tokens"], {:integer, 40_000}))
{:ok, deployment_draft} = Deployment.from_value(deployment_value)
deployment_digest = deployment_draft |> Deployment.content_digest() |> Digest.to_tagged()
deployment_value = put_member.(deployment_value, "deployment_digest", {:string, deployment_digest})
{:ok, deployment_bytes} = Canonicalization.encode(deployment_value)
{:ok, deployment} = AgentBlueprintProtocol.decode_deployment(deployment_bytes)
%{
digest: deployment_digest,
binds_changed_release: Deployment.binds?(deployment, blueprint),
canonical_byte_size: byte_size(deployment_bytes)
}
%{
digest: "sha-256:F9n_bnRsD_pTbtsFk6uwRgx3pgQpDfGcXNVRhvwyBcs",
canonical_byte_size: 2539,
binds_changed_release: true
}
Sign with Ed25519
The producer creates a throwaway Ed25519 key with OTP crypto. The protected header uses Ed25519, and the signature is attached to the Blueprint value. No key bytes are printed.
Create and attach a detached signature, then verify the canonical round trip.
alias AgentBlueprintProtocol.{Base64Url, Signature}
alias AgentBlueprintProtocol.Signature.PublicKey
{public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519)
protected = {:object, [
{"alg", {:string, "Ed25519"}},
{"b64", {:boolean, false}},
{"crit", {:array, [{:string, "b64"}]}},
{"kid", {:string, "example-producer-key"}}
]}
signed_attributes = {:object, [
{"algorithm", {:string, "Ed25519"}},
{"content_digest", {:string, blueprint_digest}},
{"created_at", {:string, "2026-08-20T00:00:00Z"}},
{"key_id", {:string, "example-producer-key"}},
{"purpose", {:string, "blueprint"}}
]}
entry_with = fn header, signature_bytes ->
{:object, [
{"protected", header},
{"signed_attributes", signed_attributes},
{"signature", {:string, Base64Url.encode(signature_bytes)}}
]}
end
unsigned_entry = entry_with.(protected, <<0::512>>)
{:ok, signing_input} = Signature.signing_input(unsigned_entry)
signature_bytes = :crypto.sign(:eddsa, :none, signing_input, [private_key, :ed25519])
entry = entry_with.(protected, signature_bytes)
public_key_record = %PublicKey{
key_id: "example-producer-key",
algorithm: :ed25519,
key: public_key
}
{:ok, signed_value} =
blueprint
|> Blueprint.to_value()
|> put_member.("signatures", {:array, [entry]})
|> Canonicalization.encode()
{:ok, signed_blueprint} = AgentBlueprintProtocol.decode_blueprint(signed_value)
{:ok, ^signed_value} = AgentBlueprintProtocol.canonical_bytes(signed_blueprint)
%{
signature_verifies: match?({:ok, %Signature.Attributes{}}, Signature.verify(entry, [public_key_record])),
digest_unchanged: Digest.to_tagged(Blueprint.content_digest(signed_blueprint)) == blueprint_digest,
canonical_round_trip: true
}
%{canonical_round_trip: true, signature_verifies: true, digest_unchanged: true}
Accept the EdDSA header spelling
The protected header also accepts the EdDSA spelling. The signed attributes keep the Ed25519 algorithm name.
Sign the same input with the EdDSA protected-header spelling and verify it with the same public key.
eddsa_header =
put_member.(protected, "alg", {:string, "EdDSA"})
eddsa_entry = entry_with.(eddsa_header, <<0::512>>)
{:ok, eddsa_input} = Signature.signing_input(eddsa_entry)
eddsa_signature = :crypto.sign(:eddsa, :none, eddsa_input, [private_key, :ed25519])
eddsa_entry = entry_with.(eddsa_header, eddsa_signature)
%{eddsa_header_verifies: match?({:ok, %Signature.Attributes{}}, Signature.verify(eddsa_entry, [public_key_record]))}
%{eddsa_header_verifies: true}
Three producer errors
These examples show the typed error value for a missing artifact shape, an unsupported member, and a nonportable value.
Collect three representative typed errors and retain their code, subject, and detail fields.
alias AgentBlueprintProtocol.Error
missing_blueprint = Blueprint.from_value({:object, []})
unknown_member = AgentBlueprintProtocol.decode_blueprint(~s({"unknown":true}))
portable_value =
AgentBlueprintProtocol.Portability.scan({:object, [{"credential", {:string, "example"}}]})
errors =
Enum.map([missing_blueprint, unknown_member, portable_value], fn
{:error, %Error{} = error} -> error
other -> other
end)
errors
[
%AgentBlueprintProtocol.Error{
code: :missing_required_field,
detail: nil,
subject: ["blueprint_id"]
},
%AgentBlueprintProtocol.Error{code: :unknown_member, detail: nil, subject: []},
%AgentBlueprintProtocol.Error{code: :forbidden_portable_value, detail: nil, subject: []}
]