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/ 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. 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.")