Skip to content

Quickstart: verify your first receipt

This tutorial starts after a deployment of the Google Cloud Marketplace listing reports READY. It creates one truthful example packet from locally available values, uploads it, retrieves the generation-bound receipt, and verifies the receipt offline.

Outcome

At the end you will have:

  • one finalized packet object in the artifact bucket;
  • one create-only receipt object in the evidence bucket;
  • one BigQuery index row keyed by event_key;
  • the issuer public key and independently retrieved fingerprint; and
  • a successful offline verification result against the original packet bytes.

HX-Provenance does not call Vertex AI for you. Your application calls its selected Vertex AI model, then constructs the packet documented here.

Before you begin

You need:

  • a deployed HX-Provenance for Vertex AI Marketplace release whose bootstrap status is READY;
  • gcloud, bq, Python 3.11 or later, and access to the signed release verifier package;
  • permission to create objects in the artifact bucket;
  • permission to read receipt evidence and the BigQuery receipt index; and
  • authorized access to the public-key and public-key-fingerprint secret values.

Copy these values from the Marketplace deployment outputs. If you deployed the reviewed Terraform module directly, obtain them with terraform output -raw OUTPUT_NAME.

export PROJECT_ID="your-project-id"
export REGION="us-central1"
export ARTIFACT_UPLOAD_URI="gs://deployment-artifact-bucket/packets/"
export EVIDENCE_BUCKET="deployment-evidence-bucket"
export BQ_TABLE="project.dataset.receipts"
export PUBLIC_KEY_SECRET="deployment-public-key-secret"
export PUBLIC_KEY_SHA256_SECRET="deployment-public-key-sha256-secret"

Confirm the upload URI ends with the configured artifact prefix, normally packets/.

1. Create a bounded packet

Replace the tutorial prompt, output, source IDs, and model with values from one completed Vertex AI workflow. The example defines a simple producer commitment convention: text is hashed as UTF-8, source metadata and content are canonicalized JSON, and content_sha256 commits to the canonical content object.

export VERTEX_MODEL="gemini-model-id"
python3 - <<'PY'
from datetime import UTC, datetime
import hashlib
import json
import os
import uuid


def canonical(value: object) -> bytes:
    return json.dumps(
        value,
        ensure_ascii=False,
        separators=(",", ":"),
        sort_keys=True,
    ).encode("utf-8")


def sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()

prompt = "Summarize the approved source material."
output = "Example answer returned by the customer-owned Vertex AI workflow."
source_material = [
    {
        "id": "policy-doc-001",
        "content_sha256": sha256(b"Approved tutorial source one."),
    },
    {
        "id": "policy-doc-002",
        "content_sha256": sha256(b"Approved tutorial source two."),
    },
]

content = {
    "task": "rag-answer",
    "model": {
        "provider": "vertex-ai",
        "model": os.environ["VERTEX_MODEL"],
    },
    "prompt": {"prompt_sha256": sha256(prompt.encode("utf-8"))},
    "retrieval": {
        "k": len(source_material),
        "source_set_sha256": sha256(canonical(source_material)),
        "sources": [{"id": source["id"]} for source in source_material],
    },
    "output": {"output_sha256": sha256(output.encode("utf-8"))},
}
packet = {
    "schema": "hx.verifiable-ai.rag-packet/v1",
    "envelope": {
        "packet_id": f"quickstart-{uuid.uuid4().hex}",
        "created_utc": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
    },
    "content": content,
    "content_sha256": sha256(canonical(content)),
}
with open("packet.json", "wb") as stream:
    stream.write(canonical(packet) + b"\n")
print(packet["envelope"]["packet_id"])
PY

python3 -m json.tool packet.json >/dev/null
sha256sum packet.json

The adapter independently hashes the complete packet.json object bytes. It validates the four producer commitments as lowercase SHA-256 values but does not recompute their preimages. Your production application must define and enforce its own canonicalization and truthfulness policy. See Packet integration reference.

2. Upload one finalized generation

Use a unique object name for every logical packet:

export PACKET_BASENAME="quickstart-$(date -u +%Y%m%dT%H%M%SZ).json"
export SOURCE_URI="${ARTIFACT_UPLOAD_URI}${PACKET_BASENAME}"

gcloud storage cp packet.json "$SOURCE_URI"

export SOURCE_GENERATION="$(
  gcloud storage objects describe "$SOURCE_URI" \
    --format='value(generation)'
)"
test -n "$SOURCE_GENERATION"
printf 'source=%s generation=%s\n' "$SOURCE_URI" "$SOURCE_GENERATION"

Do not overwrite the object to retry. Each finalized generation is a distinct event and receives a distinct event_key.

3. Derive the event key

The receipt path is based on the finalized source tuple, not the packet ID:

export ARTIFACT_BUCKET="${ARTIFACT_UPLOAD_URI#gs://}"
export ARTIFACT_BUCKET="${ARTIFACT_BUCKET%%/*}"
export OBJECT_NAME="${SOURCE_URI#gs://${ARTIFACT_BUCKET}/}"

export EVENT_KEY="$(python3 - <<'PY'
import hashlib
import os

bucket = os.environ["ARTIFACT_BUCKET"].encode("utf-8")
name = os.environ["OBJECT_NAME"].encode("utf-8")
generation = int(os.environ["SOURCE_GENERATION"], 10)
if not 0 < generation < 2**63:
    raise SystemExit("generation must be a positive signed 64-bit integer")

value = bytearray(b"HX-GCS-FINALIZED-EVENT-V1\0")
for field in (bucket, name):
    value.extend(len(field).to_bytes(8, "big", signed=False))
    value.extend(field)
value.extend(generation.to_bytes(8, "big", signed=False))
print(hashlib.sha256(value).hexdigest())
PY
)"
[[ "$EVENT_KEY" =~ ^[0-9a-f]{64}$ ]]
export RECEIPT_OBJECT="receipts/${EVENT_KEY:0:2}/${EVENT_KEY}.receipt.json"
printf 'event_key=%s\nreceipt=%s\n' "$EVENT_KEY" "$RECEIPT_OBJECT"

Use your configured receipt prefix if it differs from receipts/.

4. Wait for accepted evidence

Query by event_key until the row appears:

bq --project_id="$PROJECT_ID" query --use_legacy_sql=false \
  --parameter="event_key::${EVENT_KEY}" \
  "SELECT event_key, source_bucket, source_object_name, source_generation,
          packet_id, packet_object_sha256, receipt_uri,
          receipt_object_generation, receipt_public_key_sha256,
          self_verified, receipt_issued_utc
   FROM \`${BQ_TABLE}\`
   WHERE event_key = @event_key"

A successful row has self_verified = true and exactly matches the bucket, object name, and generation you recorded. If the row does not appear, check for deterministic rejection evidence before treating the event as a transient failure:

gcloud storage ls \
  "gs://${EVIDENCE_BUCKET}/rejections/${EVENT_KEY:0:2}/${EVENT_KEY}.rejection.json" \
  2>/dev/null || true

Use Troubleshooting if neither accepted nor rejected evidence appears.

5. Download the receipt

gcloud storage cp \
  "gs://${EVIDENCE_BUCKET}/${RECEIPT_OBJECT}" \
  receipt.json

python3 -m json.tool receipt.json >/dev/null
sha256sum receipt.json

The evidence object is create-only. Retain its Cloud Storage generation and SHA-256 with audit records.

6. Obtain issuer material independently

Retrieve the public key and its fingerprint through separately authorized Secret Manager reads:

gcloud secrets versions access latest \
  --project="$PROJECT_ID" \
  --secret="$PUBLIC_KEY_SECRET" \
  > issuer-ml-dsa-65.pub

export EXPECTED_FINGERPRINT="$(
  gcloud secrets versions access latest \
    --project="$PROJECT_ID" \
    --secret="$PUBLIC_KEY_SHA256_SECRET" \
    | tr -d '\r\n'
)"
[[ "$EXPECTED_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]]

Do not obtain the expected fingerprint only from the receipt being verified. That would prove integrity against an embedded key, not issuer identity.

7. Verify offline

From the signed release's hash-checked verifier/ directory:

python3 -m venv verifier-venv
. verifier-venv/bin/activate
python -m pip install --requirement requirements.txt

python verify_receipt.py \
  --receipt receipt.json \
  --public-key issuer-ml-dsa-65.pub \
  --expected-fingerprint "$EXPECTED_FINGERPRINT" \
  --artifact packet.json

A zero exit status confirms the supported receipt schema, ML-DSA-65 signature, canonical signed body, issuer key and fingerprint, and packet-object digest. It does not prove that the model output was correct or that producer-declared nested commitments were truthful.

Acceptance checklist

  • Bootstrap status printed READY before packet upload.
  • The source bucket, object name, and generation were recorded.
  • Exactly one receipt exists for the derived event_key.
  • The BigQuery row matches the source tuple and reports self_verified = true.
  • The public key and fingerprint came from an authorized channel independent of the receipt.
  • Offline verification exited successfully against the original packet bytes.
  • No key, secret value, packet body, or unsanitized receipt was copied into a support ticket.

Next: review Operations, upgrades, and removal, Security and data handling, and Support.