Skip to content
Console
Browse documentation
Reference

Verify a signed attestation offline

Check an exported agent bill of materials or action-receipt checkpoint yourself, with no call to Anectico.

On this page

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.

Online signature and receipt-chain checks keep their complete evidence even when invalid. MCP returns status: error and a summary starting invalid: <reason>. The corresponding CLI commands print the full verdict and exit 1 when invalid, including JSON output. The manifest's online valid field checks its digest, body contract and Ed25519 signature against a published key. Verify independently with the public key below when you need to check the server's assertion. Receipt-chain verification authenticates a supplied retained checkpoint before comparing its chain position.

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_at is 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

A bill of materials carries canonical_form: the exact bytes the signature covers. Verify against that string, byte for byte. Its other fields are a convenience view over those bytes. The public checkpoint object instead carries scope, chain, sequence, entry digest, generation time, checkpoint digest, signature, signing key and algorithm. It has no canonical_form field. Reconstruct its unsigned body using the fixed checkpoint contract below before checking its digest and signature. Passing the complete object to receipt-chain verification does this for you.

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:

  1. Timestamps are UTC RFC 3339 with exactly nine fractional digits, trailing zeros kept: 2026-08-16T09:30:00.000000000Z.
  2. Enumerated values enter as integers, not display names. A bill-of-materials component's kind is the numeric value from the API's AssetKind enumeration.
  3. Every field is always present, including empty optional identifiers, which appear as "". The key set never depends on the data.
  4. 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.
  5. release is the one member whose value may be null. The key is still always present — rule 3 holds — but its value distinguishes two facts that must never collapse into one: null means 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.

For a public checkpoint, the body above has exactly these members. Set schema to anectico.receipt-checkpoint.v1 and the signed body's signature_alg to EdDSA. The public API's algorithm is SIGNATURE_ALGORITHM_ED25519. Preserve the original scope, chain, sequence, entry digest and key id. Render the generation time in UTC with nine fractional digits. The signature and checkpoint_sha256 are outside this unsigned body.

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, re
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 checkpoint_canonical(artifact):
    if artifact["signature_alg"] != "SIGNATURE_ALGORITHM_ED25519":
        raise ValueError("unsupported checkpoint algorithm")
    seq = artifact["seq"]
    if (isinstance(seq, bool) or not isinstance(seq, (int, str))
            or not re.fullmatch(r"0|[1-9]\d*", str(seq)) or int(seq) >= 2**64):
        raise ValueError("checkpoint sequence must be an exact uint64")
    # Checkpoint command timestamps are UTC; preserve their fractional precision.
    match = re.fullmatch(r"(.+T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z", artifact["generated_at"])
    if not match:
        raise ValueError("checkpoint timestamp must be UTC RFC3339")
    body = {"chain_id": artifact["chain_id"], "entry_sha256": artifact["entry_sha256"],
            "generated_at": match[1] + "." + (match[2] or "").ljust(9, "0") + "Z",
            "schema": "anectico.receipt-checkpoint.v1", "scope": artifact["scope"],
            "seq": int(seq), "signature_alg": "EdDSA",
            "signing_key_id": artifact["signing_key_id"]}
    return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

def verify(artifact, jwks, domain):
    canonical = (artifact["canonical_form"].encode("utf-8") if domain == ABOM_DOMAIN
                 else checkpoint_canonical(artifact))

    # 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 Meaning
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=...

Checkpoint signing requires agents:read, agents:write, mcp:read and mcp:write. It is a write because it retains a signed attestation. The first request previews the complete unsigned checkpoint and signing key. A faulty or empty chain is refused. Review that preview, then repeat identical arguments with confirm_token and a stable idempotency_key.

anectico receipt-chains checkpoint actions --idempotency-key <key>
# After reviewing the returned preview:
anectico receipt-chains checkpoint actions --idempotency-key <key> --confirm-token <token>

Over MCP use sign_receipt_checkpoint through execute_internal_action. The confirmed mutation receipt contains the complete signed checkpoint in its /checkpoint change. Retain that exact checkpoint outside Anectico. Later pass it as retained_checkpoint to verify_receipt_chain, or save the CLI response and use anectico receipt-chains verify actions --checkpoint-file <file>. Verification authenticates its scope, signature, sequence and digest. matched means that exact entry survives unchanged; contradicted makes verification invalid, including a consistently deleted tail. outside_retained_range means retention removed the earlier assertion; it is not a match.

Record your own agents' actions

Actions Anectico performs or gates are entered in the chain as mediated. You can add your own agents' actions to the same chain:

POST /api/v1/action-receipts/entries

Requires the agents:write scope. Send the draft — action_id (an id you mint for the action), state, occurred_at, tool_name and the other fields you know — and omit chain_id and receipt_authority, which are set for you — a draft that supplies either is refused. The entry is recorded as reported, never mediated: it proves what you told us and when, not that the action happened. Sending a byte-identical draft again within seven days returns the entry already recorded instead of adding a second one, so a retry after a timeout is safe; after seven days the same draft is recorded as a new entry. A reported entry does not add anything to the authority graph.

anectico receipt-chains append --environment production --file draft.json
anectico receipt-chains append --environment production --file draft.json --confirm-token "$TOKEN"

Over MCP this is the append_action_receipt write action. REST, MCP and the CLI preview the complete current and proposed entry first, then require confirmation with identical arguments. The preview needs both agents:read and agents:write and stops if current state cannot be read. The applied answer carries the entry's sequence number and digest. Recording the entry does not perform its reported action.

Ask your agent

Check that this project's action receipt chain is intact, and give me a signed checkpoint I can keep outside Anectico.

Job MCP tool or action CLI command
Read one action's receipt with every ledger entry get_action_receipt anectico action-receipts get
List receipts by action or chain list_action_receipts anectico action-receipts list
Ask the platform to check a chain verify_receipt_chain anectico receipt-chains verify
Get a signed checkpoint to keep sign_receipt_checkpoint anectico receipt-chains checkpoint
Sign or read an agent bill of materials sign_abom_manifest, get_abom_manifest anectico abom sign, anectico abom get

Verification reads need mcp:read and agents:read; checkpoint signing additionally needs mcp:write and agents:write. A receipt has no proof page. The signed checkpoint and the offline check below are the proof.

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.
fault_count Total number of faults found. Zero when valid is true.
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.
verified_from_seq The sequence number of the first entry the check covered. 0 until entries age out of the chain; see below. Entries before it are not verified: they no longer exist.
anchor_entry_sha256 The digest of entry verified_from_seq - 1, the last one that aged out. Empty while verified_from_seq is 0.
prune_anchor The signed statement that entries before verified_from_seq were removed for age, returned whole so you can check its signature yourself. Absent while verified_from_seq is 0.
redacted_entry_count How many of the entries read had personal values erased. They are counted in entry_count.
redaction_statement_count How many of the entries read are the ledger's own statements of which entries an erasure redacted.

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.

Entries older than 400 days leave the chain, under a signed prune anchor. A receipt is kept for 400 days. When the oldest entries pass that age they are removed from the front of the chain, never from the middle, and in the same step a prune anchor is signed and stored. The chain then starts at verified_from_seq and its first remaining entry links to anchor_entry_sha256.

A prune anchor is not a checkpoint. A checkpoint is something you can ask for at any time, so it cannot be what permits removing the start of a chain. The anchor is a separate signed statement, written only when entries are removed for age, and it says exactly what was done:

Field Meaning
scope The organization, project and environment of the chain.
chain_id The chain.
base_seq The sequence number the chain now starts at. Equals verified_from_seq.
base_prev_sha256 The digest of entry base_seq - 1, the last one removed. Equals anchor_entry_sha256.
cutoff Every removed entry was recorded before this instant.
retention_seconds The retention in force when the entries were removed.
generated_at When the anchor was signed.
anchor_sha256, signature, signing_key_id, signature_alg The digest of the anchor, its Ed25519 signature and the key that made it.

What the check does with it, and what it leaves to you. The check refuses a chain that starts after sequence 0 unless an anchor for exactly that start is present, states that start and that digest, and carries a signature that verifies under one of our published verification keys. A chain whose early entries are missing without such an anchor does not verify (anchor_contradicted), and so does one whose anchor was altered or signed by anything else. A checkpoint is never accepted in place of an anchor.

The check cannot verify the entries that were removed: they are gone, and nobody can recompute the digest of an entry that no longer exists. valid: true on a chain with verified_from_seq above 0 is a statement about the entries from there on, plus a signed statement about where the chain starts. It is not a statement that the removed entries were intact. If you need that, keep a checkpoint of your own while the entries exist.

You do not have to take our word that the anchor's signature is good. Recompute it:

body      = canonical JSON of
            {"base_prev_sha256", "base_seq", "chain_id", "cutoff", "generated_at",
             "retention_seconds", "schema": "anectico.receipt-prune-anchor.v1",
             "scope": {"environment", "org_id", "project_id"},
             "signature_alg": "EdDSA", "signing_key_id"}
digest    = SHA-256(body)                 // equals anchor_sha256
signed    = "anectico.receipt-prune-anchor.signature.v1" || 0x00 || digest

with keys sorted, no insignificant whitespace, integers in decimal, and both timestamps as UTC RFC 3339 with exactly nine fractional digits (2027-09-20T00:00:00.000000000Z). Verify signature (unpadded base64url) over signed with the Ed25519 public key whose id is signing_key_id. The first string is different from the one a checkpoint is signed under, so a checkpoint's signature never verifies as an anchor's.

entry_count and the two authority counts are of the entries that remain. A checkpoint you retained over an entry that has since aged out is still a true, signed statement about the chain at that time; it can no longer be compared against a stored entry.

An entry whose personal values were erased still verifies. A receipt can say who an action was about. Those values — distinct_id, subject_refs, purpose, resource_uris, the session and turn, and the three digests sent with the entry — are not inside an entry's digest in the clear. The digest covers one commitment per value:

SHA-256("anectico.receipt-sealed.v1" || 0x00 || field name || 0x00 || salt || value)

where the salt is 32 random bytes drawn for that one entry and kept beside the values, outside the chain. Erasing the person deletes the salt and the values together. The commitments, and so every digest, link and checkpoint, are unchanged; and without the salt a commitment cannot be matched to a value, even a guessed one. Such entries are counted in redacted_entry_count. The people and credentials an entry names as having performed, requested or approved the action are in the digest in the clear and are never erased.

An erasure is itself in the ledger. An entry is not treated as erased just because its values are missing. When an erasure removes values from entries, it appends one or more statements to the same chain in the same step: a mediated entry with tool redact_receipt_entries and an action_id of the form redaction:<erasure operation id>:<n>, whose resource_uris list the affected entries (anectico:receipt-chain/actions?redacted=3-5,9). It names the erasure operation and the entries, and no person. An entry recorded after its subject was erased says so in its own digest (born_redacted is true in its body) and needs no such statement. The check reports redaction_unaccounted for an entry whose values are missing with neither, and for a statement that does not match what it names. Nobody can submit an entry under the redaction: prefix.

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. It checks the digest, current body contract, indexed authority and release and signing key agreement, and the Ed25519 signature against the published key in the ABOM domain. Retained published keys still verify after rotation. An unavailable key is an explicit defect. Verify independently with the recipe above when you need to check the server's assertion.

From a terminal, anectico abom get <manifest-id> returns the same response. Over MCP, get_abom_manifest returns the canonical form and signature byte-exact alongside the verdict. Its canonical_form is an untrusted document: use its exact value, never its display label. The read takes a manifest id and project; its environment is part of the signed body.

defect What it means
(empty) The stored body matches this contract and its signature verifies under the published key.
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.
column_body_mismatch An indexed authority, release or signing key disagrees with the signed body.
signature_invalid The detached signature does not verify in the ABOM domain.
signing_key_unavailable The signing key is absent from the current published set; signature validity cannot be established.
signature_algorithm_unsupported The declared signature algorithm is unsupported.

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 or 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". Two representations of the same fact coexist on that response: the top-level REST field carries the prefixed enum name (ABOM_AUTHORITY_DECLARED), while the lowercase declared shown above is the value inside canonical_form — the bytes the signature actually covers.

To sign from a terminal, put the manifest in a file and run anectico abom sign --file manifest.json. Over MCP, use the sign_abom_manifest write action. Both require manifest_id — generate a UUID for each manifest you intend to sign. With it, retrying the same request returns the same signed artifact, and a different manifest sent under an id already used is refused rather than signed twice.

Checkpoint signing requires a durable idempotency_key (anectico receipt-chains checkpoint <chain-id> --idempotency-key <intent>). A retry returns the original signed artifact after chain growth; use a new key for a fresh tail. Preview and confirm with the same key. The chain supplies its actual environment. The preview may return an already-issued signed checkpoint unchanged; it creates no signature.

The applied or replayed structured receipt retains the complete detached checkpoint as JSON in the target change whose path is /checkpoint. Decode that change's after value and keep the exact object outside Anectico. Its target reference identifies the artifact; it is not a browser proof link.