Skip to content

Attesting progress from an external writer

Use a progress attestation when work changes a claimed file outside Anvil's file-change hooks. The artifact binds one changed path to the exact claim, claim generation, repository, PRD revision, task revision, and claim-start baseline. Anvil verifies the local bytes or Git object before it records the artifact.

An attestation is renewal evidence, not completion evidence. It does not change task status and does not replace anvil submit.

Obtain the immutable claim values

Create the claim before the external writer changes an expected path, then save the structured responses:

anvil status --json > status.json
anvil claim T007 --actor external-writer --json > claim.json

The claim response contains data.claim.generation and data.claim.attestation_context. The context records the claim-start Git SHA, repository identity, PRD/task revisions, and each canonical expected path with its SHA-256 baseline. A missing file has baseline_sha256: null.

If attestation_context is null, the project was not an accessible Git repository when it was claimed. External attestations are unavailable for that claim; Anvil preserves legacy hook-observed renewal behavior instead.

Version 1 wire schema

The input is one JSON object. Unknown fields, duplicate keys, floats, malformed Unicode, and alternate spellings are rejected.

{
  "envelope_id": "producer-run-42",
  "payload": {
    "schema_version": 1,
    "kind": "file",
    "project_id": "my-project",
    "claim_id": "C1234ABCD",
    "generation": 2,
    "task_id": "T007",
    "task_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "prd_id": "default",
    "prd_revision": 4,
    "claimed_by": "external-writer",
    "repository_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "claim_start_sha": "cccccccccccccccccccccccccccccccccccccccc",
    "commit_sha": "cccccccccccccccccccccccccccccccccccccccc",
    "path": "src/feature.py",
    "prior_sha256": null,
    "file_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
    "issued_at": "2026-08-08T19:30:00Z"
  }
}

The optional signed form adds an issuer sibling to payload:

{
  "envelope_id": "producer-run-42",
  "payload": {"...": "the complete payload above"},
  "issuer": {
    "algorithm": "ed25519",
    "signer_id": "16-lowercase-hex-characters",
    "public_key": "64-lowercase-hex-characters",
    "signature": "128-lowercase-hex-characters"
  }
}

FileProgressPayload and CommitProgressPayload have the same complete key set shown above; neither kind permits an omitted or additional field. Their discriminated contracts are:

FileProgressPayload   = payload with kind="file"
                        and commit_sha=claim_start_sha
CommitProgressPayload = payload with kind="commit"
                        and commit_sha=<full descendant commit object id>

For both variants, path, prior_sha256, and file_sha256 remain mandatory (with only prior_sha256 allowed to be JSON null). The distinction is which repository object Anvil independently reads and hashes.

Field meanings:

Field Contract
envelope_id Producer-chosen non-empty identifier. It is audited but is not part of semantic identity or the signature.
schema_version Integer 1 exactly; booleans are not integers here.
kind file for current working-tree bytes, or commit for one path in a descendant commit.
project_id Exact data.project_id from anvil status --json.
claim_id, generation, task_id, claimed_by Exact values from the active claim. A later claim generation cannot replay an older artifact.
task_revision, prd_id, prd_revision, repository_id, claim_start_sha Copy verbatim from claim.attestation_context.
path One path in attestation_context.expected_paths, using its canonical /-separated spelling. Absolute paths, .., empty segments, Windows device names, alternate data streams, and unsafe trailing dots/spaces are rejected.
prior_sha256 Copy that path's baseline_sha256 verbatim, including null for a path absent at claim start.
file_sha256 Lowercase SHA-256 of the raw current file bytes (file) or raw Git blob bytes at commit_sha:path (commit). It is not a text-normalized digest.
commit_sha For file, exactly claim_start_sha; local HEAD must still equal it. For commit, the full object ID of a commit descended from claim_start_sha in which path is a changed regular blob.
issued_at Timezone-aware ISO 8601 time at or after claim creation, not in the verifier's future, while the claim is active and unexpired.

Both kinds require a real change from prior_sha256. For file, the verifier reads the contained regular file in the current working tree. For commit, it resolves the exact full commit ID, proves claim-start ancestry, and hashes the regular Git blob without checking out the commit.

Canonical bytes, digest, and signature

The file must be exactly Anvil canonical JSON bytes:

  • UTF-8 without a BOM or trailing newline;
  • object keys sorted lexicographically;
  • no whitespace between tokens (separators=(",", ":"));
  • Unicode emitted as UTF-8 rather than ASCII escapes;
  • only JSON null, booleans, signed 64-bit integers, strings, arrays, and string-keyed objects; floats are forbidden;
  • at most 262,144 decoded bytes.

Use anvil.state.hashing.canonical_json_bytes; ordinary pretty-printed JSON is not accepted. Let evidence_core be the complete payload with only issued_at removed. Semantic identity is:

hex_sha256(b"anvil.progress-attestation.v1\0" + canonical_json_bytes(evidence_core))

issued_at, envelope_id, and issuer therefore cannot change the semantic digest. This makes two reports of the same claim-bound file/commit evidence the same semantic evidence even when their wrapper or reporting time differs.

For a signed envelope, the Ed25519 signature preimage is exactly canonical_json_bytes(payload). There is no additional signature-domain prefix. signer_id is the first 16 lowercase hex characters of SHA-256 over the raw 32-byte public key. The embedded public key or fingerprint must appear in $ANVIL_TRUST_LIST, or in ~/.anvil/trust.txt when that variable is unset, one value per line. A signed artifact that is valid but not configured as trusted is rejected.

That trust configuration is also an operational replay dependency. Live append and every later state replay revalidate the embedded fingerprint, signature, and current issuer membership using $ANVIL_TRUST_LIST, or ~/.anvil/trust.txt when the variable is unset. If that file is missing, moved, or changed so the recorded issuer is no longer trusted, replay fails closed. Back up the applicable trust-list file with the Anvil state and restore it at the same configured path before rebuilding or moving the workspace. Retain at least the recorded issuer's full public key or fingerprint; never back up or distribute the private signing key as verifier state.

An envelope without issuer is recorded as claim_owner_self_attested. A trusted valid signed envelope is recorded as configured_issuer_verified. Actor identity remains local audit attribution; the unsigned mode does not turn the actor string into authentication. Replay of self-attested evidence is deterministic and does not depend on the trust list.

Reproducible generator

Save this as make_progress_attestation.py. It uses Anvil's public canonicalizer and signing helpers, so its output is byte-for-byte acceptable to the loader.

from __future__ import annotations

import argparse
import hashlib
import json
import subprocess
from datetime import UTC, datetime
from pathlib import Path

from anvil import signing
from anvil.state.hashing import canonical_json_bytes

parser = argparse.ArgumentParser()
parser.add_argument("--claim", type=Path, required=True)
parser.add_argument("--status", type=Path, required=True)
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--path", required=True)
parser.add_argument("--kind", choices=("file", "commit"), required=True)
parser.add_argument("--commit")
parser.add_argument("--envelope-id", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--sign", action="store_true")
args = parser.parse_args()

claim = json.loads(args.claim.read_text(encoding="utf-8"))["data"]["claim"]
project_id = json.loads(args.status.read_text(encoding="utf-8"))["data"][
    "project_id"
]
context = claim["attestation_context"]
if context is None:
    raise SystemExit("claim has no attestation_context")
baselines = {item["path"]: item["baseline_sha256"] for item in context["expected_paths"]}
if args.path not in baselines:
    raise SystemExit("--path is not in the claim's canonical expected_paths")

if args.kind == "file":
    commit_sha = context["claim_start_sha"]
    content = (args.root / args.path).read_bytes()
else:
    if not args.commit:
        raise SystemExit("--commit is required for kind=commit")
    commit_sha = args.commit
    content = subprocess.check_output(
        ["git", "-C", str(args.root), "show", f"{commit_sha}:{args.path}"]
    )

payload = {
    "schema_version": 1,
    "kind": args.kind,
    "project_id": project_id,
    "claim_id": claim["id"],
    "generation": claim["generation"],
    "task_id": claim["task_id"],
    "task_revision": context["task_revision"],
    "prd_id": context["prd_id"],
    "prd_revision": context["prd_revision"],
    "claimed_by": claim["claimed_by"],
    "repository_id": context["repository_id"],
    "claim_start_sha": context["claim_start_sha"],
    "commit_sha": commit_sha,
    "path": args.path,
    "prior_sha256": baselines[args.path],
    "file_sha256": hashlib.sha256(content).hexdigest(),
    "issued_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
}
envelope = {"envelope_id": args.envelope_id, "payload": payload}
if args.sign:
    private_key, public_key, signer_id = signing.load_or_create_signer()
    envelope["issuer"] = {
        "algorithm": "ed25519",
        "signer_id": signer_id,
        "public_key": public_key,
        "signature": signing.sign(private_key, canonical_json_bytes(payload)),
    }
args.output.write_bytes(canonical_json_bytes(envelope))

File-mode example after changing src/feature.py:

uv run --project bin python make_progress_attestation.py --claim claim.json --status status.json --path src/feature.py --kind file --envelope-id producer-run-42 --output progress.json
anvil progress T007 external-write --attestation-file progress.json --actor external-writer --json

When Anvil is installed in the active Python environment, run the script with python directly; uv run --project bin is the source-checkout form.

Commit-mode example after committing the expected path on a descendant of the claim-start commit:

uv run --project bin python make_progress_attestation.py --claim claim.json --status status.json --path src/feature.py --kind commit --commit 0123456789abcdef0123456789abcdef01234567 --envelope-id producer-run-43 --output progress.json

Use the full commit object ID from git rev-parse HEAD; the synthetic value above is only representative. Add --sign to either generator command to use Anvil's configured Ed25519 runner key, then add the emitted envelope's issuer.signer_id or issuer.public_key to the configured trust list before submitting it.

For MCP, standard-base64 encode the exact file bytes, including required = padding and without whitespace:

import base64
from pathlib import Path

attestation_base64 = base64.b64encode(Path("progress.json").read_bytes()).decode("ascii")

Call submit_progress with task_id, the exact claim actor, that attestation_base64, and the repository cwd. Noncanonical base64, URL-safe spellings, omitted padding, and whitespace are rejected.

Renewal and replay behavior

Acceptance records the semantic digest, claim generation, kind, issuer, and trust mode as progress.attested; it does not renew immediately. The next successful anvil renew or MCP renew_claim atomically consumes that pending artifact and reports:

{
  "source": "attestation",
  "digest": "semantic-digest",
  "generation": 2,
  "trust_mode": "configured_issuer_verified"
}

The same artifact cannot authorize a second renewal. A release, stale claim, different generation, different owner, changed PRD/task revision, moved file baseline, unrelated repository, or expired lease also prevents reuse. Ordinary progress.noted text is audit-only and never authorizes renewal.

Only one unconsumed attestation may be pending for a claim generation. A second submission is refused; if independently appended branches replay two pending facts for the same generation, Anvil quarantines the collision rather than choosing one as renewal authority.