Reference
Read as MarkdownVerify a signed attestation offline
Check an exported agent bill of materials or action-receipt checkpoint yourself, with no call to Anectico.
Anectico signs two kinds of artifact with a dedicated Ed25519 key:
- an agent bill of materials — the components an agent asset depends on at a point in time, and
- an action-receipt checkpoint — an assertion about how long one action-receipt chain was, and what its last entry’s digest was, at a point in time.
Both are detached signatures. You can hand either artifact to an auditor, a regulator, or your own security team, and they can check it without an Anectico account, an API key, or a request to us. This page is the complete contract they need.
What a signature proves — and what it does not
A valid signature proves exactly one thing: the holder of this Anectico private key asserted these exact bytes.
It does not prove:
- that the contents are accurate — signing attests to the bytes, not to the world they describe;
- that
generated_atis an independently witnessed time — it is the time we recorded, not a time anyone else vouched for; - that a compromised signer did not produce false or backdated artifacts.
Making the set of attestations append-only to a party we cannot influence is a different mechanism (a transparency log with an external witness) and Anectico does not offer one. Hardware-backed key custody is likewise not offered today. We would rather you know the shape of the claim than assume a larger one.
What the signature does give you is independence: verification never requires a secret. That is why the signature is asymmetric rather than a shared-secret MAC — a MAC can only be checked with the key that could also forge it, so handing you verification would hand you forgery.
Get the verification keys
GET https://<your Anectico host>/.well-known/anectico-signing-keys.json
No credential. The response is a JWK Set:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"use": "sig",
"kid": "anectico-abom-2026-08",
"x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"
}
]
}
x is the raw 32-byte public key as unpadded base64url.
Retired keys stay in this document permanently. An artifact signed in 2027 is still verifiable in
2030, because the key it names is still published. Take the kid from inside the artifact — not
from any wrapper around it — and look it up here.
The byte contract
Every artifact carries a canonical_form: the exact bytes the signature covers. Verify against
that string, byte for byte. Do not re-serialize the artifact’s other fields and hope to reproduce
it — the other fields are a convenience view over the canonical form, not a second copy of it.
The canonical form is RFC 8785 (JCS) JSON encoded as
UTF-8: object members sorted by key, no insignificant whitespace, minimal string escaping, integers
in shortest decimal form, and non-ASCII characters emitted as literal UTF-8 rather than \uXXXX
escapes. Beyond JCS, five rules are fixed so nothing is left to guess:
- Timestamps are UTC RFC 3339 with exactly nine fractional digits, trailing zeros kept:
2026-08-16T09:30:00.000000000Z. - Enumerated values enter as integers, not display names. A bill-of-materials component’s
kindis the numeric value from the API’sAssetKindenumeration. - Every field is always present, including empty optional identifiers, which appear as
"". The key set never depends on the data. - Components are sorted by
(kind, ref, version, content_sha256, declared_by)before encoding. Every other array’s order is significant and is encoded as given. releaseis the one member whose value may benull. The key is still always present — rule 3 holds — but its value distinguishes two facts that must never collapse into one:nullmeans the producer named no release, and""means the producer named the empty label. They are different bytes, a different digest and a different signature.
signing_key_id and signature_alg are inside the signed bytes, so neither can be swapped after
the fact. For a bill of materials, authority is inside them too — see below.
An agent bill of materials
{"asset_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","asset_version_id":"1b4e28ba-2fa1-11d2-883f-0016d3cca427","authority":"declared","components":[{"content_sha256":"bbbb…","declared_by":"telemetry","kind":1,"ref":"agent:triage","version":"4.0.1"}],"deployment_id":"","generated_at":"2026-08-16T09:30:00.000000000Z","manifest_id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","release":"2026.08.16","schema":"anectico.abom.v1","scope":{"environment":"production","org_id":"…","project_id":"…"},"signature_alg":"EdDSA","signing_key_id":"anectico-abom-2026-08"}
release says which release this bill of materials is for, using the same label the agent puts
on its spans as gen_ai.agent.version. It is inside the signed body so that “this is the
composition for the release that ran” is something you can check yourself: two releases of one
unchanged composition are two artifacts with two digests and two signatures, and a manifest signed
for one cannot be presented as the manifest for the other without breaking the signature. null
means the producer declared no release at all — not the same thing as "".
Read authority before you read anything else in a manifest. It has exactly two values and they
mean different things:
authority |
What the signature establishes |
|---|---|
declared |
The organization named in scope submitted this composition and Anectico notarized it. The signature proves what was declared and that it is unchanged since — not that the components are accurate. |
mediated |
Anectico itself supplied the composition. |
It is a member of the signed body, not a field beside it, precisely so that this distinction survives export: a manifest handed to you as a file carries its own authority, and no column, wrapper or covering email can change it without breaking the signature. A verifier that ignores it is reading a customer’s declaration as a platform finding.
An action-receipt checkpoint
{"chain_id":"…","entry_sha256":"9f9f…","generated_at":"2026-08-16T09:30:00.000000000Z","schema":"anectico.receipt-checkpoint.v1","scope":{"environment":"production","org_id":"…","project_id":"…"},"seq":41,"signature_alg":"EdDSA","signing_key_id":"anectico-abom-2026-08"}
seq is the sequence number of the last entry covered, so a chain of 42 entries checkpoints at
seq 41.
What is actually signed
Not the canonical form directly, and not the bare digest. Compute the raw 32-byte SHA-256 of the canonical form, then verify the Ed25519 signature over:
ASCII(<domain>) || 0x00 || <32-byte SHA-256 digest>
where <domain> is:
| Artifact | Domain string |
|---|---|
| Agent bill of materials | anectico.abom.signature.v1 |
| Action-receipt checkpoint | anectico.receipt-checkpoint.signature.v1 |
The trailing NUL byte is part of the input. The two domains are separate on purpose: a signature made for one artifact type will not verify as the other, so a checkpoint can never be presented as a bill of materials or the reverse.
The signature field is the Ed25519 signature as unpadded base64url (64 bytes decoded).
Verify it
import base64, hashlib, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ABOM_DOMAIN = b"anectico.abom.signature.v1\x00"
CHECKPOINT_DOMAIN = b"anectico.receipt-checkpoint.signature.v1\x00"
def b64url(s): # unpadded base64url -> bytes
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def verify(artifact, jwks, domain):
canonical = artifact["canonical_form"].encode("utf-8")
# The digest recorded beside the artifact must be the digest OF these bytes.
recorded = artifact.get("manifest_sha256") or artifact["checkpoint_sha256"]
if hashlib.sha256(canonical).hexdigest() != recorded:
return False
# The key id comes from INSIDE the signed bytes, never from a wrapper.
kid = json.loads(canonical)["signing_key_id"]
jwk = next((k for k in jwks["keys"] if k["kid"] == kid), None)
if jwk is None:
return False
pub = Ed25519PublicKey.from_public_bytes(b64url(jwk["x"]))
try:
pub.verify(b64url(artifact["signature"]), domain + hashlib.sha256(canonical).digest())
return True
except Exception:
return False
Test your implementation against these fixed vectors before trusting it. They are the same ones the platform’s own tests are pinned to.
| Value | |
|---|---|
| Key id | anectico-abom-2026-08 |
Public key (x) |
A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg |
| Canonical-form SHA-256 | 55a4b06e4214bc956a8602d004c124d9f6132903af066cd33827aeee70125daa |
| Signature (bill of materials domain) | kryppUbmXxJMgwxs-ZBzeWwBxW6MZTLpxkdZz9hm63sMozjslk-LhZi7AXdKiYDIpMnceudaa2NG-NfxFVk1AQ |
A correct implementation also rejects that same signature when checked in the checkpoint domain. If yours accepts it, your domain separation is not wired up.
What a checkpoint is for
Action receipts are chained: each entry’s digest covers the previous entry’s digest. That makes the chain tamper-evident against modification and reordering — but not against truncation, since deleting the end of a chain leaves a shorter chain that is perfectly self-consistent.
A checkpoint you have retained yourself closes that. Your copy says the chain held n entries ending in a specific digest at a specific time, signed by a key whose private half never left Anectico. Nothing we do later can reach your copy.
That is only true of a copy you hold. Ask for a checkpoint on a schedule that matches how much history you need to be able to prove, store it outside Anectico, and keep the ones you have.
POST /api/v1/receipt-chains/{chainId}/checkpoints?project_id=...
Requires the agents:read scope — not a write scope, because you supply nothing that goes into the
signed bytes: the sequence number and the digest are read from the chain itself. The chain is checked
first, and one that does not currently verify is not checkpointed; signing a state already known
to be faulty would make it look attested. Your project’s chain id is actions.
Ask the platform to check a chain
GET /api/v1/receipt-chains/{chainId}/verify?project_id=...
Requires the agents:read scope. The response reports whether the stored chain is internally
consistent:
| Field | Meaning |
|---|---|
valid |
Every entry’s digest recomputes, every link matches its predecessor, and the sequence is gapless. |
entry_count |
How many entries were read. Reported even when valid is false. |
mediated_entry_count |
How many of them Anectico performed or gated. |
reported_entry_count |
How many of them your agents declared. Adds to mediated_entry_count to give entry_count. |
first_invalid_seq |
The lowest sequence number involved in a fault. Meaningful only when valid is false. |
last_entry_sha256 |
The digest of the last entry read. |
detail |
A short lowercase code naming the kind of fault. Never free text and never your data. |
A chain that has never been opened — no action has been recorded in that project, by us or by you —
is a 404, not valid: false. Having done nothing is not evidence of tampering.
Read the two counts, not just valid. One chain holds both kinds of entry, and the split is what
tells you how much of a verified ledger is Anectico’s own attestation and how much is your agents’
own account of themselves. MEDIATED means the action ran through this platform. REPORTED means an
agent told us it acted, and the record proves what we received and when — not that it happened. The
word is inside the bytes each entry’s digest covers, so it survives export: an entry cannot be
relabelled without breaking its digest, which is exactly what the recipe above detects.
This check does not replace a checkpoint you hold. It reads the same stored chain it is
reporting on, so it can tell you that a chain is internally inconsistent, but it cannot tell you that
entries were removed from the end. Compare last_entry_sha256 and entry_count against a checkpoint
you retained to answer that. The two together are the complete answer; either alone is not.
The same check is available where you are already working, and both default to your project’s one action chain:
anectico receipt-chains verify # or: anectico receipt-chains verify <chain-id>
From an AI agent connected over MCP, ask it to run the verify_receipt_chain action — it is reached
through execute_read_action and needs the same agents:read scope. All three surfaces return the
same fields and the same verdict; a project that has recorded no action reports that it has no chain
rather than reporting a failure.
Fetch a bill of materials
GET /api/v1/abom-manifests/{manifestId}?project_id=...
Requires the agents:read scope. The response carries the manifest, its canonical form, the
canonical-form SHA-256 and the signature — everything the verification recipe above needs. Re-derive
the digest from the canonical bytes yourself rather than trusting the one in the response; a manifest
is immutable once signed, so the same id returns the same bytes and the same digest every time.
Read authority and release from the canonical form, not from the JSON fields beside it. They
agree today, and only one copy of each is covered by the signature.
The response also carries a verdict on the stored artifact: valid, and defect naming the reason
when valid is false. This is not a claim about the signature — checking that is your job, with
the published key and the recipe above, and an attestation whose verification depended on us would
be worth less. What it reports is the one thing you cannot determine alone: whether the byte contract
has moved underneath the artifact since it was signed.
defect |
What it means |
|---|---|
| (empty) | The stored body matches the contract on this page. |
canonical_form_superseded |
The manifest was signed under an earlier version of this contract. Its signature is still a true statement about the bytes it covers; those bytes are simply no longer a body we would produce for that manifest. Sign a fresh manifest for the asset version and reference that one going forward. |
canonical_form_unreadable |
The stored body is not JSON. This should never happen; tell us. |
digest_mismatch |
The recorded manifest_sha256 is not the digest of the recorded canonical_form. This should never happen; tell us. |
valid: false is reported with a normal 200 and the manifest is returned as stored. It is your
artifact, it is the only copy of it you can reach here, and withholding it would destroy a
historical attestation rather than protect anyone.
Declare a bill of materials
POST /api/v1/abom-manifests
Requires the agents:write scope, which is owner/admin-only to mint. Send the scope, the asset (and
optionally the asset version and deployment) the manifest describes, generated_at, and the
component list — identifiers, versions and content hashes only. Send release as well, using the
same label your agents put on their spans as gen_ai.agent.version; it goes inside the signed bytes
so a holder can check that this is the composition for the release that actually ran. Omit it
entirely if you do not tag releases — the signed body then says "release": null, which is a
different statement from "release": "", and sending an empty string to mean “none” makes the
artifact say something you did not intend. The five derived fields
(canonical_form, manifest_sha256, signature, signing_key_id, authority) must be absent; a
supplied value is rejected rather than ignored, so nothing you sent can be silently discarded and
nothing you sent can choose what the signature means. The response is the complete signed artifact,
with authority: DECLARED.