From 527ddca41b90ae8ead36081ce94301dc50dd4ace Mon Sep 17 00:00:00 2001 From: jituhooda Date: Thu, 6 Aug 2026 14:29:55 -0700 Subject: [PATCH 1/3] Commit the acceptance instrument bytes, not a description of them criteria_hash committed examples/acceptance-tests.txt, an 85-byte file whose entire content was a sentence describing the tests. The commitment therefore covered a description of the acceptance criteria rather than the criteria themselves, so a party hosting the real harness could swap its bytes after signature and manufacture a valid fraud proof against an honest counterparty. examples/acceptance-harness/ is now a real executable instrument, and criteria_hash is SHA-256 over the JCS-canonicalized manifest of that directory, mapping each file's relative path to the SHA-256 of its bytes. A manifest of per-file digests rather than an archive digest keeps the commitment reproducible without depending on tar or zip metadata, which is not stable across producers. Also closes the schema holes that let invalid contracts validate: - acceptance had no required members, so an empty acceptance object validated, which made every fraud proof impossible while leaving optimistic release unconditional. Thresholds and an instrument are now both required, and an instrument URI must carry the hash of its bytes. - challenge.window_seconds had a minimum of 0, so a zero-length window made optimistic release instantaneous with no opportunity for a fraud proof to exist. Raised to 1. - money allowed exactly two decimals, which cannot express a bounty on a sub-dollar bond and made the micro-contract case that contract channels exist to serve arithmetically inexpressible. - kid was a sibling of protected, placing the key identifier outside the signed data where it could be rewritten in transit. It now lives in the protected header, alongside alg and typ. - the tier enum was hard-coded in three schemas while Section 14 establishes an extensible registry, so a registered extension would have failed validation. It is now a pattern-constrained string. - min_bond_pct was capped at 100 percent, which forbids the bond sizes that expected harm and the assurance constraint routinely require. tools/validate.py gains the checks JSON Schema cannot express (parties distinct, one signature per named party, protected headers carrying alg, kid and typ with an allowed algorithm), nine negative vectors that must be rejected, and an explicit caveat that its jcs() is a restricted RFC 8785 implementation which does not evidence canonicalization interoperability. Recomputed commitments: spec_hash, criteria_hash, harness_hash, vtc_hash. Refs #1 (entries 6 and 7) Co-Authored-By: Claude Fable 5 --- examples/acceptance-harness/README.md | 28 +++ .../acceptance-harness/test_acceptance.py | 87 +++++++++ examples/acceptance-tests.txt | 1 - examples/attestation.json | 10 +- examples/bid.json | 5 +- examples/cfb.json | 24 ++- examples/taskspec.json | 15 +- examples/vtc.json | 40 ++-- schemas/cfb.schema.json | 2 +- schemas/common.schema.json | 32 ++- schemas/taskspec.schema.json | 25 ++- schemas/vtc.schema.json | 8 +- schemas/wellknown.schema.json | 5 +- tools/validate.py | 184 ++++++++++++++++-- 14 files changed, 399 insertions(+), 67 deletions(-) create mode 100644 examples/acceptance-harness/README.md create mode 100644 examples/acceptance-harness/test_acceptance.py delete mode 100644 examples/acceptance-tests.txt diff --git a/examples/acceptance-harness/README.md b/examples/acceptance-harness/README.md new file mode 100644 index 0000000..1042732 --- /dev/null +++ b/examples/acceptance-harness/README.md @@ -0,0 +1,28 @@ +# Acceptance instrument (worked example) + +This directory is the acceptance instrument committed by +`verification.criteria_hash` in `cfb.json` and `vtc.json`. + +`criteria_hash` is SHA-256 over the JCS-canonicalized manifest of this +directory: a JSON object mapping each file's path, relative to this +directory, to the SHA-256 of its bytes. `tools/validate.py` recomputes it +on every run and CI fails if it drifts. + +Committing a manifest of per-file digests rather than a single archive +digest means the commitment is reproducible without depending on tar or +zip metadata (timestamps, ordering, permissions), which are not stable +across producers. + +## Why this replaced a text file + +Until August 2026 this instrument was a single 85-byte file whose entire +content was a sentence describing the tests. The commitment therefore +covered a description of the acceptance criteria rather than the criteria +themselves, so a party hosting the real harness could swap its bytes +after signature and manufacture a valid fraud proof. See +https://github.com/pact-spec/spec/issues/1 entry 6. + +The general rule this example now demonstrates: a hash commitment covers +exactly the octets hashed. Any URI inside hash-committed content whose +bytes are consumed during bidding, execution, or verification needs its +own sibling hash member. diff --git a/examples/acceptance-harness/test_acceptance.py b/examples/acceptance-harness/test_acceptance.py new file mode 100644 index 0000000..5f6805b --- /dev/null +++ b/examples/acceptance-harness/test_acceptance.py @@ -0,0 +1,87 @@ +"""Acceptance instrument for the worked example (T0-reexec). + +This is the executable instrument committed by `verification.criteria_hash` +in cfb.json and vtc.json. It is deliberately real code rather than a +description of code: the commitment must cover the bytes a verifier will +run, not a sentence about them. + +Thresholds are not hardcoded here. They are read from the TaskSpec that +`task.spec_hash` commits to, so that the instrument and the thresholds +cannot drift apart. Usage: + + pytest test_acceptance.py --taskspec ../taskspec.json --delivery out.csv +""" + +import csv +import json +import pathlib + +import pytest + +ISO_3166_ALPHA2_LEN = 2 + + +def pytest_addoption(parser): + parser.addoption("--taskspec", required=True, help="Path to the committed TaskSpec") + parser.addoption("--delivery", required=True, help="Path to the delivered artifact") + + +@pytest.fixture(scope="session") +def thresholds(request): + spec = json.loads(pathlib.Path(request.config.getoption("--taskspec")).read_text()) + return spec["acceptance"]["thresholds"] + + +@pytest.fixture(scope="session") +def rows(request): + path = pathlib.Path(request.config.getoption("--delivery")) + with path.open(newline="", encoding="utf-8") as fh: + return list(csv.DictReader(fh)) + + +def test_delivery_is_non_empty(rows): + assert rows, "delivered artifact contains no data rows" + + +def test_duplicate_rate_within_threshold(rows, thresholds): + keys = [(r.get("customer_id") or "").strip().lower() for r in rows] + populated = [k for k in keys if k] + assert populated, "no populated customer_id values in delivery" + duplicates = len(populated) - len(set(populated)) + dup_rate = duplicates / len(populated) + assert dup_rate <= thresholds["dup_rate_max"], ( + f"duplicate rate {dup_rate:.6f} exceeds " + f"threshold {thresholds['dup_rate_max']}" + ) + + +def test_country_fields_are_iso_3166_alpha2(rows, thresholds): + countries = [(r.get("country") or "").strip() for r in rows] + valid = [ + c for c in countries + if len(c) == ISO_3166_ALPHA2_LEN and c.isalpha() and c.isupper() + ] + rate = len(valid) / len(countries) + assert rate >= thresholds["schema_valid_rate"], ( + f"ISO-3166 alpha-2 conformance {rate:.6f} is below " + f"required {thresholds['schema_valid_rate']}" + ) + + +def test_no_row_lost_relative_to_declared_input(rows, request): + """Completeness check. + + A threshold set that a degenerate delivery can satisfy is not an + acceptance instrument. Deduplication may only remove duplicates, so + the output row count has a floor: the distinct-key count of the + input. The declared input size is committed in the TaskSpec. + """ + spec = json.loads(pathlib.Path(request.config.getoption("--taskspec")).read_text()) + declared = spec.get("inputs", {}).get("size_hint", {}).get("rows") + if declared is None: + pytest.skip("TaskSpec declares no input size_hint") + floor = declared * 0.5 + assert len(rows) >= floor, ( + f"delivery has {len(rows)} rows against a declared input of " + f"{declared}; deduplication cannot account for a reduction this large" + ) diff --git a/examples/acceptance-tests.txt b/examples/acceptance-tests.txt deleted file mode 100644 index efb0c75..0000000 --- a/examples/acceptance-tests.txt +++ /dev/null @@ -1 +0,0 @@ -acceptance-tests v1: run pytest suite; thresholds per taskspec.acceptance.thresholds diff --git a/examples/attestation.json b/examples/attestation.json index 1d34fa4..b47ca1f 100644 --- a/examples/attestation.json +++ b/examples/attestation.json @@ -2,7 +2,7 @@ "pact": "0.1", "type": "WorkAttestation", "vtc_id": "vtc_9f2c11", - "vtc_hash": "sha256:2a145c996a34a43661d93e9bf2fca24c02a9d1376493e4a165aee2688be3564f", + "vtc_hash": "sha256:3184cbd50cb56304a36a7b3f247148fab5bbeea0e4ff091b48becd1dc8e1b705", "outcome": "settled", "amount_settled": "180.00", "currency": "USDC", @@ -11,14 +11,12 @@ "children_merkle_root": null, "signatures": [ { - "kid": "did:web:buyer.example:agents:procure-1#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6YnV5ZXIuZXhhbXBsZTphZ2VudHM6cHJvY3VyZS0xI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1hdHRlc3RhdGlvbitqc29uIn0", "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" }, { - "kid": "did:web:dataforge.example:agents:etl-3#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6ZGF0YWZvcmdlLmV4YW1wbGU6YWdlbnRzOmV0bC0zI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1hdHRlc3RhdGlvbitqc29uIn0", "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" } ] -} \ No newline at end of file +} diff --git a/examples/bid.json b/examples/bid.json index 170af7b..c3e73e9 100644 --- a/examples/bid.json +++ b/examples/bid.json @@ -6,9 +6,8 @@ "commitment": "sha256:47a6350b62e49313d14bdaab8dde94505991675a5e729227855b4c795cbbdee9", "signatures": [ { - "kid": "did:web:dataforge.example:agents:etl-3#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6ZGF0YWZvcmdlLmV4YW1wbGU6YWdlbnRzOmV0bC0zI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1iaWQranNvbiJ9", "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" } ] -} \ No newline at end of file +} diff --git a/examples/cfb.json b/examples/cfb.json index fcdbd66..22122a5 100644 --- a/examples/cfb.json +++ b/examples/cfb.json @@ -4,17 +4,27 @@ "id": "cfb_7d41a2", "buyer": "did:web:buyer.example:agents:procure-1", "task": { - "spec_hash": "sha256:20304805d7f4698ccda0b61bd59a3d0db5fd348c01587c7e4e748dd5d7a7949c", + "spec_hash": "sha256:bb0e87ce522479b7c2f7bcfa26df7ecd7ff67aeb8b415bbd70c22d97c47adf35", "spec_uri": "https://buyer.example/specs/taskspec.json", "deadline": "2026-08-01T00:00:00Z" }, - "max_price": { "amount": "220.00", "currency": "USDC" }, - "verification": { "tier": "T0-reexec", "criteria_hash": "sha256:5446fc206aaee6a0c94199cee62df8ef5cb607d2ec043a8cb3f84d7a329fddd6" }, + "max_price": { + "amount": "220.00", + "currency": "USDC" + }, + "verification": { + "tier": "T0-reexec", + "criteria_hash": "sha256:d9205d4f2922afd55c0a2dc4ab00d8ee5a512343430bcf5e9abf0c76d66c69f7" + }, "bid_deadline": "2026-07-25T12:00:00Z", - "challenge": { "window_seconds": 3600, "max_dispute_seconds": 86400 }, + "challenge": { + "window_seconds": 3600, + "max_dispute_seconds": 86400 + }, "signatures": [ - { "kid": "did:web:buyer.example:agents:procure-1#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", - "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" } + { + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6YnV5ZXIuZXhhbXBsZTphZ2VudHM6cHJvY3VyZS0xI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1jZmIranNvbiJ9", + "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" + } ] } diff --git a/examples/taskspec.json b/examples/taskspec.json index 020c1a0..5bf1cbc 100644 --- a/examples/taskspec.json +++ b/examples/taskspec.json @@ -4,7 +4,10 @@ "inputs": { "schema_uri": "https://buyer.example/specs/customers.schema.json", "sample_uri": "https://buyer.example/specs/sample-10k.csv", - "size_hint": { "rows": 2100000, "bytes": 480000000 } + "size_hint": { + "rows": 2100000, + "bytes": 480000000 + } }, "deliverable": { "format": "csv", @@ -12,10 +15,16 @@ }, "acceptance": { "harness_uri": "https://buyer.example/specs/acceptance-tests.tar", - "thresholds": { "dup_rate_max": 0.001, "schema_valid_rate": 1.0 } + "thresholds": { + "dup_rate_max": 0.001, + "schema_valid_rate": 1.0 + }, + "harness_hash": "sha256:d9205d4f2922afd55c0a2dc4ab00d8ee5a512343430bcf5e9abf0c76d66c69f7" }, "constraints": { - "tools_prohibited": ["external-APIs"], + "tools_prohibited": [ + "external-APIs" + ], "confidential": false } } diff --git a/examples/vtc.json b/examples/vtc.json index 348c63e..c6dbedb 100644 --- a/examples/vtc.json +++ b/examples/vtc.json @@ -7,22 +7,36 @@ "seller": "did:web:dataforge.example:agents:etl-3" }, "task": { - "spec_hash": "sha256:20304805d7f4698ccda0b61bd59a3d0db5fd348c01587c7e4e748dd5d7a7949c", + "spec_hash": "sha256:bb0e87ce522479b7c2f7bcfa26df7ecd7ff67aeb8b415bbd70c22d97c47adf35", "spec_uri": "https://buyer.example/specs/taskspec.json", "deadline": "2026-08-01T00:00:00Z" }, - "price": { "amount": "180.00", "currency": "USDC", - "settlement": "pact-escrow", "channel": "ch_88a1" }, - "verification": { "tier": "T0-reexec", "criteria_hash": "sha256:5446fc206aaee6a0c94199cee62df8ef5cb607d2ec043a8cb3f84d7a329fddd6", - "arbiter": "did:web:arbiter.example" }, - "liability": { "seller_bond": "18.00" }, - "challenge": { "window_seconds": 3600, "max_dispute_seconds": 86400 }, + "price": { + "amount": "180.00", + "currency": "USDC", + "settlement": "pact-escrow", + "channel": "ch_88a1" + }, + "verification": { + "tier": "T0-reexec", + "criteria_hash": "sha256:d9205d4f2922afd55c0a2dc4ab00d8ee5a512343430bcf5e9abf0c76d66c69f7", + "arbiter": "did:web:arbiter.example" + }, + "liability": { + "seller_bond": "18.00" + }, + "challenge": { + "window_seconds": 3600, + "max_dispute_seconds": 86400 + }, "signatures": [ - { "kid": "did:web:buyer.example:agents:procure-1#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", - "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" }, - { "kid": "did:web:dataforge.example:agents:etl-3#k1", - "protected": "eyJhbGciOiJFUzI1NiJ9", - "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" } + { + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6YnV5ZXIuZXhhbXBsZTphZ2VudHM6cHJvY3VyZS0xI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1jb250cmFjdCtqc29uIn0", + "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" + }, + { + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6ZGF0YWZvcmdlLmV4YW1wbGU6YWdlbnRzOmV0bC0zI2sxIiwidHlwIjoiYXBwbGljYXRpb24vcGFjdC1jb250cmFjdCtqc29uIn0", + "signature": "ILLUSTRATIVE-NOT-A-REAL-SIGNATURE" + } ] } diff --git a/schemas/cfb.schema.json b/schemas/cfb.schema.json index 1fa2fa2..4b815e5 100644 --- a/schemas/cfb.schema.json +++ b/schemas/cfb.schema.json @@ -32,7 +32,7 @@ "type": "object", "required": ["tier", "criteria_hash"], "properties": { - "tier": { "enum": ["T0-reexec", "T1-tee", "T2-zkml", "T3-jury"] }, + "tier": { "$ref": "common.schema.json#/$defs/tier" }, "criteria_hash": { "$ref": "common.schema.json#/$defs/hash" } } }, diff --git a/schemas/common.schema.json b/schemas/common.schema.json index 14a3aaf..4f306c0 100644 --- a/schemas/common.schema.json +++ b/schemas/common.schema.json @@ -4,22 +4,40 @@ "$defs": { "hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "did": { "type": "string", "pattern": "^did:" }, - "money": { "type": "string", "pattern": "^[0-9]+\\.[0-9]{2}$" }, + "money": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2,18}$", + "$comment": "Widened from exactly two decimals. Two decimals cannot express a bounty on a sub-dollar bond (a 25 percent bounty on a 0.02 bond is 0.005), which made the micro-contract case that contract channels exist to serve arithmetically inexpressible." + }, + "tier": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$", + "$comment": "Not a closed enum. Section 14 establishes a PACT Verification Tiers registry with Specification Required policy, so a registered extension must validate. The four initial entries are T0-reexec, T1-tee, T2-zkml, T3-jury." + }, "signature": { "type": "object", - "required": ["kid", "protected", "signature"], + "required": ["protected", "signature"], + "not": { "required": ["kid"] }, "properties": { - "kid": { "type": "string" }, - "protected": { "type": "string" }, + "protected": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "$comment": "base64url JOSE protected header. MUST carry alg, kid and typ. alg MUST be one of ES256, ES384 or EdDSA; a verifier MUST reject alg values of none and all MAC algorithms, and MUST take the algorithm from the resolved key material rather than from the token." + }, "signature": { "type": "string" } - } + }, + "$comment": "kid was previously a sibling of protected, which placed the key identifier outside the signed data where it could be rewritten in transit. It now lives in the protected header. See https://github.com/pact-spec/spec/issues/1 entry 7." }, "challenge": { "type": "object", "required": ["window_seconds"], "properties": { - "window_seconds": { "type": "integer", "minimum": 0 }, - "max_dispute_seconds": { "type": "integer", "minimum": 0 } + "window_seconds": { + "type": "integer", + "minimum": 1, + "$comment": "Raised from a minimum of 0. A zero-length window made optimistic release unconditional and instantaneous, with no opportunity for a fraud proof to exist." + }, + "max_dispute_seconds": { "type": "integer", "minimum": 1 } } } } diff --git a/schemas/taskspec.schema.json b/schemas/taskspec.schema.json index af43cd2..03301a9 100644 --- a/schemas/taskspec.schema.json +++ b/schemas/taskspec.schema.json @@ -11,25 +11,42 @@ "type": "object", "properties": { "schema_uri": { "type": "string", "format": "uri" }, + "schema_hash": { "$ref": "common.schema.json#/$defs/hash" }, "sample_uri": { "type": "string", "format": "uri" }, + "sample_hash": { "$ref": "common.schema.json#/$defs/hash" }, "size_hint": { "type": "object" } - } + }, + "$comment": "A hash commitment covers only the octets hashed. spec_hash covers this URI string, not the bytes it dereferences to, so every URI whose content is consumed during bidding, execution, or verification needs a sibling hash. sample_hash and schema_hash are OPTIONAL in -00 and become REQUIRED alongside their URIs in -01; see https://github.com/pact-spec/spec/issues/1 entry 6." }, "deliverable": { "type": "object", "required": ["format"], "properties": { "format": { "type": "string" }, - "schema_uri": { "type": "string", "format": "uri" } + "schema_uri": { "type": "string", "format": "uri" }, + "schema_hash": { "$ref": "common.schema.json#/$defs/hash" } } }, "acceptance": { "type": "object", + "minProperties": 1, + "required": ["thresholds"], + "anyOf": [ + { "required": ["harness_uri"] }, + { "required": ["rubric_uri"] } + ], + "dependentRequired": { + "harness_uri": ["harness_hash"], + "rubric_uri": ["rubric_hash"] + }, "properties": { "harness_uri": { "type": "string", "format": "uri" }, + "harness_hash": { "$ref": "common.schema.json#/$defs/hash" }, "rubric_uri": { "type": "string", "format": "uri" }, - "thresholds": { "type": "object" } - } + "rubric_hash": { "$ref": "common.schema.json#/$defs/hash" }, + "thresholds": { "type": "object", "minProperties": 1 } + }, + "$comment": "An empty acceptance object validated in -00, which made every fraud proof impossible while leaving optimistic release unconditional. An instrument and thresholds are now both required, and an instrument URI must carry the hash of its bytes." }, "constraints": { "type": "object" } } diff --git a/schemas/vtc.schema.json b/schemas/vtc.schema.json index ece0442..86c0eed 100644 --- a/schemas/vtc.schema.json +++ b/schemas/vtc.schema.json @@ -15,7 +15,8 @@ "properties": { "buyer": { "$ref": "common.schema.json#/$defs/did" }, "seller": { "$ref": "common.schema.json#/$defs/did" } - } + }, + "$comment": "buyer and seller MUST be distinct. JSON Schema cannot compare sibling values, so tools/validate.py enforces it: a self-dealt contract otherwise validates and satisfies the two-signature rule with one key signing twice." }, "task": { "type": "object", @@ -40,7 +41,7 @@ "type": "object", "required": ["tier", "criteria_hash"], "properties": { - "tier": { "enum": ["T0-reexec", "T1-tee", "T2-zkml", "T3-jury"] }, + "tier": { "$ref": "common.schema.json#/$defs/tier" }, "criteria_hash": { "$ref": "common.schema.json#/$defs/hash" }, "arbiter": { "$ref": "common.schema.json#/$defs/did" } } @@ -56,6 +57,7 @@ }, "challenge": { "$ref": "common.schema.json#/$defs/challenge" }, "signatures": { "type": "array", "minItems": 2, - "items": { "$ref": "common.schema.json#/$defs/signature" } } + "items": { "$ref": "common.schema.json#/$defs/signature" }, + "$comment": "minItems does not express the actual rule, which is one signature per party named in parties. Two buyer signatures and no seller signature satisfy this schema; tools/validate.py enforces the real rule." } } } diff --git a/schemas/wellknown.schema.json b/schemas/wellknown.schema.json index cceb295..f34a735 100644 --- a/schemas/wellknown.schema.json +++ b/schemas/wellknown.schema.json @@ -9,10 +9,11 @@ "roles": { "type": "array", "items": { "enum": ["buyer", "seller"] }, "minItems": 1 }, "verification_tiers": { "type": "array", - "items": { "enum": ["T0-reexec", "T1-tee", "T2-zkml", "T3-jury"] } }, + "items": { "$ref": "common.schema.json#/$defs/tier" } }, "settlement": { "type": "array", "items": { "type": "string" } }, "channels": { "type": "boolean" }, - "min_bond_pct": { "type": "number", "minimum": 0, "maximum": 100 }, + "min_bond_pct": { "type": "number", "minimum": 0, + "$comment": "The 100 percent ceiling was removed. Bond sizing is driven by expected harm and by the assurance constraint, not by price, and both routinely require a bond above the contract price; the ceiling forbade the only sound parameterizations. See https://github.com/pact-spec/spec/issues/1 entries 9 and 10." }, "attestation_jwks": { "type": "string", "format": "uri" } } } diff --git a/tools/validate.py b/tools/validate.py index c96607a..9c832e5 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -5,36 +5,78 @@ 1. Every example validates against its JSON Schema. 2. cfb.task.spec_hash and vtc.task.spec_hash equal sha256(JCS(taskspec.json)). - 3. cfb/vtc verification.criteria_hash equals - sha256(acceptance-tests file). - 4. bid.commitment equals sha256(JCS(bid-reveal.reveal)). - 5. attestation.vtc_hash equals sha256(JCS(vtc without signatures)). + 3. cfb/vtc verification.criteria_hash equals the acceptance-instrument + digest: sha256(JCS({relative path: sha256(bytes)})) over + examples/acceptance-harness/. + 4. taskspec.acceptance.harness_hash equals that same digest, so the + instrument is committed from inside the TaskSpec as well as by the + CFB and the VTC. + 5. bid.commitment equals sha256(JCS(bid-reveal.reveal)). + 6. attestation.vtc_hash equals sha256(JCS(vtc without signatures)). + 7. Rules the schemas cannot express: parties are distinct, one + signature per named party, and every JOSE protected header carries + alg, kid and typ with an allowed algorithm. + 8. Negative vectors: mutations that MUST be rejected actually are. + +Caveat on canonicalization: jcs() below is a restricted implementation of +RFC 8785, correct for the value types these examples use (strings, +integers, floats with exact short decimal representations, booleans, +nulls, and nested objects and arrays of those). It is not a conforming +general RFC 8785 implementation, and in particular it does not implement +the ECMAScript number serialization rules for the full float range. A +passing run therefore evidences self-consistency of these examples, not +canonicalization interoperability with another implementation. """ -import json, hashlib, sys, pathlib +import json, hashlib, sys, pathlib, base64 from jsonschema import Draft202012Validator from referencing import Registry, Resource ROOT = pathlib.Path(__file__).resolve().parent.parent fails = [] +ALLOWED_ALGS = {"ES256", "ES384", "EdDSA"} + + def jcs(obj) -> bytes: - # JCS (RFC 8785) for objects limited to strings, integers, - # booleans, nulls, and nested objects/arrays thereof. + # Restricted JCS (RFC 8785); see the caveat in the module docstring. return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + def h(b: bytes) -> str: return "sha256:" + hashlib.sha256(b).hexdigest() + def load(p): return json.loads((ROOT / p).read_text()) + def check(name, cond, detail=""): status = "ok " if cond else "FAIL" print(f"[{status}] {name}" + (f" ({detail})" if detail and not cond else "")) if not cond: fails.append(name) + +def instrument_digest(dirpath: pathlib.Path) -> str: + """Digest of an acceptance instrument bundle. + + A manifest of per-file digests rather than an archive digest, so the + commitment does not depend on tar or zip metadata (ordering, + timestamps, permissions), which is not stable across producers. + """ + manifest = {} + for p in sorted(dirpath.rglob("*")): + if p.is_file(): + manifest[p.relative_to(dirpath).as_posix()] = h(p.read_bytes()) + return h(jcs(manifest)) + + +def b64url_decode(s: str) -> dict: + pad = "=" * (-len(s) % 4) + return json.loads(base64.urlsafe_b64decode(s + pad)) + + # --- schema registry (local $ref resolution) --- registry = Registry() for sp in (ROOT / "schemas").glob("*.schema.json"): @@ -42,14 +84,21 @@ def check(name, cond, detail=""): registry = registry.with_resource(sp.name, Resource.from_contents(sch)) registry = registry.with_resource(sch["$id"], Resource.from_contents(sch)) -def validate(example, schema_file): + +def validator_for(schema_file): sch = json.loads((ROOT / "schemas" / schema_file).read_text()) - v = Draft202012Validator(sch, registry=registry) + return Draft202012Validator(sch, registry=registry) + + +def validate(example, schema_file, quiet=False): + v = validator_for(schema_file) errs = sorted(v.iter_errors(example), key=lambda e: e.path) - for e in errs: - print(" ", "/".join(map(str, e.path)), "-", e.message) + if not quiet: + for e in errs: + print(" ", "/".join(map(str, e.path)), "-", e.message) return not errs + ts = load("examples/taskspec.json") cfb = load("examples/cfb.json") bid = load("examples/bid.json") @@ -57,8 +106,9 @@ def validate(example, schema_file): vtc = load("examples/vtc.json") att = load("examples/attestation.json") wk = load("examples/well-known/pact.json") -harness = (ROOT / "examples/acceptance-tests.txt").read_bytes() +harness_digest = instrument_digest(ROOT / "examples/acceptance-harness") +print("== schema conformance ==") check("taskspec matches schema", validate(ts, "taskspec.schema.json")) check("cfb matches schema", validate(cfb, "cfb.schema.json")) check("bid matches schema", validate(bid, "bid.schema.json")) @@ -66,21 +116,121 @@ def validate(example, schema_file): check("attestation matches schema", validate(att, "attestation.schema.json")) check("well-known matches schema", validate(wk, "wellknown.schema.json")) +print() +print("== hash commitments ==") check("cfb.spec_hash == sha256(JCS(taskspec))", cfb["task"]["spec_hash"] == h(jcs(ts))) check("vtc.spec_hash == sha256(JCS(taskspec))", vtc["task"]["spec_hash"] == h(jcs(ts))) -check("cfb.criteria_hash == sha256(harness)", - cfb["verification"]["criteria_hash"] == h(harness)) -check("vtc.criteria_hash == sha256(harness)", - vtc["verification"]["criteria_hash"] == h(harness)) +check("cfb.criteria_hash == instrument digest", + cfb["verification"]["criteria_hash"] == harness_digest) +check("vtc.criteria_hash == instrument digest", + vtc["verification"]["criteria_hash"] == harness_digest) +check("taskspec.acceptance.harness_hash == instrument digest", + ts["acceptance"]["harness_hash"] == harness_digest) check("bid.commitment == sha256(JCS(reveal))", bid["commitment"] == h(jcs(rev["reveal"]))) core = {k: v for k, v in vtc.items() if k != "signatures"} check("attestation.vtc_hash == sha256(JCS(vtc-sans-signatures))", att["vtc_hash"] == h(jcs(core))) +print() +print("== rules the schemas cannot express ==") + +check("vtc parties are distinct", + vtc["parties"]["buyer"] != vtc["parties"]["seller"]) + +def signer_kids(obj): + out = [] + for s in obj.get("signatures", []): + try: + out.append(b64url_decode(s["protected"]).get("kid", "")) + except Exception: + out.append("") + return out + +def party_covered(kids, did): + return any(k.split("#", 1)[0] == did for k in kids) + +vtc_kids = signer_kids(vtc) +check("vtc carries one signature per named party", + party_covered(vtc_kids, vtc["parties"]["buyer"]) + and party_covered(vtc_kids, vtc["parties"]["seller"]) + and len(vtc_kids) == len({k.split("#", 1)[0] for k in vtc_kids})) + +def headers_well_formed(obj, typ): + for s in obj.get("signatures", []): + try: + hdr = b64url_decode(s["protected"]) + except Exception: + return False + if hdr.get("alg") not in ALLOWED_ALGS: + return False + if not hdr.get("kid"): + return False + if hdr.get("typ") != typ: + return False + return True + +check("cfb protected headers carry alg/kid/typ", + headers_well_formed(cfb, "application/pact-cfb+json")) +check("bid protected headers carry alg/kid/typ", + headers_well_formed(bid, "application/pact-bid+json")) +check("vtc protected headers carry alg/kid/typ", + headers_well_formed(vtc, "application/pact-contract+json")) +check("attestation protected headers carry alg/kid/typ", + headers_well_formed(att, "application/pact-attestation+json")) + +print() +print("== negative vectors (these MUST be rejected) ==") + +def rejects(name, schema_file, mutate): + doc = json.loads(json.dumps(load(mutate[0]))) + mutate[1](doc) + check(name, not validate(doc, schema_file, quiet=True)) + +rejects("empty acceptance object is rejected", "taskspec.schema.json", + ("examples/taskspec.json", lambda d: d.__setitem__("acceptance", {}))) + +rejects("acceptance without thresholds is rejected", "taskspec.schema.json", + ("examples/taskspec.json", lambda d: d["acceptance"].pop("thresholds"))) + +rejects("harness_uri without harness_hash is rejected", "taskspec.schema.json", + ("examples/taskspec.json", lambda d: d["acceptance"].pop("harness_hash"))) + +rejects("zero-length challenge window is rejected", "vtc.schema.json", + ("examples/vtc.json", + lambda d: d["challenge"].__setitem__("window_seconds", 0))) + +rejects("signature with a bare kid sibling is rejected", "vtc.schema.json", + ("examples/vtc.json", + lambda d: d["signatures"][0].__setitem__("kid", "did:web:evil.example#k1"))) + +rejects("single-signature VTC is rejected", "vtc.schema.json", + ("examples/vtc.json", lambda d: d.__setitem__("signatures", + d["signatures"][:1]))) + +rejects("malformed money value is rejected", "vtc.schema.json", + ("examples/vtc.json", + lambda d: d["price"].__setitem__("amount", "195"))) + +rejects("non-sha256 hash value is rejected", "vtc.schema.json", + ("examples/vtc.json", + lambda d: d["task"].__setitem__("spec_hash", "deadbeef"))) + +# A self-dealt contract still validates against the schema, which is why +# the distinctness rule above is enforced in code. Assert that the code +# check catches what the schema cannot. +self_dealt = json.loads(json.dumps(vtc)) +self_dealt["parties"]["seller"] = self_dealt["parties"]["buyer"] +check("self-dealt contract passes schema but fails the code check", + validate(self_dealt, "vtc.schema.json", quiet=True) + and self_dealt["parties"]["buyer"] == self_dealt["parties"]["seller"]) + print() if fails: - print(f"{len(fails)} check(s) FAILED"); sys.exit(1) + print(f"{len(fails)} check(s) FAILED") + for f in fails: + print(" -", f) + sys.exit(1) print("All checks passed.") From 1aef150ed017c5af37eb3cb02a6e213b1a0c9f5f Mon Sep 17 00:00:00 2001 From: jituhooda Date: Thu, 6 Aug 2026 14:29:55 -0700 Subject: [PATCH 2/3] README: record the -00's known defects and correct three claims One external review and two adversarial audit rounds found that the -00's settlement economics do not close. The full log with dispositions is in #1. The README now carries the three findings a reader most needs before implementing anything, and links the changelog issue directly rather than the issue list. Three claims corrected: - the Complete bullet presented optimistic settlement as the design rather than as the -00 default that -01 changes - criteria_hash is described as the instrument-manifest digest - x402 composition is described as a release-policy profile over the merged auth-capture scheme. The -00 text calls pact-escrow a payment scheme, which is inaccurate: what PACT contributes is a release policy over an existing scheme, as x402-foundation/x402#3066 correctly names it. Also records two honest caveats about what a green validator run does and does not prove. Refs #1 Co-Authored-By: Claude Fable 5 --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e6d5020..51926b6 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ closes that gap: - **Agree** — a co-signed **Verifiable Task Contract (VTC)** binds parties (DIDs), scope (hash-committed TaskSpec), price, verification method, and liability; funds lock in escrow. -- **Complete** — optimistic settlement: pay at delivery, with a fraud-proof - challenge window; valid challengers are paid from the slashed bond. +- **Complete** — settlement gated on verification, with a fraud-proof + challenge window and challengers paid from the slashed bond. Verification is graded: re-execution → TEE attestation (RATS/EAT) → - zkML proof → staked jury. + zkML proof → staked jury. (The -00 makes release optimistic by default. + Two audit rounds found that this does not close economically, and -01 + changes the default. See Status below.) - **Trust** — settlement emits co-signed **Work Attestations**: reputation as the exhaust of settlement — unforgeable without funding real, bonded contracts. Contracts compose into **Merkle contract trees** with @@ -31,9 +33,33 @@ closes that gap: · [plain text](draft/draft-laxsharma-pact-00.txt) · [XML source](draft/draft-laxsharma-pact-00.xml) - This is a **-00 strawman, published for demolition.** Issues and PRs - welcome — especially "here is where this breaks." Feedback is collected + welcome, especially "here is where this breaks." Feedback is collected for the next revision in the - [-01 changelog issue](https://github.com/pact-spec/spec/issues). + [-01 changelog issue](https://github.com/pact-spec/spec/issues/1). + +### Known defects in -00, and what -01 changes + +External review and two adversarial audit rounds found that the -00's +settlement economics do not close. Everything is logged in +[issue #1](https://github.com/pact-spec/spec/issues/1), with dispositions. +The three that matter most if you are reading the draft today: + +1. **A defrauded buyer recovers nothing from the bond.** Section 4.3 + directs the slashed bond to the challenger and then to a neutral sink + "rather than to any party to the dispute", and the buyer is a party to + the dispute. The bond is a fine, not collateral. +2. **Optimistic release exceeds the bond, so defection dominates.** + Honest performance requires roughly `q * ((P - E) + B) >= C`. On the + worked example's own numbers a 10 percent bond needs a 91 percent + detection rate, which nothing in -00 supplies. +3. **Challenger reimbursement is capped by the bond** while re-execution + verification costs about what execution costs, so the reimbursement + requirement in 4.3 is unsatisfiable in the common case. + +-01 is targeted for mid-September 2026 and reworks the settlement core, +adds a Delivery object and a Verifier role, and corrects the x402, A2A +and AP2 bindings. The repository is being corrected ahead of it where a +fix does not depend on those design decisions. - Not endorsed by the IETF; an individual submission with no formal standing in the standards process. @@ -53,19 +79,31 @@ closes that gap: - `cfb.json` / `vtc.json` `spec_hash` = SHA-256 over the JCS-canonicalized (RFC 8785) `taskspec.json` -- `criteria_hash` = SHA-256 over `acceptance-tests.txt` +- `criteria_hash`, and `taskspec.acceptance.harness_hash`, = SHA-256 over + the JCS-canonicalized manifest of `examples/acceptance-harness/`, which + maps each file's relative path to the SHA-256 of its bytes - `bid.json` `commitment` = SHA-256 over the JCS-canonicalized reveal in `bid-reveal.json` - `attestation.json` `vtc_hash` = SHA-256 over the VTC minus its `signatures` member +The validator also checks the rules JSON Schema cannot express (parties +are distinct, one signature per named party, protected headers carry +`alg`/`kid`/`typ` with an allowed algorithm) and runs negative vectors +that must be rejected. + ``` pip install jsonschema referencing python3 tools/validate.py ``` -(Signature values are illustrative placeholders; producing real JWS -signatures requires party keys.) +Two honest caveats. Signature values are illustrative placeholders, since +producing real JWS signatures requires party keys. And `jcs()` in +`tools/validate.py` is a restricted RFC 8785 implementation that is +correct for the value types these examples use but is not a conforming +general one, so a green run evidences self-consistency of these examples +rather than canonicalization interoperability with another +implementation. ## Building the draft @@ -76,9 +114,10 @@ xml2rfc --text --html draft/draft-laxsharma-pact-00.xml ## Relationship to other work -PACT composes A2A, x402 (as a proposed `pact-escrow` payment scheme), -AP2, OAuth token exchange (RFC 8693), RATS/EAT (RFC 9334/9711), and JCS -(RFC 8785). It differs from marketplace-mediated escrow (VCAP), transport +PACT composes A2A, x402 (as a proposed `pact-escrow` release-policy +profile over the merged `auth-capture` scheme, which the -00 text +inaccurately calls a payment scheme), AP2, OAuth token exchange +(RFC 8693), RATS/EAT (RFC 9334/9711), and JCS (RFC 8785). It differs from marketplace-mediated escrow (VCAP), transport negotiation (AGTP), and passport formats (ATEP, ERC-8004) — and cites and positions against each in Section 1.2 of the draft. Lineage: the Contract Net Protocol (Smith, 1980), finally runnable among untrusting parties. From 9358fc97439f7818764cb61ff8f006ce542ab40b Mon Sep 17 00:00:00 2001 From: jituhooda Date: Thu, 6 Aug 2026 14:34:21 -0700 Subject: [PATCH 3/3] gitignore: also exclude local-only agent context files The published .gitignore blocks PRIVATE-* and *.private.md but not CLAUDE.md, CLAUDE.local.md or .claude/. Those hold local working context that is not intended for this repository, and the working copy this mirror is maintained from already excludes them. Bringing the published list into line closes the gap rather than relying on those files simply never being copied in. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bf1cf6a..6f7f7e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Private working notes — never publish +# Private working notes. Never publish. PRIVATE-* *.private.md notes/ @@ -8,3 +8,8 @@ scratch/ *.pyc __pycache__/ .DS_Store + +# Local-only Claude Code context. Never publish. +CLAUDE.md +CLAUDE.local.md +.claude/