Skip to content

feat(verify): TypeScript port, test vectors, historical trust epochs#22

Merged
hizrianraz merged 4 commits into
mainfrom
feat/ts-port-test-vectors-epochs
Jul 11, 2026
Merged

feat(verify): TypeScript port, test vectors, historical trust epochs#22
hizrianraz merged 4 commits into
mainfrom
feat/ts-port-test-vectors-epochs

Conversation

@hizrianraz

@hizrianraz hizrianraz commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Expands verify into the canonical independent verification CLI and library per ADR 2026-07-11-github-org-audit-restructure.

Changes

Package + CLI

  • Renamed package to ainfera-verify (was verify)
  • Fixed broken pyproject.toml (stray README lines, missing entry point)
  • Bumped version to 0.2.0
  • Resolved README merge conflict, removed placeholder Quick Start
  • Removed hardcoded debug log path from fetch.py
  • Added CLI commands: receipt (receipt-to-chain), merkle (external anchor)

New verification checks

  • Sequence continuity: verify_chain now checks for gaps and duplicates in seq values (contiguous from 1)
  • Receipt-to-inference: receipts.py verifies receipt links to a real inference.routed event, cost matching, and savings arithmetic
  • Merkle root anchoring: merkle.py computes Merkle root over event hashes and verifies against external anchors
  • Pricing/savings: expanded savings verification with baseline kind validation and arithmetic checks

TypeScript/browser port (typescript/)

  • chain.ts: canonicalJson, computeEventHash, verifyChain (with sequence continuity + HMAC)
  • signatures.ts: verifyHmac, verifyEd25519 (Web Crypto API)
  • merkle.ts: computeMerkleRoot, verifyMerkleAnchor
  • receipts.ts: verifyReceipt (receipt-to-inference)
  • crypto.ts: sha256, hmacSha256 (Web Crypto API)
  • 6 tests, all passing

Test vectors (test-vectors/)

9 canonical vectors with known-good hashes:

  1. genesis-hash
  2. canonical-json
  3. event-hash-seq1
  4. hmac-signature
  5. three-event-chain
  6. merkle-root-3-events
  7. savings-arithmetic
  8. broken-chain-detection
  9. sequence-gap-detection

Historical trust epochs (docs/trust-epochs.md)

Documents all signature epochs: Epoch 0 (hash-only), Epoch 1 (HMAC), Epoch 2 (Ed25519), Epoch 2a (agent Ed25519), future (Sigstore, Merkle anchor).

Test results

  • 82 Python tests pass
  • 6 TypeScript tests pass
  • Offline bundle verification confirmed: 1247 events, zero network calls

Acceptance criteria

  • CLI renamed to ainfera-verify
  • All verification checks implemented (Ed25519, HMAC, hash continuity, sequence continuity, receipt-to-inference, Merkle root, pricing/savings)
  • TypeScript/browser-compatible version works
  • Test vectors published
  • Historical epochs documented
  • Offline verification works (no Ainfera API call needed)

Note

Medium Risk
Touches cryptographic verification semantics (sequence checks, Merkle tree, receipt–chain linkage) and dual Python/TS implementations; regressions could falsely pass or fail audits, though broad tests and published vectors mitigate this.

Overview
Ships ainfera-verify 0.2.0 as the canonical offline verifier: package rename and pyproject.toml fix (entry point, deps), README merge cleanup, and removal of debug logging from fetch.py.

Chain verification gains default sequence continuity (seq 1…n, no gaps/duplicates) in verify_chain, plus new merkle and receipt CLI commands backed by merkle.py (Merkle root vs external anchor) and receipts.py (link savings receipts to inference.routed events, cost match, savings math).

Adds a browser/Node TypeScript port under typescript/ mirroring chain, HMAC/Ed25519, Merkle, and receipt checks, with tests driven by shared test-vectors/ (9 JSON fixtures + generator). docs/trust-epochs.md documents signature epochs and Merkle anchoring status.

Reviewed by Cursor Bugbot for commit be0ef37. Bugbot is set up for automated code reviews on this repo. Configure here.

- Fix pyproject.toml: rename package to ainfera-verify, add entry point,
  fix broken stray README lines, bump version to 0.2.0
- Fix README.md: resolve merge conflict, remove placeholder Quick Start,
  document all new verification checks
- Add sequence continuity check to verify_chain (gaps, duplicates)
- Add receipts.py: receipt-to-inference relationship verification
  (event linkage, inference type, cost matching, savings arithmetic)
- Add merkle.py: externally anchored Merkle root computation + verification
- Add CLI commands: receipt (receipt-to-chain), merkle (external anchor)
- Create TypeScript/browser-compatible port (typescript/):
  - chain.ts: canonicalJson, computeEventHash, verifyChain
  - signatures.ts: verifyHmac, verifyEd25519 (Web Crypto API)
  - merkle.ts: computeMerkleRoot, verifyMerkleAnchor
  - receipts.ts: verifyReceipt (receipt-to-inference)
  - crypto.ts: sha256, hmacSha256 (Web Crypto API)
  - 6 tests, all passing
- Publish 9 canonical test vectors (test-vectors/):
  genesis-hash, canonical-json, event-hash-seq1, hmac-signature,
  three-event-chain, merkle-root-3-events, savings-arithmetic,
  broken-chain-detection, sequence-gap-detection
- Document historical trust epochs (docs/trust-epochs.md):
  Epoch 0 (hash-only), Epoch 1 (HMAC), Epoch 2 (Ed25519),
  Epoch 2a (agent Ed25519), future epochs (Sigstore, Merkle anchor)
- Remove hardcoded debug log path from fetch.py
- Add test_vector validation suite (9 tests)
- Add sequence_continuity tests (4 tests)
- Add merkle tests (9 tests)
- Add receipts tests (8 tests)
- Total: 82 Python tests pass, 6 TypeScript tests pass
- Offline bundle verification confirmed: 1247 events, zero network calls
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hizrianraz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bbf1653-8e1d-41d1-b214-fcd20523a5ba

📥 Commits

Reviewing files that changed from the base of the PR and between f7d6f1a and be0ef37.

⛔ Files ignored due to path filters (1)
  • typescript/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (36)
  • .gitignore
  • README.md
  • ainfera_verify/__init__.py
  • ainfera_verify/chain.py
  • ainfera_verify/cli.py
  • ainfera_verify/fetch.py
  • ainfera_verify/merkle.py
  • ainfera_verify/receipts.py
  • docs/trust-epochs.md
  • pyproject.toml
  • test-vectors/broken-chain-detection.json
  • test-vectors/canonical-json.json
  • test-vectors/event-hash-seq1.json
  • test-vectors/genesis-hash.json
  • test-vectors/hmac-signature.json
  • test-vectors/manifest.json
  • test-vectors/merkle-root-3-events.json
  • test-vectors/savings-arithmetic.json
  • test-vectors/sequence-gap-detection.json
  • test-vectors/three-event-chain.json
  • tests/_generate_test_vectors.py
  • tests/test_merkle.py
  • tests/test_receipts.py
  • tests/test_sequence_continuity.py
  • tests/test_smoke.py
  • tests/test_test_vectors.py
  • typescript/package.json
  • typescript/src/chain.ts
  • typescript/src/crypto.ts
  • typescript/src/index.ts
  • typescript/src/merkle.ts
  • typescript/src/receipts.ts
  • typescript/src/signatures.ts
  • typescript/src/types.ts
  • typescript/test/verify.test.ts
  • typescript/tsconfig.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ts-port-test-vectors-epochs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

else:
errors.append("audit_event_ref is missing or has no event_hash/seq")
else:
warnings.append("no chain provided; skipping receipt-to-inference linkage")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong warning on chain load

Medium Severity

When --chain is given but _load_chain_events fails, chain_events stays unset and the code still emits the warning that no chain was provided, alongside the load error. That misstates what happened and can send operators toward the wrong fix.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.

return ev
if seq is not None and ev.seq == seq:
return ev
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Receipt ref OR not AND

High Severity

_find_event returns the first event that matches event_hash or seq, not one event where both match. An audit_event_ref with a mismatched hash and seq can bind to the wrong inference event while cost and savings checks still run.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.

Comment thread typescript/src/chain.ts
};
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TS ignores API signature field

High Severity

verifyChain only checks Ed25519 when platform_signature is set. Live audit events expose the platform signature as signature, which Python maps in AuditEvent.from_dict, but the TypeScript path never reads that field, so Ed25519 chains can verify as OK without any signature check.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.

{ name: "Ed25519" },
false,
["verify"],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ed25519 PEM import wrong format

High Severity

verifyEd25519 base64-decodes the full PEM body and imports it as a raw Ed25519 key. Published keys are SPKI PEM; Web Crypto raw import expects 32 bytes, so import fails and verification always returns false when Ed25519 is actually exercised.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.

Comment thread typescript/src/crypto.ts

import { webcrypto } from "node:crypto";

const _subtle = webcrypto.subtle;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node-only crypto breaks browser

Medium Severity

crypto.ts imports webcrypto from node:crypto, which bundlers and browsers cannot resolve. The README positions the TypeScript tree for in-browser verification, but this module ties hashing to Node unless a nonstandard alias is added.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.

if baseline_kind and baseline_kind in PROJECTED_COST_ALIASES:
errors.append("projected_cost is a budget estimate, not a savings baseline")
elif baseline_kind and baseline_kind not in ALLOWED_BASELINE_KINDS:
errors.append(f"unsupported baseline_kind: {baseline_kind}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing baseline kind check

Medium Severity

The new receipt / verify_receipt path only rejects invalid baseline_kind when the field is present. Omitting baseline_kind does not fail, unlike verify_savings_receipt, which requires it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23f2174. Configure here.

Comment thread typescript/src/chain.ts

expectedPrevious = event.event_hash;
tipHash = event.event_hash;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TS skips unsigned event failure

High Severity

After hash checks, TypeScript verifyChain does not fail when key material is supplied but an event has neither a verified Ed25519 nor HMAC signature. Python returns event has no signature in that case.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23f2174. Configure here.

@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Wrong warning on chain load
    • Track whether a chain argument was supplied and only emit the 'no chain provided' warning when the caller did not pass --chain/events, so a failed load surfaces only the load error.
  • ✅ Fixed: Receipt ref OR not AND
    • When the audit_event_ref supplies both event_hash and seq, _find_event now requires them to match on the same event before returning.
  • ✅ Fixed: TS ignores API signature field
    • verifyChain now falls back to event.signature when sig_alg is ed25519 and no explicit platform_signature is present, matching Python's AuditEvent.from_dict.
  • ✅ Fixed: Ed25519 PEM import wrong format
    • verifyEd25519 now imports the PEM body as SPKI DER instead of a raw 32-byte key, matching the SubjectPublicKeyInfo format published by the platform.
  • ✅ Fixed: Node-only crypto breaks browser
    • crypto.ts now uses globalThis.crypto.subtle, dropping the node:crypto import so browser bundlers can ship the module without polyfills.

Create PR

Or push these changes by commenting:

@cursor push 20ed2f8bbd
Preview (20ed2f8bbd)
diff --git a/ainfera_verify/receipts.py b/ainfera_verify/receipts.py
--- a/ainfera_verify/receipts.py
+++ b/ainfera_verify/receipts.py
@@ -83,7 +83,11 @@
     """Find an event by event_hash or seq."""
 
     for ev in events:
-        if event_hash and ev.event_hash == event_hash:
+        if event_hash is not None and seq is not None:
+            if ev.event_hash == event_hash and ev.seq == seq:
+                return ev
+            continue
+        if event_hash is not None and ev.event_hash == event_hash:
             return ev
         if seq is not None and ev.seq == seq:
             return ev
@@ -116,6 +120,7 @@
 
     # --- Load chain events ---
     chain_events: list[AuditEvent] | None = None
+    chain_provided = events is not None or chain_path is not None
     if events is not None:
         chain_events = events
     elif chain_path is not None:
@@ -149,7 +154,7 @@
                 errors.append("audit_event_ref does not match any event in the chain")
         else:
             errors.append("audit_event_ref is missing or has no event_hash/seq")
-    else:
+    elif not chain_provided:
         warnings.append("no chain provided; skipping receipt-to-inference linkage")
 
     # --- Verify event is an inference event ---

diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts
--- a/typescript/src/chain.ts
+++ b/typescript/src/chain.ts
@@ -155,8 +155,17 @@
       };
     }
 
-    // Signature verification
-    const usesPlatformEd25519 = event.sig_alg === "ed25519" && event.platform_signature;
+    // Signature verification.
+    //
+    // The API exposes the platform Ed25519 signature under the top-level
+    // `signature` field (matching Python's `AuditEvent.from_dict`). We
+    // accept either that field or the explicit `platform_signature`.
+    const platformSignature =
+      event.platform_signature ??
+      (event.sig_alg === "ed25519"
+        ? (event.signature as string | undefined)
+        : undefined);
+    const usesPlatformEd25519 = event.sig_alg === "ed25519" && !!platformSignature;
     if (usesPlatformEd25519) {
       if (!platformPublicKeyPem) {
         return {
@@ -170,7 +179,7 @@
       }
       const sigOk = await verifyEd25519(
         event.event_hash,
-        event.platform_signature!,
+        platformSignature!,
         platformPublicKeyPem,
       );
       if (!sigOk) {

diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts
--- a/typescript/src/crypto.ts
+++ b/typescript/src/crypto.ts
@@ -1,14 +1,13 @@
 /**
  * SHA-256 implementation using the Web Crypto API (browser + Node 18+).
  *
- * In Node, uses crypto.subtle. In browsers, uses window.crypto.subtle.
- * Falls back to a pure-JS implementation if SubtleCrypto is unavailable.
+ * Uses the global `crypto.subtle`, which is available in modern browsers
+ * and Node.js 18+ without any Node-specific imports so bundlers can ship
+ * this module to the browser as-is.
  */
 
-import { webcrypto } from "node:crypto";
+const _subtle = globalThis.crypto.subtle;
 
-const _subtle = webcrypto.subtle;
-
 export async function sha256(data: string): Promise<string> {
   const buf = await _subtle.digest("SHA-256", new TextEncoder().encode(data));
   return Array.from(new Uint8Array(buf))

diff --git a/typescript/src/signatures.ts b/typescript/src/signatures.ts
--- a/typescript/src/signatures.ts
+++ b/typescript/src/signatures.ts
@@ -24,11 +24,13 @@
   publicKeyPem: string,
 ): Promise<boolean> {
   try {
-    // Extract raw public key from PEM
-    const rawKey = extractRawKeyFromPem(publicKeyPem);
+    // Published Ed25519 platform keys are SPKI-encoded PEM. Web Crypto's
+    // `spki` import expects the DER SubjectPublicKeyInfo bytes decoded
+    // from the PEM body.
+    const spkiKey = spkiFromPem(publicKeyPem);
     const cryptoKey = await crypto.subtle.importKey(
-      "raw",
-      rawKey,
+      "spki",
+      spkiKey,
       { name: "Ed25519" },
       false,
       ["verify"],
@@ -51,8 +53,8 @@
   return result === 0;
 }
 
-/** Extract raw key bytes from a PEM-encoded Ed25519 public key. */
-function extractRawKeyFromPem(pem: string): Uint8Array {
+/** Decode the DER SPKI bytes from a PEM-encoded public key. */
+function spkiFromPem(pem: string): Uint8Array {
   const b64 = pem
     .replace(/-----BEGIN PUBLIC KEY-----/g, "")
     .replace(/-----END PUBLIC KEY-----/g, "")

diff --git a/typescript/src/types.ts b/typescript/src/types.ts
--- a/typescript/src/types.ts
+++ b/typescript/src/types.ts
@@ -11,6 +11,8 @@
   hmac_signature: string;
   ed25519_signature?: string;
   platform_signature?: string;
+  /** Platform Ed25519 signature as returned by the API (alias for platform_signature when sig_alg == "ed25519"). */
+  signature?: string;
   sig_alg?: string;
   payload_hash?: string;
   sigstore_sig?: Record<string, unknown> | null;

You can send follow-ups to the cloud agent here.

@hizrianraz
hizrianraz merged commit cc9975a into main Jul 11, 2026
5 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 9 total unresolved issues (including 7 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Weaker receipt savings gates
    • verify_receipt now delegates to verify_savings_receipt to enforce all fail-closed gates (receipt_id, baseline_kind, baseline_model, catalog_snapshot, etc.) before running chain linkage checks.
  • ✅ Fixed: Skipped cost linkage check
    • Both Python and TypeScript verifiers now emit an error when the matched event's cost_usd (or the receipt's actual_cost, in TS) is missing instead of silently skipping the comparison.

Create PR

Or push these changes by commenting:

@cursor push b6b7bd9bc4
Preview (b6b7bd9bc4)
diff --git a/ainfera_verify/receipts.py b/ainfera_verify/receipts.py
--- a/ainfera_verify/receipts.py
+++ b/ainfera_verify/receipts.py
@@ -21,12 +21,9 @@
 
 from ainfera_verify.chain import AuditEvent
 from ainfera_verify.savings import (
-    ALLOWED_BASELINE_KINDS,
-    PROJECTED_COST_ALIASES,
     SavingsReceiptError,
-    _money,
-    _nested,
     load_savings_receipt,
+    verify_savings_receipt,
 )
 
 _MONEY = Decimal("0.000001")
@@ -106,12 +103,16 @@
     - errors: list of failure reasons
     """
 
-    errors: list[str] = []
-    warnings: list[str] = []
-
     receipt = load_savings_receipt(receipt_path)
-    receipt_id = receipt.get("receipt_id")
 
+    # Apply all fail-closed savings-receipt gates first so `ainfera-verify receipt`
+    # is at least as strict as `ainfera-verify savings` (baseline_kind, baseline_model,
+    # catalog_snapshot, receipt_id, savings arithmetic, ...).
+    savings_result = verify_savings_receipt(receipt)
+    errors: list[str] = list(savings_result.errors)
+    warnings: list[str] = list(savings_result.warnings)
+    receipt_id = savings_result.receipt_id
+
     # --- Load chain events ---
     chain_events: list[AuditEvent] | None = None
     if events is not None:
@@ -151,6 +152,8 @@
         warnings.append("no chain provided; skipping receipt-to-inference linkage")
 
     # --- Verify event is an inference event ---
+    provider: str | None = None
+    model: str | None = None
     if matched is not None:
         if matched.event_type != "inference.routed":
             errors.append(f"referenced event is not inference.routed (got {matched.event_type})")
@@ -160,6 +163,8 @@
         event_cost = payload.get("cost_usd")
         actual_cost_raw = receipt.get("actual_cost") or receipt.get("actual_cost_usd")
 
+        if event_cost is None:
+            errors.append("referenced event payload is missing cost_usd")
         if event_cost is not None and actual_cost_raw is not None:
             try:
                 event_cost_dec = Decimal(str(event_cost)).quantize(_MONEY)
@@ -171,54 +176,19 @@
             except (InvalidOperation, ValueError):
                 errors.append("cannot compare actual_cost with event cost_usd")
 
-        # Extract provider/model for the result
         provider = payload.get("provider")
         model = payload.get("model")
-    else:
-        provider = None
-        model = None
 
-    # --- Verify savings arithmetic ---
-    actual_cost = _money(
-        receipt.get("actual_cost") or receipt.get("actual_cost_usd"),
-        "actual_cost",
-        errors,
-    )
-    baseline_cost = _money(
-        _nested(receipt, "baseline_cost") or _nested(receipt, "baseline_cost_usd"),
-        "baseline_cost",
-        errors,
-    )
-    savings_claimed = _money(
-        receipt.get("savings") or receipt.get("savings_usd") or receipt.get("savings_proven_usd"),
-        "savings",
-        errors,
-    )
-
-    # Baseline kind validation
-    baseline_kind_raw = _nested(receipt, "baseline_kind") or _nested(receipt, "kind")
-    baseline_kind = str(baseline_kind_raw) if baseline_kind_raw is not None else None
-    if baseline_kind and baseline_kind in PROJECTED_COST_ALIASES:
-        errors.append("projected_cost is a budget estimate, not a savings baseline")
-    elif baseline_kind and baseline_kind not in ALLOWED_BASELINE_KINDS:
-        errors.append(f"unsupported baseline_kind: {baseline_kind}")
-
-    expected_savings: Decimal | None = None
-    if actual_cost is not None and baseline_cost is not None:
-        expected_savings = max(Decimal("0"), baseline_cost - actual_cost).quantize(_MONEY)
-        if savings_claimed is not None and savings_claimed != expected_savings:
-            errors.append(f"savings mismatch: expected {expected_savings}, got {savings_claimed}")
-
     return ReceiptVerifyResult(
         ok=not errors,
-        receipt_id=str(receipt_id) if receipt_id else None,
+        receipt_id=receipt_id,
         matched_event=matched,
         provider=provider,
         model=model,
-        actual_cost=actual_cost,
-        baseline_cost=baseline_cost,
-        savings_claimed=savings_claimed,
-        savings_expected=expected_savings,
+        actual_cost=savings_result.actual_cost,
+        baseline_cost=savings_result.baseline_cost,
+        savings_claimed=savings_result.savings_claimed,
+        savings_expected=savings_result.savings_expected,
         errors=errors,
         warnings=warnings,
     )

diff --git a/typescript/src/receipts.ts b/typescript/src/receipts.ts
--- a/typescript/src/receipts.ts
+++ b/typescript/src/receipts.ts
@@ -61,7 +61,18 @@
     const eventCost = payload["cost_usd"];
     const actualCostRaw = receipt["actual_cost"] ?? receipt["actual_cost_usd"];
 
-    if (eventCost !== undefined && actualCostRaw !== undefined) {
+    if (eventCost === undefined || eventCost === null) {
+      errors.push("referenced event payload is missing cost_usd");
+    }
+    if (actualCostRaw === undefined || actualCostRaw === null) {
+      errors.push("receipt is missing actual_cost");
+    }
+    if (
+      eventCost !== undefined &&
+      eventCost !== null &&
+      actualCostRaw !== undefined &&
+      actualCostRaw !== null
+    ) {
       const eventCostNum = Number(eventCost);
       const actualNum = Number(actualCostRaw);
       if (!isNaN(eventCostNum) && !isNaN(actualNum) && eventCostNum !== actualNum) {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit be0ef37. Configure here.

if actual_cost is not None and baseline_cost is not None:
expected_savings = max(Decimal("0"), baseline_cost - actual_cost).quantize(_MONEY)
if savings_claimed is not None and savings_claimed != expected_savings:
errors.append(f"savings mismatch: expected {expected_savings}, got {savings_claimed}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weaker receipt savings gates

Medium Severity

verify_receipt omits several fail-closed checks that verify_savings_receipt enforces, including required baseline_kind, baseline_model, catalog_snapshot, and receipt_id. Receipts missing those fields can pass ainfera-verify receipt while failing ainfera-verify savings.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be0ef37. Configure here.

f"actual_cost ({actual_dec}) != event cost_usd ({event_cost_dec})"
)
except (InvalidOperation, ValueError):
errors.append("cannot compare actual_cost with event cost_usd")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped cost linkage check

Medium Severity

Receipt-to-inference verification only compares actual_cost to cost_usd when both values are present. If the matched event omits cost_usd or the receipt omits actual_cost, the cost linkage step is skipped and verification can still succeed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be0ef37. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant