Skip to content

Commit 82e528b

Browse files
aeoessclaude
andcommitted
test(cross-impl): add JCS byte-match harness vs rfc8785@0.1.4 + canonicalize@3.0.0
Python mirror of agent-passport-system/tests/cross-impl/. Same pinned 12-vector manifest, same three-way byte-match (Python SDK ↔ rfc8785@0.1.4 ↔ canonicalize@3.0.0 npm). - tests/cross_impl/jcs-test-vectors.json — shared verbatim with the TypeScript SDK. Updating in one place requires copying to the other. - tests/cross_impl/test_jcs_equivalence.py — 49 pytest cases asserting: (1) SDK canonicalize_jcs matches pinned canonical bytes; (2) SHA-256(canonicalize_jcs(input)) matches pinned hash; (3) rfc8785@0.1.4 (Python reference) matches both; (4) SDK and rfc8785 produce byte-identical output. - tests/cross_impl/gen_vectors.py — regen script (requires rfc8785). - .github/workflows/cross-impl-jcs.yml — CI gate that runs the Python test AND verifies canonicalize@3.0.0 (npm, erdtman) byte-matches the same pinned manifest. Local: 49/49 pytest cases pass against rfc8785@0.1.4. Follow-up filed in DECISIONS 2026-05-21: "I-D normative claims need conformance tests against external reference implementations as part of SDK CI, not just internal round-trip tests." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fc88ea9 commit 82e528b

5 files changed

Lines changed: 455 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: cross-impl JCS byte-match
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "src/agent_passport/canonical.py"
8+
- "src/agent_passport/v2/attribution_primitive/canonical.py"
9+
- "tests/cross_impl/**"
10+
- ".github/workflows/cross-impl-jcs.yml"
11+
pull_request:
12+
branches: [main]
13+
paths:
14+
- "src/agent_passport/canonical.py"
15+
- "src/agent_passport/v2/attribution_primitive/canonical.py"
16+
- "tests/cross_impl/**"
17+
- ".github/workflows/cross-impl-jcs.yml"
18+
workflow_dispatch: {}
19+
20+
jobs:
21+
byte-match:
22+
name: Python SDK ↔ rfc8785@0.1.4 ↔ canonicalize@3.0.0
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 10
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v4
28+
29+
- name: Setup Python
30+
uses: actions/setup-python@v5
31+
with:
32+
python-version: "3.11"
33+
34+
- name: Setup Node (for canonicalize@3.0.0 cross-check)
35+
uses: actions/setup-node@v4
36+
with:
37+
node-version: "20"
38+
39+
- name: Install Python SDK + deps
40+
run: |
41+
python -m pip install --upgrade pip
42+
pip install -e .
43+
pip install pytest "rfc8785==0.1.4"
44+
45+
- name: Install canonicalize@3.0.0 (Node reference)
46+
run: npm install --no-save --no-audit --no-fund canonicalize@3.0.0
47+
48+
- name: Python test — SDK canonicalize_jcs vs rfc8785@0.1.4
49+
run: pytest tests/cross_impl/test_jcs_equivalence.py -v
50+
51+
- name: Node test — canonicalize@3.0.0 vs pinned manifest
52+
# Asserts erdtman's canonicalize@3.0.0 (npm) byte-matches the same
53+
# pinned manifest the Python SDK is validated against. Mismatch here
54+
# means either canonicalize@3.0.0 has shifted (which would break the
55+
# TypeScript SDK's cross-impl-jcs gate downstream) or the manifest
56+
# has drifted. Either signal worth surfacing as a hard CI failure.
57+
run: |
58+
node - <<'JS'
59+
import('canonicalize').then(async (mod) => {
60+
const canonicalize = mod.default;
61+
const { createHash } = await import('node:crypto');
62+
const { readFileSync } = await import('node:fs');
63+
const m = JSON.parse(readFileSync('tests/cross_impl/jcs-test-vectors.json', 'utf8'));
64+
let fail = 0;
65+
for (const v of m.vectors) {
66+
const bytes = canonicalize(v.input);
67+
const hash = createHash('sha256').update(bytes, 'utf8').digest('hex');
68+
if (bytes !== v.expected_canonical_bytes) {
69+
console.log(`FAIL ${v.id}: bytes mismatch`);
70+
console.log(` pinned: ${v.expected_canonical_bytes}`);
71+
console.log(` canonicalize: ${bytes}`);
72+
fail++;
73+
}
74+
if (hash !== v.expected_sha256) {
75+
console.log(`FAIL ${v.id}: sha256 mismatch`);
76+
console.log(` pinned: ${v.expected_sha256}`);
77+
console.log(` canonicalize: ${hash}`);
78+
fail++;
79+
}
80+
}
81+
const total = m.vectors.length;
82+
if (fail) {
83+
console.log(`\nFAILED: ${fail} divergence(s) across ${total} vectors`);
84+
process.exit(1);
85+
}
86+
console.log(`\nOK: canonicalize@3.0.0 byte-matches all ${total} pinned vectors`);
87+
});
88+
JS
89+
90+
- name: Three-way assertion (Python SDK ≡ rfc8785 ≡ canonicalize@3.0.0)
91+
run: |
92+
echo "Py test: Python SDK canonicalize_jcs ≡ rfc8785@0.1.4"
93+
echo "Node test: canonicalize@3.0.0 (erdtman, RFC 8785 author) ≡ pinned manifest"
94+
echo "Pinned: manifest pinned to rfc8785@0.1.4 + canonicalize@3.0.0 byte-match"
95+
echo "Therefore Python SDK ≡ rfc8785@0.1.4 ≡ canonicalize@3.0.0 byte-for-byte."

tests/cross_impl/__init__.py

Whitespace-only changes.

tests/cross_impl/gen_vectors.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Generate pinned expected canonical bytes + SHA-256 from rfc8785@0.1.4.
2+
3+
This produces a JSON manifest that the SDK's cross-impl test asserts against
4+
in BOTH TypeScript and Python. The Node-side reference (canonicalize@3.0.0)
5+
must produce byte-identical output; the SDK's strict-JCS impl
6+
(canonicalHashJCS in TS, canonicalize_jcs in Python) must too.
7+
"""
8+
import hashlib
9+
import json
10+
import sys
11+
12+
import rfc8785
13+
14+
VECTORS = [
15+
{
16+
"id": "v01-simple",
17+
"description": "Simple object, no nulls — variants identical (sanity baseline).",
18+
"input": {"agentId": "agent-001", "scope": "read"},
19+
},
20+
{
21+
"id": "v02-null-preserved",
22+
"description": "Null value at top level — JCS preserves, legacy strips. Divergence vector.",
23+
"input": {"agentId": "agent-001", "metadata": None, "scope": "read"},
24+
},
25+
{
26+
"id": "v03-key-order",
27+
"description": "Keys MUST be sorted by Unicode code point.",
28+
"input": {"zebra": 1, "alpha": 2, "middle": 3},
29+
},
30+
{
31+
"id": "v04-nested-null",
32+
"description": "Null at depth inside nested object.",
33+
"input": {"outer": {"inner": None, "value": 42}, "top": "ok"},
34+
},
35+
{
36+
"id": "v05-array-null",
37+
"description": "Null elements inside arrays. Both variants preserve array nulls.",
38+
"input": {"items": [1, None, 3]},
39+
},
40+
{
41+
"id": "v06-numbers",
42+
"description": "Mixed integer + signed + fractional + zero numeric serialization.",
43+
"input": {"integer": 42, "negative": -7, "float": 3.14, "zero": 0},
44+
},
45+
{
46+
"id": "v07-empties",
47+
"description": "Empty object and empty array.",
48+
"input": {"emptyArr": [], "emptyObj": {}},
49+
},
50+
{
51+
"id": "v08-unicode",
52+
"description": "Non-ASCII content in keys and values; UTF-8 bytes emitted literally per RFC 8785 §3.2.2.2.",
53+
"input": {"name": "Тимофій", "emoji": "🔐"},
54+
},
55+
{
56+
"id": "v09-action-ref-tuple",
57+
"description": "I-D §4.1 action_ref pre-image shape — production-like input.",
58+
"input": {
59+
"agentId": "did:aps:z6Mkfoo",
60+
"actionType": "code_execution",
61+
"scopeRequired": ["commerce:read", "commerce:write"],
62+
"timestamp": "2026-05-21T00:00:00Z",
63+
},
64+
},
65+
{
66+
"id": "v10-attribution-tuple",
67+
"description": "ATTRIBUTION-PRIMITIVE-v1.1 §1.6 four-tuple shape with null in params.",
68+
"input": {
69+
"agentId": "a",
70+
"actionType": "t",
71+
"params": {"k": None, "v": 1},
72+
"nonce": "n0",
73+
},
74+
},
75+
{
76+
"id": "v11-string-escapes",
77+
"description": "Tab (U+0009) and newline (U+000A) escapes per RFC 8785 §3.2.2.2.",
78+
"input": {"raw": "line1\tcol2\nline3"},
79+
},
80+
{
81+
"id": "v12-booleans",
82+
"description": "Boolean values are serialized as 'true' / 'false'.",
83+
"input": {"active": True, "revoked": False},
84+
},
85+
]
86+
87+
88+
def main():
89+
out = {
90+
"generator": "rfc8785@0.1.4 (Python)",
91+
"spec": "RFC 8785 JSON Canonicalization Scheme (JCS)",
92+
"hash": "SHA-256, lowercase hex",
93+
"vectors": [],
94+
}
95+
for v in VECTORS:
96+
canon_bytes = rfc8785.dumps(v["input"])
97+
canon_str = canon_bytes.decode("utf-8") if isinstance(canon_bytes, bytes) else canon_bytes
98+
digest = hashlib.sha256(canon_str.encode("utf-8")).hexdigest()
99+
out["vectors"].append({
100+
"id": v["id"],
101+
"description": v["description"],
102+
"input": v["input"],
103+
"expected_canonical_bytes": canon_str,
104+
"expected_sha256": digest,
105+
})
106+
json.dump(out, sys.stdout, indent=2, ensure_ascii=False)
107+
sys.stdout.write("\n")
108+
109+
110+
if __name__ == "__main__":
111+
main()
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
{
2+
"generator": "rfc8785@0.1.4 (Python)",
3+
"spec": "RFC 8785 JSON Canonicalization Scheme (JCS)",
4+
"hash": "SHA-256, lowercase hex",
5+
"vectors": [
6+
{
7+
"id": "v01-simple",
8+
"description": "Simple object, no nulls — variants identical (sanity baseline).",
9+
"input": {
10+
"agentId": "agent-001",
11+
"scope": "read"
12+
},
13+
"expected_canonical_bytes": "{\"agentId\":\"agent-001\",\"scope\":\"read\"}",
14+
"expected_sha256": "598fa19ccadbc8df940a9cde9bff497c094ff209818b2bc36e5fac8d5cc2f477"
15+
},
16+
{
17+
"id": "v02-null-preserved",
18+
"description": "Null value at top level — JCS preserves, legacy strips. Divergence vector.",
19+
"input": {
20+
"agentId": "agent-001",
21+
"metadata": null,
22+
"scope": "read"
23+
},
24+
"expected_canonical_bytes": "{\"agentId\":\"agent-001\",\"metadata\":null,\"scope\":\"read\"}",
25+
"expected_sha256": "52a6250f9b8d4df9654556cb46fcf3a6b3829693643578bacfa344831cc6d99d"
26+
},
27+
{
28+
"id": "v03-key-order",
29+
"description": "Keys MUST be sorted by Unicode code point.",
30+
"input": {
31+
"zebra": 1,
32+
"alpha": 2,
33+
"middle": 3
34+
},
35+
"expected_canonical_bytes": "{\"alpha\":2,\"middle\":3,\"zebra\":1}",
36+
"expected_sha256": "8279885d9e6f748807a4a8838376aad819aee964979d576d496aa9a67ea2bd6a"
37+
},
38+
{
39+
"id": "v04-nested-null",
40+
"description": "Null at depth inside nested object.",
41+
"input": {
42+
"outer": {
43+
"inner": null,
44+
"value": 42
45+
},
46+
"top": "ok"
47+
},
48+
"expected_canonical_bytes": "{\"outer\":{\"inner\":null,\"value\":42},\"top\":\"ok\"}",
49+
"expected_sha256": "02f4d61eb43de15b3680d6c9d2e1852ba3947e78521c2ba7de8e4afb640ef043"
50+
},
51+
{
52+
"id": "v05-array-null",
53+
"description": "Null elements inside arrays. Both variants preserve array nulls.",
54+
"input": {
55+
"items": [
56+
1,
57+
null,
58+
3
59+
]
60+
},
61+
"expected_canonical_bytes": "{\"items\":[1,null,3]}",
62+
"expected_sha256": "df088e3ff71850c361e768d9ca5141426005ad9497b6d4cf6ec3e082fe663e75"
63+
},
64+
{
65+
"id": "v06-numbers",
66+
"description": "Mixed integer + signed + fractional + zero numeric serialization.",
67+
"input": {
68+
"integer": 42,
69+
"negative": -7,
70+
"float": 3.14,
71+
"zero": 0
72+
},
73+
"expected_canonical_bytes": "{\"float\":3.14,\"integer\":42,\"negative\":-7,\"zero\":0}",
74+
"expected_sha256": "35c2f03aed2d77e96b1e301c3ead65d8212d3a27f0776e9c85bb0e74414a904b"
75+
},
76+
{
77+
"id": "v07-empties",
78+
"description": "Empty object and empty array.",
79+
"input": {
80+
"emptyArr": [],
81+
"emptyObj": {}
82+
},
83+
"expected_canonical_bytes": "{\"emptyArr\":[],\"emptyObj\":{}}",
84+
"expected_sha256": "2e49ff7a9de29f7f42b6cfb0e7a9826b5743d888aed3029df47b773b4393d0d4"
85+
},
86+
{
87+
"id": "v08-unicode",
88+
"description": "Non-ASCII content in keys and values; UTF-8 bytes emitted literally per RFC 8785 §3.2.2.2.",
89+
"input": {
90+
"name": "Тимофій",
91+
"emoji": "🔐"
92+
},
93+
"expected_canonical_bytes": "{\"emoji\":\"🔐\",\"name\":\"Тимофій\"}",
94+
"expected_sha256": "f04c986f5b3862ad3c6ee7b26b54846bad39e101a27e98f72772023c40584fc6"
95+
},
96+
{
97+
"id": "v09-action-ref-tuple",
98+
"description": "I-D §4.1 action_ref pre-image shape — production-like input.",
99+
"input": {
100+
"agentId": "did:aps:z6Mkfoo",
101+
"actionType": "code_execution",
102+
"scopeRequired": [
103+
"commerce:read",
104+
"commerce:write"
105+
],
106+
"timestamp": "2026-05-21T00:00:00Z"
107+
},
108+
"expected_canonical_bytes": "{\"actionType\":\"code_execution\",\"agentId\":\"did:aps:z6Mkfoo\",\"scopeRequired\":[\"commerce:read\",\"commerce:write\"],\"timestamp\":\"2026-05-21T00:00:00Z\"}",
109+
"expected_sha256": "956fb10ca09e093086c856517a8f93c11416f5b84b0a01634bc5ebfff41670d3"
110+
},
111+
{
112+
"id": "v10-attribution-tuple",
113+
"description": "ATTRIBUTION-PRIMITIVE-v1.1 §1.6 four-tuple shape with null in params.",
114+
"input": {
115+
"agentId": "a",
116+
"actionType": "t",
117+
"params": {
118+
"k": null,
119+
"v": 1
120+
},
121+
"nonce": "n0"
122+
},
123+
"expected_canonical_bytes": "{\"actionType\":\"t\",\"agentId\":\"a\",\"nonce\":\"n0\",\"params\":{\"k\":null,\"v\":1}}",
124+
"expected_sha256": "c0686ef2cbb2b1c38b149598c50a60b0c01c2fd0ef9fd35f81eabb1aced6d591"
125+
},
126+
{
127+
"id": "v11-string-escapes",
128+
"description": "Tab (U+0009) and newline (U+000A) escapes per RFC 8785 §3.2.2.2.",
129+
"input": {
130+
"raw": "line1\tcol2\nline3"
131+
},
132+
"expected_canonical_bytes": "{\"raw\":\"line1\\tcol2\\nline3\"}",
133+
"expected_sha256": "87d59a36d71d2c4e2bebe9b0badb8d1e028e8b925cfdec41c824d6f874853922"
134+
},
135+
{
136+
"id": "v12-booleans",
137+
"description": "Boolean values are serialized as 'true' / 'false'.",
138+
"input": {
139+
"active": true,
140+
"revoked": false
141+
},
142+
"expected_canonical_bytes": "{\"active\":true,\"revoked\":false}",
143+
"expected_sha256": "9135447f8ef8e76a18f1e9b8457c3764002d36b2bf247a0f2ee9eb836dddcd4f"
144+
}
145+
]
146+
}

0 commit comments

Comments
 (0)