Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
| | |
|---|---|
| **Protocol site & spec** | [agentreceipts.ai](https://agentreceipts.ai) |
| **Conformance** | [Cross-language interop suite](https://agentreceipts.ai/conformance/) |
| **Tooling docs** | [obsigna.dev](https://obsigna.dev) |
| **Daemon setup & migration guide** | [obsigna.dev/getting-started/daemon-setup/](https://obsigna.dev/getting-started/daemon-setup/) |
| **API reference** | [Go](https://obsigna.dev/sdk-go/api-reference/) · [TypeScript](https://obsigna.dev/sdk-ts/api-reference/) · [Python](https://obsigna.dev/sdk-py/api-reference/) |
Expand Down Expand Up @@ -46,6 +47,12 @@
<img alt="How it works: Authorize → Act → Sign → Link → Audit" src=".github/how-it-works.svg">
</picture>

## Conformance

Anyone can implement the protocol — so interoperability is the property that matters. The three SDKs (Go, Python, TypeScript) are independent implementations that **verify one another's receipts**: every conformance vector is signed once with a shared Ed25519 keypair, and each SDK re-verifies the signatures, canonical JSON, and hashes the other two produced — across a positive corpus, a MUST-reject corpus, and four pinned spec versions. This cross-language verification is enforced in CI.

The **[Conformance page](https://agentreceipts.ai/conformance/)** is the citeable summary: the interop claim, a results matrix with per-set vector counts, permanent links to every frozen vector set, and how to reproduce the checks. The shared corpus lives in [`cross-sdk-tests/`](cross-sdk-tests/).

## Start here

Both paths below require the daemon — it holds the signing key and owns the audit chain. Install it first:
Expand Down
27 changes: 23 additions & 4 deletions cross-sdk-tests/README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
# Cross-SDK Tests

This directory holds shared fixtures plus a Go-side spec-example validator. **The actual cross-SDK signature verification lives in each SDK's own test suite**, where it consumes the shared vectors below.
Three independent implementations of the Agent Receipt Protocol — the Go, Python, and TypeScript SDKs — verify one another's receipts. The vectors in this directory are the shared corpus that makes that claim checkable: each is signed once with a single shared Ed25519 keypair, and every SDK re-verifies the signatures, canonical JSON (RFC 8785), and receipt hashes the other two produced. Verification runs in both directions, over a **positive corpus** every implementation must accept and a **MUST-reject corpus** every implementation must refuse, across four pinned spec versions (v0.2.0–v0.5.0).

That split is why `.github/workflows/cross-sdk-tests.yml` only runs `spec_examples_test.go` here — it is not a missing test, it is a deliberate division of work.
For the published, citeable summary of this suite — the interop claim, the results matrix with per-set vector counts, and CI-enforcement status — see the [Conformance page](https://agentreceipts.ai/conformance/).

## How interop is wired

All vector files use the same Ed25519 keypair, so any SDK can verify a receipt signed by any other. The TypeScript SDK is the canonical source of that keypair (`sdk/py/tests/fixtures/ts_vectors.json`); the Go and Python SDKs generate their own vectors with the same keypair.

The **actual cross-SDK signature verification lives in each SDK's own test suite**, where it consumes the shared vectors below — `sdk/go` (Go `CrossLanguage` integration tests), `sdk/py/tests/test_cross_language_*.py`, and `sdk/ts` (cross-language suite). That split is deliberate: the shared-vector verification is a property of each implementation, so it is tested where the implementation lives. `.github/workflows/cross-sdk-tests.yml` in this directory runs the spec-example and version-pinned shape checks plus the CLI↔WASM verifier gate — it is not a missing signature test, it is a division of work.

## Shared test vectors

- `go_vectors.json` — Generated by the Go SDK using the shared keypair from `sdk/py/tests/fixtures/ts_vectors.json`
- `py_vectors.json` — Generated by the Python SDK using the same shared keypair
- `sdk/py/tests/fixtures/ts_vectors.json` — Generated by the TypeScript SDK (canonical source)
- `canonicalization_vectors.json` — RFC 8785 canonical-JSON and receipt-hash agreement vectors, consumed by all three SDKs
- `emit_failure_vectors.json` — Emit-failure outcome contract cases (ADR-0025)
- `v020_vectors.json` — Pinned v0.2.0 / v0.2.1 vectors: terminal chain, response-hash redaction, and legacy flat-map `parameters_disclosure` (ADR-0012 Phase A). Frozen — never regenerate against newer canonicalizer changes.
- `v030_vectors.json` — Pinned v0.3.0 vectors: HPKE envelope `parameters_disclosure` (ADR-0012 amendment), plus daemon-attested `peer_credential` and `emitter_metadata` (ADR-0010). Envelope bytes are sourced from `spec/test-vectors/disclosure-envelope/vectors.json` vector-1.
- `v040_vectors.json` — Pinned v0.4.0 vectors: idempotency-key receipt and duplicate-idempotency chain.
- `v050_vectors.json` — Pinned v0.5.0 vectors: runtime metadata receipts and root-agent receipt.
- `malformed_vectors.json` — Shared corpus of receipts and chains every SDK MUST reject (tampering, swapped multibase prefix, broken chain link, etc.)

All four vector files use the same Ed25519 keypair so any SDK can verify receipts signed by any other.
All vector files use the same Ed25519 keypair so any SDK can verify receipts signed by any other. The `v0*_vectors.json` files are **frozen** — do not regenerate them against a newer canonicalizer.

## Spec-example shape validation

`spec_examples_test.go` validates that the example receipts in `spec/examples/` have the correct structure, required fields, and consistent chain properties. It does **not** verify cross-SDK signatures — that happens inside each SDK.

## Vector counts

The per-set vector counts published on the Conformance page are generated from these files by [`scripts/conformance_matrix/count.py`](../scripts/conformance_matrix/count.py), so the numbers stay reproducible. Run from this directory (the script resolves the repository root itself, so the output is the same from anywhere):

```sh
python3 ../scripts/conformance_matrix/count.py # human-readable table
python3 ../scripts/conformance_matrix/count.py --format md # Markdown for the page
```

## Running

```sh
Expand All @@ -36,6 +55,6 @@ go test -tags=integration -v

# Cross-SDK signature verification — one per SDK, each consuming the shared vectors:
# Go: cd sdk/go && go test -tags=integration -v -run CrossLanguage
# Python: cd sdk/py && uv run pytest tests/test_cross_language_go.py -v
# Python: cd sdk/py && uv run pytest tests/test_cross_language_go.py tests/test_cross_language_ts.py -v
# TS: cd sdk/ts && pnpm test -- cross-language
```
35 changes: 35 additions & 0 deletions scripts/conformance_matrix/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# conformance_matrix

Emits the conformance-vector counts for the [Conformance page](https://agentreceipts.ai/conformance/)
(`site/src/content/docs/conformance.mdx`).

The page carries a results matrix whose vector counts are citeable, so they must
not be hand-typed. `count.py` reads the frozen vector files directly and reports
the count per set, letting the page be regenerated and letting a reviewer
reproduce every figure from source.

## Layout

| File | Role |
|------|------|
| `count.py` | Reads the committed vector files and emits per-set counts as a table, Markdown, or JSON. Read-only — never writes or regenerates a vector file. |
| `test_count.py` | Unit tests over the real committed vectors (no network, no SDK install). |

## Run locally

```sh
python3 scripts/conformance_matrix/count.py # human-readable table
python3 scripts/conformance_matrix/count.py --format md # Markdown for the page
python3 scripts/conformance_matrix/count.py --format json

python3 -m unittest discover -s scripts/conformance_matrix -p 'test_*.py'
```

## What it counts

The vector sets are frozen (see `cross-sdk-tests/README.md` and each
`spec/test-vectors/*/README.md`). This script only measures them; it is not a
generator. Counting rules are declared per set in `VECTOR_SETS` — combined
list lengths for the shared corpora, top-level vector entries (excluding file
metadata) for the version-pinned files, and a single document for the rotation
example — so every number is transparent and reproducible.
252 changes: 252 additions & 0 deletions scripts/conformance_matrix/count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Emit the conformance-vector counts for the published conformance page.

The conformance page on agentreceipts.ai (``site/src/content/docs/conformance.mdx``)
carries a results matrix whose vector counts must not be hand-typed — a stale
number on a citeable page is worse than no number. This
script reads the frozen vector files directly and emits the counts, so the page
can be regenerated and reviewers can reproduce every figure from source.

It is deliberately read-only and dependency-free (standard library only): it
never writes, generates, or mutates a vector file. The vector corpora it counts
are frozen (see ``cross-sdk-tests/README.md`` and each ``spec/test-vectors``
README); this script only measures them.

Usage:
count.py # human-readable table
count.py --format md # Markdown table (paste-ready for the page)
count.py --format json # machine-readable counts

Exit codes:
0 all vector files read and counted
1 a vector file was missing or malformed
"""

from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Callable

# Repo root is three levels up from this file (scripts/conformance_matrix/).
_REPO_ROOT = Path(__file__).resolve().parents[2]

# Object-valued top-level keys in the version-pinned vector files that are NOT a
# receipt/chain vector. Scalar metadata ($comment, version, adr, …) is excluded
# by type below, so this list only needs the non-vector *objects* — currently
# just `keys` (the shared keypair). Keeping this a denylist of objects rather
# than an allowlist of vectors avoids hand-maintaining vector names, while the
# type check stops a future scalar metadata field from inflating the count.
_VERSION_METADATA_OBJECTS = frozenset({"keys"})


def _sum_len(*fields: str) -> Callable[[dict[str, Any]], int]:
"""Count the combined length of one or more list fields."""

def counter(doc: dict[str, Any]) -> int:
return sum(len(doc[field]) for field in fields)

return counter


def _top_level_vectors(doc: dict[str, Any]) -> int:
"""Count top-level receipt/chain vectors in a version-pinned file.

A vector is a JSON object; scalar metadata (``$comment``, ``version``,
``adr``, …) is excluded by type, and the one non-vector object (``keys``,
the shared keypair) by name. Counting objects rather than "everything not on
a denylist" means a newly added scalar metadata field cannot silently
inflate the published count.
"""
return sum(
1
for key, value in doc.items()
if isinstance(value, dict) and key not in _VERSION_METADATA_OBJECTS
)


def _single(_doc: dict[str, Any]) -> int:
"""A file that is itself a single vector (one example document)."""
return 1


@dataclass(frozen=True)
class VectorSet:
"""One row of the conformance matrix."""

name: str
path: str # repo-relative
purpose: str
consumers: str # which SDKs consume/verify it
spec_versions: str
counter: Callable[[dict[str, Any]], int]

def count(self) -> int:
doc = json.loads((_REPO_ROOT / self.path).read_text(encoding="utf-8"))
return self.counter(doc)
Comment thread
ojongerius marked this conversation as resolved.


# The matrix, in the order it appears on the page. Consumer/spec-version columns
# are authored here (they describe how the suites wire the file up, not
# something derivable from the JSON); the count column is computed.
VECTOR_SETS: list[VectorSet] = [
VectorSet(
name="canonicalization",
path="cross-sdk-tests/canonicalization_vectors.json",
purpose="RFC 8785 canonical JSON + receipt-hash agreement",
consumers="Go, Py, TS",
spec_versions="version-independent",
counter=_sum_len("canonicalization_vectors", "receipt_hash_vectors"),
),
VectorSet(
name="emit-failure",
path="cross-sdk-tests/emit_failure_vectors.json",
purpose="emit-failure outcome contract (ADR-0025)",
consumers="Go, Py, TS",
spec_versions="version-independent",
counter=_sum_len("cases"),
),
VectorSet(
name="malformed (MUST-reject)",
path="cross-sdk-tests/malformed_vectors.json",
purpose="negative corpus: every verifier MUST reject these",
consumers="Go, Py, TS",
spec_versions="version-independent",
counter=_sum_len("receipts", "chains"),
),
VectorSet(
name="v0.2.0",
path="cross-sdk-tests/v020_vectors.json",
purpose="frozen v0.2.0 receipts + chains",
consumers="Go, Py, TS",
spec_versions="0.2.0",
counter=_top_level_vectors,
),
VectorSet(
name="v0.3.0",
path="cross-sdk-tests/v030_vectors.json",
purpose="frozen v0.3.0 receipts + chains",
consumers="Go, Py, TS",
spec_versions="0.3.0",
counter=_top_level_vectors,
),
VectorSet(
name="v0.4.0",
path="cross-sdk-tests/v040_vectors.json",
purpose="frozen v0.4.0 receipts + chains",
consumers="Go, Py, TS",
spec_versions="0.4.0",
counter=_top_level_vectors,
),
VectorSet(
name="v0.5.0",
path="cross-sdk-tests/v050_vectors.json",
purpose="frozen v0.5.0 receipts + chains",
consumers="Go, Py, TS",
spec_versions="0.5.0",
counter=_top_level_vectors,
),
VectorSet(
name="did:key resolution",
path="spec/test-vectors/did-key/vectors.json",
purpose="did:key v0.7 resolution wire shape (ADR-0007)",
consumers="reference fixtures — not yet wired into a suite",
spec_versions="did:key v0.7",
counter=_sum_len("vectors"),
),
VectorSet(
name="disclosure envelope",
path="spec/test-vectors/disclosure-envelope/vectors.json",
purpose="parameter-disclosure envelope: pinned ciphertext",
consumers="Go, Py, TS",
spec_versions="envelope v1",
counter=_sum_len("vectors"),
),
VectorSet(
name="rotation event",
path="spec/test-vectors/rotation-event/example.json",
purpose="key-rotation event verifies under the outgoing key",
consumers="Go, Py, TS",
spec_versions="0.2.1",
counter=_single,
),
]


def collect() -> list[tuple[VectorSet, int]]:
"""Read every vector file and pair each set with its count."""
return [(vs, vs.count()) for vs in VECTOR_SETS]


def _render_table(rows: list[tuple[VectorSet, int]]) -> str:
header = ("Vector set", "Purpose", "Consumers", "Spec", "Count")
lines = [f"{header[0]:<24} {header[1]:<48} {header[2]:<44} {header[3]:<16} {header[4]}"]
for vs, count in rows:
lines.append(
f"{vs.name:<24} {vs.purpose:<48} {vs.consumers:<44} {vs.spec_versions:<16} {count}"
)
total = sum(count for _, count in rows)
lines.append("")
lines.append(f"Total vectors: {total}")
return "\n".join(lines)


def _render_md(rows: list[tuple[VectorSet, int]]) -> str:
lines = [
"| Vector set | Purpose | Consumers | Spec version(s) | Vectors |",
"| --- | --- | --- | --- | ---: |",
]
for vs, count in rows:
lines.append(
f"| {vs.name} | {vs.purpose} | {vs.consumers} | {vs.spec_versions} | {count} |"
)
return "\n".join(lines)


def _render_json(rows: list[tuple[VectorSet, int]]) -> str:
payload = {
"sets": [
{
"name": vs.name,
"path": vs.path,
"purpose": vs.purpose,
"consumers": vs.consumers,
"specVersions": vs.spec_versions,
"count": count,
}
for vs, count in rows
],
"total": sum(count for _, count in rows),
}
return json.dumps(payload, indent=2)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--format",
choices=("table", "md", "json"),
default="table",
help="output format (default: table)",
)
args = parser.parse_args(argv)

try:
rows = collect()
except (OSError, UnicodeDecodeError, json.JSONDecodeError, KeyError) as err:
print(f"error: could not count vectors: {err}", file=sys.stderr)
return 1

renderer = {"table": _render_table, "md": _render_md, "json": _render_json}[args.format]
print(renderer(rows))
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading