Risk-Acceptance Receipts

When a team accepts a known security risk and ships anyway, the decision usually lives in a comment thread, a ticket, or a spreadsheet cell that anyone can edit after the fact. A risk-acceptance receipt turns that exception decision into a signed, hash-chained, anchored evidence object. Asqav signs a receipt of type protectmcp:lifecycle:risk_acceptance that proves a named approver authored a reason, bound to a specific scan artifact digest and a point-in-time risk snapshot, key-signed with the agent's post-quantum key, linked into the per-agent hash chain, and timestamped by an anchor. It is the portable audit-trail object the approval-workflow gap is missing, and it reads the same whether the underlying scan came from a SAST tool or a secrets scanner.

What a risk-acceptance receipt proves, and what it does not

A risk-acceptance receipt proves exactly three things and nothing more: the exception decision existed, it was authored by the holder of a specific signing key, and it held a fixed position in the hash chain at the time it was issued. Every field below carries that and only that meaning.

The signature is applied server-side by Asqav, an operator unaffiliated with the agent's operator. The recorded party never holds the signing key, so it cannot forge or backdate its own record. That separation is what lets the receipt support audit-trail and recordkeeping obligations: the evidence comes from outside the party it is evidence about.

Asqav never makes a value true, reproducible, enforced, or trusted. The receipt is a record, not a control. It does not re-run the scan, recompute a score, enforce an expiry, or check that the approver was entitled to approve. Those are the deploying organization's decisions. Asqav's job is to make the decision that was taken tamper-evident and independently verifiable.

The scope rule, stated explicitly

These honesty statements are normative on the wire, published at GET https://api.asqav.com/.well-known/governance.json, and enforced by validators on the signing path:

The EPSS, KEV, and CVSS signals in risk_snapshot are labeled point-in-time reads, not a closure of any reproducibility gap. A verifier reads them as what the producer asserted at snapshot_at from snapshot_source, never as platform-derived risk.

The fields

A risk-acceptance receipt carries the base receipt fields every Asqav receipt carries, plus the acceptance-specific extension fields. Every extension field below is producer-supplied and signed into the payload.

Inside risk_snapshot, every numeric is a string on the wire: floats are rejected, so Asqav never holds a number it could be accused of computing. snapshot_source is required whenever any numeric signal is populated, which structurally pins the label that this is a snapshot and not a score.

A risk-acceptance receipt

json
{
  "payload": {
    "v": 1,
    "type": "protectmcp:lifecycle:risk_acceptance",
    "issued_at": "2026-06-01T19:26:44.289388Z",
    "issuer_id": "org_acme_00001",
    "action_ref": "sha256:82b51ff0063d5f0569eba20c2fce749eb8ddb601e7f1adaf67636c0fbff74b78",
    "payload_digest": { "hash": "82b51ff0063d5f0569eba20c2fce749eb8ddb601e7f1adaf67636c0fbff74b78", "size": 512 },
    "policy_digest": "sha256:3ac0b2df9f295d0f61d4ddf5d8cadafc77d0d141dc1841ae0504f7be39af6ec4",
    "previousReceiptHash": "0000000000000000000000000000000000000000000000000000000000000000",
    "decision": "observation",
    "approver_id": "approver:alice@example.com",
    "initiator_id": "initiator:bob@example.com",
    "acceptance_reason": "Compensating control in place; risk accepted for 90 days pending patch.",
    "accepted_at": "2026-06-01T19:05:00Z",
    "expires_at": "2026-08-30T19:26:44.289388Z",
    "supersedes": "act_prior_risk_acceptance_0001",
    "sarif_digest": "sha256:9cbaf8102f5eb904e89b9ab2a505091188364805a4b65c23075e5f3a5af13faa",
    "finding_ref": "github.com/acme/repo/security/code-scanning/42",
    "approval_ref": "JIRA-SEC-9001",
    "risk_snapshot": {
      "snapshot_at": "2026-06-01T19:00:00Z",
      "snapshot_source": "CISA KEV catalog + FIRST EPSS feed",
      "epss": "0.94210",
      "cvss": "9.8",
      "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "kev_listed": true,
      "cve_ids": ["CVE-2026-1234", "CVE-2026-5678"]
    }
  },
  "signature": { "alg": "ML-DSA-65", "kid": "org_acme_00001", "sig": "BASE64..." },
  "anchors": [
    { "type": "opentimestamps", "value": "BASE64-OTS", "status": "pending" }
  ]
}

The decision is observation, never allow. A risk-acceptance receipt records an acceptance, not a policy outcome, so it asserts no policy evaluation. The controls_evaluated block that Asqav stamps on this sign carries no policy key, which is the truthful statement for a receipt that evaluated no policy. See Control Attestation for how that omission-over-false rule reads to a verifier.

How to sign one

Sign with compliance_mode: true, receipt_type: protectmcp:lifecycle:risk_acceptance, and policy_decision: none. The acceptance fields only project into the signed bytes on the compliance-mode signing path. The Asqav SDK defaults compliance_mode to true, so SDK callers get them by construction.

Set the validity window with valid_seconds. The server writes expires_at into the signed payload as signed_at plus valid_seconds, falling back to a fixed default of 86400 seconds (24 hours, overridable per request via valid_seconds_default) when unset. expires_at itself is not a request field.

bash
curl -X POST https://api.asqav.com/api/v1/agents/agt_example/sign \
  -H "X-API-Key: $ASQAV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action_type": "risk:accept",
    "hash": "sha256:82b51ff0063d5f0569eba20c2fce749eb8ddb601e7f1adaf67636c0fbff74b78",
    "hash_algo": "sha256",
    "payload_size": 512,
    "compliance_mode": true,
    "receipt_type": "protectmcp:lifecycle:risk_acceptance",
    "policy_decision": "none",
    "approver_id": "approver:alice@example.com",
    "initiator_id": "initiator:bob@example.com",
    "acceptance_reason": "Compensating control in place; risk accepted for 90 days pending patch.",
    "accepted_at": "2026-06-01T19:05:00Z",
    "valid_seconds": 7776000,
    "sarif_digest": "sha256:9cbaf8102f5eb904e89b9ab2a505091188364805a4b65c23075e5f3a5af13faa",
    "risk_snapshot": {
      "snapshot_at": "2026-06-01T19:00:00Z",
      "snapshot_source": "CISA KEV catalog + FIRST EPSS feed",
      "epss": "0.94210",
      "cvss": "9.8",
      "kev_listed": true,
      "cve_ids": ["CVE-2026-1234"]
    }
  }'

The validators on the signing path keep the record honest:

How to verify one

A verifier can check four independent things offline, with no access to Asqav:

  1. Recompute the agent's post-quantum signature over the canonical payload bytes against the kid named in signature. A valid signature proves the receipt is exactly as the key holder signed it.
  2. Recompute SHA-256 over the immediately preceding receipt's signed payload and compare it to previousReceiptHash. A mismatch is a tamper signal. This confirms adjacency to the predecessor. The receipt's absolute position is established by re-deriving the chain from its seed, which the audit-pack export does over the full set of prior receipts.
  3. Resolve each entry in anchors[] against its timestamp authority to prove the payload existed no later than the anchored time T.
  4. Recompute SHA-256 over the retained SARIF bytes and compare to sarif_digest. A mismatch proves the scan artifact changed after the acceptance was signed.

Every step runs without Asqav in the loop, which is the third-party-notary posture: the decision is independently verifiable by anyone who holds the receipt and the referenced artifacts. See Third-Party Notary for why the unaffiliated-verifier property is the load-bearing one.

Which acceptance is in force: the supersedes walk

A finding gets re-accepted, narrowed, or extended over time, and each of those decisions is its own immutable receipt. Nothing in the chain is ever edited, so a verifier decides which acceptance is in force by walking the supersedes pointers:

Every pointer in the walk must come from a receipt that itself verifies. A superseding claim inside an unverified blob displaces nothing.

The expiry loop: Asqav answers, CI enforces

Asqav records the expiry and never enforces it, so the enforcement loop belongs in CI:

  1. When a finding is accepted, the suppression entry in the scanner's baseline carries the receipt id.
  2. A scheduled CI job walks the suppression entries, loads each referenced receipt, runs the supersedes walk, and checks expires_at on the current receipt.
  3. The job fails the build when a referenced receipt is expired, superseded with no current replacement, or missing.
  4. Removing the failed suppression lets the scanner re-flag the finding on its next run, which forces a fresh decision and a fresh receipt.

Asqav answers the question "is this acceptance still in force". The build answers "then you may not ship". Keeping the two roles apart is the point: the record stays honest because the recorder has no stake in the gate.

Honest limits: signed garbage is still garbage

The receipt proves the values were asserted at a specific time, under a named key, in a tamper-evident chain. It does not make them true, and it does not make them enforced. An approver_id read from an environment variable anyone on the build can set, or an epss value typed by hand, signs just as cleanly as a real one. Signing a forgeable source notarizes the forgery. Fix the source first (take identities from your SSO or CI identity token, take snapshot values straight from the feed response), then sign what came out.

Regulatory framing: evidence, not enforcement

A risk-acceptance receipt is evidence for an audit, not an enforcement control. It maps to records-and-audit-trail obligations without claiming to satisfy them on its own:

In both cases Asqav supplies the evidence layer. The receipt proves the decision happened and held its place in the chain. Whether the acceptance was wise, the score correct, or the control sufficient stays the deploying organization's judgment.

Related documentation