Skip to content

Agent verification metadata store

The pycharter.verification_store package persists R1.7 preparations and R3 receipts. pycharter.agent.hosted_verification connects it to scoped preparation and exact verification. The HTTP routes are POST /api/v1/agent/preparations and POST /api/v1/agent/verify. They require a verified local JWT with a subject and concept-scheme workspace UUID; the namespace comes from that workspace. A receipt documents a check and grants no action permission. The shipped name-only validate_record and offline MCP paths retain their existing behavior.

Versioned DTOs

PreparationEvent (schema_version: "1.0") records an opaque preparation ID, principal and workspace, exact contract namespace/name/version and fingerprint, effective context fingerprint, policy projection label, creation time, and expiry. A JSON null namespace means the global namespace. Hosted preparation mints the ID and persists the event before returning the view. The lifetime is one hour, within the DTO's 24-hour maximum.

VerificationReceipt (schema_version: "1.0") records an opaque receipt ID, the same scope and exact contract/context identity, preparation ID, time, accepted/rejected verdict, normalization status, stable diagnostic codes and field paths, attempt number, optional correlation/attempt IDs, and validator version. It carries no diagnostic messages or values. A new DTO has recorded: false; a successful store write returns recorded: true. Storage failure cannot yield a stored receipt. In the default required mode, the hosted service returns a 503 with no accepted verdict when persistence fails. An explicitly selected optional mode may return recorded: false with reason: receipt_unavailable. A conflicting replay always fails closed.

Both DTOs reject unknown keys and require timezone-aware timestamps and sha256: fingerprints with 64 lowercase hexadecimal digits. They cannot accept a candidate record, normalized record, prompt, full schema/context, secret, or unrestricted error text. The SQL payload JSON and MongoDB documents contain only these allowlisted fields.

Backend and replay behavior

open_verification_store selects SQLite, PostgreSQL, or MongoDB. SQL stores use agent_preparations and agent_verification_receipts in the pycharter schema on PostgreSQL; MongoDB uses equally named collections. Alembic adds both SQL tables. The caller must supply a configured HMAC key ring and active key ID when opening the store. Keys are never persisted or logged.

save_receipt receives canonical request bytes only in memory. When both correlation and attempt IDs exist, the store computes and persists a server-keyed HMAC-SHA256 digest. The unique key is principal, workspace, correlation ID, and attempt ID. An identical retry returns the original receipt, including its ID; different canonical bytes raise ReceiptConflictError. If either caller ID is missing, every call creates a fresh receipt and stores no digest. The host must canonicalize the full verification request consistently before calling this store; the raw bytes are never written to either backend. Hosted canonicalization covers the exact contract/context identity, candidate, direction, normalization handoff, attempt number and labels, receipt mode, projection options, and validator version. The candidate exists only in request memory and this temporary digest input.

On key rotation, keep prior keys until the replay window has closed. The store verifies a replay with the key ID used for the original receipt. If that key is unavailable, it raises ReplayKeyUnavailableError and refuses to guess whether the request is identical. This failure is safe to surface as a stable conflict, without exposing canonical request bytes.

get_preparation and get_receipt require the original principal and workspace; an opaque ID alone does not grant access. The preparation read also enforces expiry. The hosted write service derives identity from its JWT, rechecks workspace namespace and the current prepared fingerprint before validating, and refuses a stale or foreign preparation. HTTP receipt reads apply current JWT scope and commit an audit event before querying metadata.

Environment provisioning

The HTTP API and the remote MCP command use these explicit settings:

Environment variable Meaning
PYCHARTER_AGENT_VERIFICATION_URL Required dedicated metadata-store URL: file-backed SQLite, PostgreSQL, or MongoDB.
PYCHARTER_AGENT_RECEIPT_KEYS Required JSON object mapping key IDs to standard base64-encoded HMAC keys, each at least 32 bytes.
PYCHARTER_AGENT_RECEIPT_ACTIVE_KEY Required ID of an entry in that key ring, used for new replay digests.
PYCHARTER_AGENT_RECEIPT_RETENTION_DAYS Optional integer from 1 to 90; defaults to 90. Applies to receipts and receipt-read audit events.

Supply URL credentials and key material through the deployment's secret environment. For example, configure the ledger location explicitly:

export PYCHARTER_AGENT_VERIFICATION_URL=sqlite:////var/lib/pycharter/agent-metadata.db
export PYCHARTER_AGENT_RECEIPT_ACTIVE_KEY=receipt-current
# Inject PYCHARTER_AGENT_RECEIPT_KEYS from the secret store as
# {"receipt-current":"<base64 encoding of at least 32 random bytes>"}.
PYCHARTER_DATABASE_URL="$PYCHARTER_AGENT_VERIFICATION_URL" pycharter db upgrade

Run SQL migrations against the dedicated ledger URL, as well as against the contract database, before enabling receipt reads. The migration creates agent_receipt_read_events alongside preparation and receipt tables. MongoDB creates the corresponding collections and indexes in the URL's database (default pycharter) when opened. Hosted Mongo URLs must use acknowledged write concerns; w=0 is refused. The hosts never infer this URL from PYCHARTER_DATABASE_URL, accept in-memory SQLite, or reuse the JWT signing secret. These keys protect internal replay digests; the receipt itself is metadata, not a signed authorization token.

The per-command database setting above points the migration runner at the ledger even when the host also has a contract database URL configured. It does not change the host's contract database setting.

To rotate, first install the new key alongside all retained old keys on every host, then switch the active ID. Keep old keys through the entire retained receipt replay window. HTTP requests open and close their own ledger handle; the remote command holds its configured handle until shutdown and must be restarted when its key settings change. Invalid/missing host settings produce a generic HTTP 503 or stop remote startup without printing input secrets.

Hosted HTTP migration and deployment

The prepare body accepts name, optional exact version, profile, and budget_tokens. It deliberately has no principal, workspace, or namespace field. The response adds a preparation_id and recorded: true to the governed prepared view. The verify body requires that ID, an exact contract_ref such as orders@1.0.0, context_fingerprint, and a JSON object candidate. Optional fields are correlation_id, attempt_id, attempt_number, direction, profile, budget_tokens, and allow_normalized_record. A completed result includes receipt: {id, recorded, reason}. The receipt ID is the same on an identical retry with both caller labels; different content under that key yields 409. Receipt mode is a trusted host setting, never a caller request field. The HTTP dependency get_hosted_receipt_mode defaults to required; a deployment may explicitly inject optional when its policy permits unrecorded results.

The HTTP store dependency get_hosted_verification_store shares the explicitly configured ledger with receipt reads. Trusted deployment dependency overrides remain available. The application operator must provision the store, run SQL migrations where applicable, retain retired HMAC keys through the replay window, and schedule retention. External introspection tokens need a separately reviewed identity adapter; this route accepts verified local JWTs only.

The authenticated remote MCP server offers the same lifecycle when its host passes RemoteHostedVerification to create_app: remote preparation is recorded, and validate_record accepts only the exact hosted request. Its receipt mode is the same trusted host setting. See Authenticated remote MCP.

Existing offline verify_prepared_record callers need no migration. A hosted caller first POSTs a preparation, then POSTs verification with its returned ID and fingerprint. A caller-supplied fingerprint without a stored preparation does not authorize hosted validation.

Retention and remaining operations

Receipt retention defaults to 90 days. A tenant may configure a shorter window from 1 to 90 days. Reads hide receipts outside that window even before cleanup. sweep_retention(now=...) deletes expired preparations and receipts beyond the configured window; sweep_read_events(now=...) deletes audit events beyond the same window. The operator command runs both sweeps:

pycharter agent receipts sweep

It uses the same host environment and reports preparation, receipt, and audit deletion counts as JSON. Schedule it with the deployment's job runner; it is safe to repeat. An unavailable store returns a nonzero exit status with a generic message. No automatic scheduler is installed. A deleted receipt cannot be recovered from this store. Tenant deletion and export need a separate authenticated, auditable service operation before R3.2/R3.4 can be claimed complete. The current store has no public bulk export or deletion endpoint and does not expose raw request payloads.

Tenant deletion/export and deployment retention scheduling remain in the single BACKLOG.md R1/R2/R3 entries.

Audited receipt reads

GET /api/v1/agent/receipts, its /{receipt_id} detail route, and GET /api/v1/agent/receipt-conformance require a verified local JWT even when development authentication is disabled. Viewers see only their own receipts; reviewers may select a principal in their workspace. A superuser must name a workspace explicitly. An opaque receipt ID never grants access by itself.

Before querying, the service writes a ReceiptReadEvent (schema_version: "1.0") through record_read_event. Its strict metadata-only shape contains event ID, aware timestamp, operation (recent, detail, conformance, or trend), actor, workspace, optional principal filter, and optional target receipt ID. Detail events require the exact principal and receipt ID. No raw record or free-text diagnostic can be added. SQL commits and MongoDB acknowledged journaled writes complete before reads proceed; an audit failure returns generic 503 and prevents the receipt query. Events survive restart and can be inspected through the operator store's workspace-scoped get_read_event; there is no unauthenticated audit endpoint.

The original VerificationStore protocol remains the preparation/receipt interface for existing injected ledgers. ReceiptAuditStore is the additional capability for read audit operations. HTTP audit overrides and retention sweeps require that capability; remote prepare/verify custom stores retain their original interface.

Receipt-backed conformance reports

pycharter.agent.receipt_conformance.summarize_receipt_conformance reads retained receipt metadata through the same store on SQLite, PostgreSQL, and MongoDB. Its caller supplies an authenticated workspace scope, an optional agent principal, a half-open UTC window (since inclusive, until exclusive), and a fixed now. The public scan_receipts store operation returns at most scan_limit records, newest first, and flags whether more records existed. The default limit is 2,000.

The report groups by exact contract namespace, name, and version plus agent principal. Its denominator is persisted receipts in the requested scope, not all verification attempts. First-pass acceptance means an accepted receipt with attempt_number == 1; repaired acceptance means an accepted receipt with attempt_number > 1. Rejected receipts contribute one count per distinct diagnostic code and field path, showing the top 10 categories in each group; duplicate diagnostics in a single receipt do not inflate a category. The report keeps contract quality scores separate from agent-output conformance.

denominator_exact is false when the scan is truncated or the requested window extends before configured retention. Observed rates are then null; the visible counts are lower bounds. coverage_complete is always false in this receipt-only slice, because failed receipt writes and quarantine are not represented by a persisted receipt. Both fields are null, and incomplete_reasons names the missing sources. No total verification rate, receipt availability percentage, or quarantine count is inferred from these receipts. The HTTP operator path requires authenticated workspace scope and the environment-provisioned ledger and audit table before exposing reports or diagnostic paths.

pycharter.agent.receipt_trends.summarize_receipt_trend counts the same persisted receipts per exact contract version (namespace, name, version) and UTC day, over days days ending on the day that contains now (1–366). Each series has one point per day, oldest first, with zero-filled days, so a chart can show gaps. A point reports first-pass acceptances, repaired acceptances, rejections, and observed rates over that day's persisted receipts. At exactly UTC midnight, a one-day window is empty; it never counts the previous day's receipts.

A day is complete only if every retained receipt from that day was read. A day that starts before the retention boundary, or that the bounded newest-first scan may have cut off, is incomplete. The report lists it in incomplete_days, and its rates are null; its counts are lower bounds. ReceiptScan.retained_since reports the lower bound the store actually read. It is the later of the requested start and retention; SQLite, PostgreSQL, and MongoDB set it. coverage_complete is always false, for the same reasons as the summary: write failures and quarantine are not persisted receipts.

The HTTP route is GET /api/v1/agent/receipt-conformance/trend with days (default 30), workspace, and principal. It uses the same verified-JWT scope rules as the other receipt reads. A viewer sees only their own receipts, and a superuser must name a workspace. It records a trend audit event before reading, and fails closed with a generic 503 when storage or auditing is unavailable.