Guide
Check the decision afterwards, without trusting the coordinator.
The repository's real-decision flow ends with a receipt file: the masked transcript, a SHA-256 hash over it, the announced decision and each participant's acceptance. This page is what you can re-derive from that file on your own machine, how to replay your own red lines against the outcome without showing them to anyone, and the list of things the check does not prove.
Library calls and values here were checked against version 0.2.0. The verifier, scripts/verify-receipt.py, and the examples are not in the PyPI wheel: they need a clone of the repository (or the v0.2.0 tag) plus the base package. Companion guides: running Parley as an MCP tool, fair multi-agent negotiation, red lines for LLM agents and a fair group decision with private limits.
Who you are checking against
The coordinator is the process that asks every agent for a verdict, writes the transcript and announces the winner. Parley treats it as untrusted by construction: it only ever sees masked verdicts, never a preference sheet. But it does author the record, so a dishonest coordinator could finalize an option that is feasible but not the max-min one, edit a verdict after the fact, or drop a participant from the roster. The checks below exist for those three moves: the first two are caught in the file itself; dropping a participant only if you check the roster yourself.
There are two separate questions, and one check does not answer the other.Was I betrayed? is answered by you alone, replaying your own sheet. Was the selection honest?is answered by anyone holding the receipt, recomputing the max-min rule over the public verdicts.
1. What a receipt contains
examples/real_decision.py --receipt receipt.json writes a JSON object with nine fields. The verifier refuses a file that is missing any of them.
| Field | Holds |
|---|---|
| title | The decision's name, as given to the run. |
| status | "agreed" or "deadlock". |
| decision | The winning option object, or null on a deadlock. |
| transcript_hash | SHA-256 hex digest of the canonical transcript. |
| max_min_verified | Whether the coordinator's own recomputation of the max-min rule matched the announced decision. |
| unanimous_acceptance | Whether every participant accepted this exact hash and decision. |
| participants | The roster the coordinator wrote down. |
| acceptances | One object per participant: owner, decision, transcript_hash, accepted, and sig / pubkey_hex, which are null unless signing was on. |
| transcript | Every option with each party's masked verdict: owner, acceptable, score, reason ("ok" or "red-line"), sig, pubkey_hex. |
No sheet and no constraint name is in the file. A verdict names its owner, a boolean, a soft score and the masked reason; it never says which red line was crossed. Soft scores are public and leak preference ordering (SECURITY.md).
2. What the SHA-256 hash covers
Transcript.hash() is SHA-256 over the canonical JSON of{"entries": [...], "result": {...}}: keys sorted, non-ASCII kept as is, and tuples tagged so a tuple and a list cannot hash the same. Two honest assemblies of the same parley produce the same digest; changing oneacceptable flag changes it. The transcript does not store the hash and does not sign it. It is tamper-evident only against a copy of the hash kept outside the record, which is why the ratification step shows each participant the hash before they accept.
3. Replay your own sheet
verify_non_betrayal(sheet, decision) re-evaluates your hard constraints against the decision and returns Trueonly when every one holds. It does not read the recorded verdicts; it needs your sheet and the public decision, and nothing else. A Nonedecision (a deadlock) returns True, since it forces nothing on anyone. This is the README quickstart, run under 0.2.0:
from parley.agent import Agent
from parley.consensus import run_consensus
from parley.preferences import HardConstraint, PreferenceSheet
options = [{"venue": "rooftop", "cost": 900}, {"venue": "garden", "cost": 600}, {"venue": "diner", "cost": 300}]
ana = PreferenceSheet("Ana", utility=lambda o: 1.0 if o["venue"] == "rooftop" else 0.4)
bob = PreferenceSheet("Bob", hard=[HardConstraint("budget", lambda o: o["cost"] <= 700)],
utility=lambda o: 1 - o["cost"] / 1000)
cara = PreferenceSheet("Cara", utility=lambda o: 0.9 if o["venue"] == "garden" else 0.5)
result = run_consensus([Agent(s.owner, s) for s in (ana, bob, cara)], options)
print(result.status, result.decision)
print("receipt sha256:", result.transcript.hash())
for sheet in (ana, bob, cara):
print(sheet.owner, "not betrayed:", result.transcript.verify_non_betrayal(sheet, result.decision))agreed {'venue': 'garden', 'cost': 600}
receipt sha256: a9cb75c0a15b41360fb2b134e084ca3b577daaf50f5edb657c5c4da43cc2fc72
Ana not betrayed: True
Bob not betrayed: True
Cara not betrayed: TrueIn the real-decision flow, parley.ratify.ratify refuses an acceptance that the sheet you submit rejects. It cannot tell whether that sheet is current or complete. The acceptance it returns carries the owner, the decision, the transcript hash and the boolean, and nothing about why the red lines held.
4. Check the whole receipt
The interactive flow from the repository writes a receipt when given a path:
python examples/real_decision.py --options examples/cases/flatshare.json \
--participants Ana,Bob,Cara --manual --receipt receipt.json
python scripts/verify-receipt.py receipt.jsonFor this page the receipt was produced in process fromexamples/demo/recipe_committee.json, a grant committee with three reviewers, in a fresh virtual environment with onlyparley-consensus==0.2.0 installed, with the verifier script taken from a checkout of the repository: the recipe was run, each party's acceptance was made with ratify, and the nine fields were written exactly as real_decision.py writes them. The Budget reviewer's one red line isreviewed == true. The unreviewed option has the best numbers and is rejected before any score is weighed. Two transcript entries, scores rounded to two decimals:
rubber-stamp (impact 9, cost 5, novelty 8, reviewed false)
Impact acceptable=true reason="ok" score=1.00
Budget acceptable=false reason="red-line" score=0.94
Novelty acceptable=true reason="ok" score=0.89
fund-biotech (impact 5, cost 20, novelty 9, reviewed true)
Impact acceptable=true reason="ok" score=0.56
Budget acceptable=true reason="ok" score=0.78
Novelty acceptable=true reason="ok" score=1.00fund-biotech wins: among the three options feasible for everyone it has the highest minimum score. Then the verifier, from the file alone, with the trailing explanation of what it checked cut for length (the script prints it every time):
$ python scripts/verify-receipt.py receipt.json
Parley receipt check: Fund a grant proposal (committee vote)
status agreed decision {"cost": 20, "id": "fund-biotech", "impact": 5, "label": "Fund the diagnostics pilot", "novelty": 9, "reviewed": true}
Record
transcript hash claimed d08a48ebf62b63f22d5e754cccbc7babcf42b867fc6be9fd1366c4135828b6bc
recomputed d08a48ebf62b63f22d5e754cccbc7babcf42b867fc6be9fd1366c4135828b6bc OK
status/decision match the finalized record OK
max-min honest yes (recomputed from the recorded verdicts) OK
roster Budget, Impact, Novelty (record: Budget, Impact, Novelty) OK
verdict sigs none
Acceptances
Impact accepted bound to this record signature unsigned OK
Budget accepted bound to this record signature unsigned OK
Novelty accepted bound to this record signature unsigned OK
unanimous yes (receipt says yes; tamper-evidence only: unsigned) OK
Result: VERIFIED. Every claim in this receipt re-derives from its own record.Now the two moves a dishonest coordinator might make. First, the winner is swapped to the climate initiative (Impact's favourite) and the record is re-hashed honestly, so the hash line passes:
transcript hash claimed 733058d64bb9085c43b870e468a5587d9a80da6fb39e776af67a5c441fc90878
recomputed 733058d64bb9085c43b870e468a5587d9a80da6fb39e776af67a5c441fc90878 OK
status/decision match the finalized record OK
max-min honest NO (recomputed from the recorded verdicts) FAIL
...
Impact accepted NOT bound to this record signature unsigned FAIL
Result: FAILED
- the announced decision is not the max-min option over the recorded verdicts
- Impact: acceptance is not bound to this record and decision
- Budget: acceptance is not bound to this record and decision
- Novelty: acceptance is not bound to this record and decision
- receipt's unanimous_acceptance claim does not match the recomputationSecond, one verdict is edited after the receipt was written (Budget's rejection of the unreviewed option flipped to acceptable) and the hash is left as it was:
transcript hash claimed d08a48ebf62b63f22d5e754cccbc7babcf42b867fc6be9fd1366c4135828b6bc
recomputed 2f0eaae11316761c9c9a9fc58c6fbb78b37ca675a2465ba84fe75c36fcca3084 FAIL
...
Result: FAILED
- transcript hash mismatch: the record was edited after the receipt was written
- the announced decision is not the max-min option over the recorded verdicts
- Impact: acceptance is not bound to this record and decision
- Budget: acceptance is not bound to this record and decision
- Novelty: acceptance is not bound to this record and decision
- receipt's unanimous_acceptance claim does not match the recomputationBoth tampered runs exit 1; the clean run exits 0. In the second case the edited record would also make the unreviewed option the max-min winner, which is why the max-min line fails too.
What the verifier re-derives
| Hash | Rebuilds the transcript from the receipt and re-hashes it. A mismatch means the record was edited after the receipt was written. |
|---|---|
| Status and decision | The receipt's status and decision equal the finalized result inside the transcript, and a deadlock carries no decision. |
| Max-min | Recomputes the winner from the recorded verdicts. Every entry must carry exactly one verdict from each listed participant. |
| Roster | The participants list equals the set of owners that appear in the transcript. |
| Acceptances | Each one is bound to exactly this hash and this decision, by an owner in the record, with no duplicate names. |
| Signatures | Where a verdict or acceptance carries sig and pubkey_hex, it is checked against that key. Needs the crypto extra; otherwise it is reported as unchecked and the run fails. |
| exit 0 | Every check passed. |
|---|---|
| exit 1 | A check failed, or a signature was present and could not be checked. |
| exit 2 | The file could not be read as a receipt. |
What the check does not prove
The verifier prints this list under every completed check, pass or fail. It is the part of the page to read twice.
- Nothing is signed unless signing was on. By default verdicts and acceptances carry
sig: null. That tier is tamper-evidence only: a hash or decision mismatch is caught, a record fabricated from scratch is not, because every field in it is public. Whoever can rewrite the verdicts can rewrite them to fit a swapped winner and the max-min check will agree. Ed25519 signing exists behind thecryptoextra:examples/run_env.pysigns each bot's verdicts over HTTP, andreal_decision.py --signsigns each acceptance with a fresh key generated by the same process that writes the receipt. - A signature does not say who signed. Each public key is self-attested by the record that carries it; there is no owner to key roster yet. A holder of any key, the coordinator included, can sign under another owner's name. A verified signature proves the content was not altered after signing, not who produced it. See SECURITY.md.
- The roster is coordinator-written. The participants list comes from the same hand as the transcript. The roster check binds the record to the roster you expect only if you compare that list against who you know took part.
- It is the record the coordinator kept, not necessarily the one you saw. Each participant has to compare the hash shown to them at ratification with the recomputed one. Without that out-of-band copy, a consistent rewrite passes.
- No red line is checked here. No sheet is in the receipt or read by the script. Whether your own red lines held is answered only by
verify_non_betrayalon your side, section 3 above. - No freshness. Verdict payloads carry no session id or nonce, so a genuine signed verdict can be replayed into another parley over the same option.
Questions
How can I prove my agent did not betray me without showing anyone my preferences?
Call Transcript.verify_non_betrayal(sheet, decision) with your own PreferenceSheet and the public decision. It re-evaluates your hard constraints against the decision locally and returns True only when every red line held. The sheet is an input and never leaves your process; the transcript itself holds only masked verdicts. In the real-decision flow, parley.ratify.ratify refuses an acceptance that the sheet you submit rejects. It cannot tell whether that sheet is current or complete.
What does the SHA-256 receipt hash actually prove?
That the transcript in front of you hashes to that value. The hash is SHA-256 over canonical JSON of the transcript (sorted keys, the six public verdict fields per party, the finalized result). It catches an edit made after the receipt was written only when the hash was kept by someone other than whoever could edit the transcript, for example each participant writing it down at ratification. It is unsigned, and the coordinator can re-hash a rewritten record.
Can a dishonest coordinator pick a worse option and still produce a valid-looking receipt?
Not one that passes scripts/verify-receipt.py. The script recomputes the max-min winner from the recorded verdicts, independent of the hash, so a swapped winner shows up as "max-min honest NO" even when the hash was recomputed honestly. What the script cannot catch is a coordinator that rewrites the verdicts themselves to fit the swapped winner, because verdicts are unsigned unless the run signed them (the per-process HTTP bots in examples/run_env.py do, with the crypto extra installed). Signed verdicts narrow this only once an owner-to-key roster exists; today the key is self-attested (SECURITY.md).
Does the check need the parley-consensus package, or anything else installed?
The verifier script and the examples are not in the PyPI wheel: clone the repository (or check out the v0.2.0 tag) and install the base package with Python 3.10 or later. The script imports parley.consensus, parley.ratify and parley.transcript. No network, no key. Signature checks additionally need pynacl, installed with pip install "parley-consensus[crypto]". Without it the script still runs, prints a note, and fails any receipt that carries a signature it could not check.
Run one decision, keep the receipt, check it on another machine.
The demo replays pre-computed runs of the same engine, no signup. The repository is Apache-2.0;parley/transcript.py is the hash and the replay, andscripts/verify-receipt.py is the whole verifier.
Back to the overview · Setup: Parley as an MCP tool · Next: how the whole negotiation stays fair · Worked case: a group decision with private limits