Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b9f5042
Added pluto-conformance plan
pinebit Jul 31, 2026
5e70926
Add Rust consumer scaffold and secp256k1 suite for pluto
pinebit Jul 31, 2026
73cb64b
Prefix suite name in failure labels, drop redundant dev-deps
pinebit Jul 31, 2026
7952ce5
Run qbft_hashing suite against pluto
pinebit Jul 31, 2026
dee5f95
Pin unsigned_data_set map-entry divergence as a named test
pinebit Jul 31, 2026
ef4c82d
Correct skip-set comment and drop line-anchored citations
pinebit Jul 31, 2026
c042616
Run bls_threshold suite against pluto
pinebit Jul 31, 2026
00da5cf
Run cluster_hashing suite against pluto
pinebit Jul 31, 2026
f670b89
Document masked timestamp gap in cluster_hashing findings
pinebit Jul 31, 2026
6e0e6bd
Run priority_scoring suite against pluto
pinebit Jul 31, 2026
27bc6c1
Run timer_deadlines suite against pluto
pinebit Jul 31, 2026
48aab75
Pin timer_deadlines default-config divergence and slot invariance
pinebit Jul 31, 2026
858b41d
Run qbft_msg_limits suite against pluto
pinebit Jul 31, 2026
07bd200
Run qbft_decided_resends suite against pluto
pinebit Jul 31, 2026
5313af7
Run parsigex_sender_binding suite against pluto
pinebit Jul 31, 2026
13e6210
Add coverage guard for the Rust pluto consumer
pinebit Aug 1, 2026
596161a
Note the wire_size_enforcement string-matching tradeoff
pinebit Aug 1, 2026
69f5488
Trim libp2p features to those the Rust consumer uses
pinebit Aug 1, 2026
c3e5594
Extend the consumer coverage guard to the Rust side
pinebit Aug 1, 2026
b2a7b8c
Document the pluto consumer's final verdicts
pinebit Aug 1, 2026
515bc64
Record Results row 10 for the coverage guard and docs task
pinebit Aug 1, 2026
6a96c8d
Skip Cargo build output in the spellcheck gate
pinebit Aug 1, 2026
ac81aab
Add proposal_timeout entry to the behaviour ladder
pinebit Aug 1, 2026
1a5a581
Correct review-found comment inaccuracies, sharpen findings
pinebit Aug 1, 2026
f6dfe27
Clarify what the Rust coverage guard checks vs the Go one
pinebit Aug 1, 2026
f58f1e6
Reclassify the default-config timer divergence as ABSENT-OK
pinebit Aug 1, 2026
998f04a
Mark the ladder-entry finding as addressed in its lead
pinebit Aug 1, 2026
5674aca
Add design spec for the pluto-conformance skill
pinebit Aug 3, 2026
d7495a9
Add the pluto-conformance skill
pinebit Aug 3, 2026
b68aa75
Remove the pluto-conformance skill design spec
pinebit Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .claude/skills/pluto-conformance/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
name: pluto-conformance
description: Use when validating a pluto commit (or the latest pluto) against this repo's published conformance test vectors, when asked whether pluto still conforms to the spec, or when re-checking pluto after it upgrades its charon parity anchor.
---

# Pluto Conformance Validation

Run the Rust conformance harness (`consumers/rust/`) against a chosen pluto commit,
triage every divergence, and report a per-suite verdict. This skill is self-contained:
everything needed to run and triage is below — no other document is required.

**Scope: re-validate only.** The harness must already exist in the repo. If it does not,
stop and point at git history. If it no longer compiles against the chosen pluto,
that is an **API-drift finding** to report — never fix the harness, never weaken an
assertion, never modify pluto.

## Workflow

1. **Run the suites** (handles checkout/restore safely — do not improvise git commands
against the pluto checkout):

```bash
.claude/skills/pluto-conformance/scripts/run_suites.sh <commit-ish|latest> [output-dir]
```

If the user names no commit, pass `latest`. The script aborts on a dirty pluto
tree, puts `../pluto` on a detached checkout of the requested commit, runs one
compile plus every suite, writes per-suite logs and `summary.tsv`
(`suite <TAB> pass|fail|build-failed <TAB> log`), and restores the original pluto
ref on exit — even on interrupt. Exit 0 means the run completed (test failures
are data); a non-zero exit means an infrastructure error to relay verbatim.

2. **Compare against the baseline** (table below). At the baseline pluto commit
`67088a2`, **every harness test is green** — known divergences are encoded as
*pinned known-divergence tests* that pass while the divergence persists. Therefore:
- All suites `pass` → every suite keeps its baseline verdict. Go to step 4.
- Any suite `fail` or `build-failed` → pluto changed relative to the baseline
(a pin flipped, new drift, or a regression). Go to step 3.

3. **Triage in parallel**: dispatch one sub-agent per red suite, all in a single
message so they run concurrently, using the brief template below. Never triage by
weakening the assertion; classify per the taxonomy and record.

4. **Report**: write `reports/pluto-conformance-<sha7>-<YYYY-MM-DD>.md` (the
`reports/` dir is gitignored — local artifact, never committed) and print the same
verdict table in chat. Include: pluto SHA tested, per-suite verdict + notes,
findings (divergence, pluto file:line, ladder cover or not), and pin-flip callouts.

## Verdict taxonomy

A conformance check produces a **verdict**; a red case is a finding to record, never a
reason to change the test.

| Verdict | Meaning |
| --- | --- |
| `PASS` | Pluto reproduces every case in the group. |
| `FAIL` | Pluto diverges and no ladder entry excuses it — a real finding; consider reporting upstream to pluto. |
| `ABSENT-OK` | Divergence matches a `charon_anchor.json` → `behaviours` entry with `first_charon_release` > v1.7.1 (or `null`). The harness then **pins pluto's current behaviour** with a comment naming the ladder entry, so it flips loudly when pluto catches up. |
| `UNREACHABLE` | No public API path from an external crate; verified by code inspection with file:line recorded. A coverage gap, not a pass. |

## Ladder protocol

- Pluto pins parity to **charon v1.7.1**; the vectors describe charon main at the
anchor commit recorded in `charon_anchor.json` (repo root).
- `charon_anchor.json` → `behaviours` lists specified behaviours that postdate v1.7.1.
An entry with `first_charon_release` `null` (unreleased) or a release newer than
v1.7.1 excuses a matching divergence as ABSENT-OK.
- If pluto moves its parity anchor past v1.7.1, ladder entries at or below the new
anchor stop excusing divergences: the corresponding pinned tests should flip, and
each flip is re-triaged (usually pluto catching up — flip the pin to the strict
spec assertion; that is the one sanctioned kind of test change).

## Baseline: pluto `67088a2`, all harness tests green

| Suite | Baseline verdict | Pins and notes (what a flip means) |
| --- | --- | --- |
| `secp256k1_signatures` | PASS | Sign / recover / verify_65 via `pluto-k1util`. |
| `qbft_hashing` | FAIL (unsigned_data_set only) | 3 of 4 groups clean. Pin `unsigned_data_set_known_divergence_empty_map_entry_fields`: prost omits default-valued map-entry fields (empty key / empty value), charon's Go marshaler writes both — real interop FAIL, no ladder cover. Pin flip = pluto's encoding changed. |
| `bls_threshold` | PASS | Keys, partials, threshold aggregates, secret recovery, plain-aggregate non-verification. |
| `cluster_hashing` | FAIL (2 pinned cases) | Pins `definition_known_divergence_null_operators_and_validators` and `lock_known_divergence_missing_partial_deposit_data`: missing `#[serde(default)]` in pluto's `Definition`/`Lock` (rejects `null` operators/validators, missing `partial_deposit_data`; a masked `timestamp` blocker sits behind the first) — real FAIL, no ladder cover. `real_keys_3_of_4` full `verify_signatures` is asserted to stop at the EIP-712 stage (placeholder sigs) — coverage gap, documented not mocked. |
| `priority_scoring` | PASS | 18/18 end-to-end through `Prioritiser` over in-process libp2p. Pin: `PROTOCOL_ID == "charon/priority/2.0.0"` (legacy slash-less only; ladder "Preferred priority protocol ID", `null`). Flip = pluto added the preferred slash ID. |
| `timer_deadlines` | round_timeout PASS only with `Feature::ProposalTimeout` enabled; deadline ABSENT-OK; duty_start_delay UNREACHABLE | Default-config divergence pinned (`round_timeouts_pluto_default_feature_set`): proposer round-1 gives 1s not 1.5s — ABSENT-OK, ladder "Extended 1.5s proposer round-1 timeout (proposal_timeout)" (v1.9.0). Slot-invariance code pin (`round_timeout_is_duty_slot_invariant`) guards against timers growing slot dependence; deadline determinism is ladder "Deterministic (genesis-derived) eager double linear round deadlines" (v1.9.0). `delay_slot_offset` (`crates/core/src/scheduler.rs`) private → UNREACHABLE. |
| `qbft_msg_limits` | counts ABSENT-OK both directions; wire_size PASS | Pluto has neither the spec's `2n` justification cap nor any values cap; its own cap is `4 * nodes` (`MAX_JUSTIFICATIONS_PER_NODE = 4`, `crates/consensus/src/qbft/component.rs`) — pinned at boundary (`4n` accepted, `4n+1` rejects with `TooManyJustifications`). Ladder "QBFT DECIDED-resend rate limit and message size/count limits" (`null`). Wire size: 32 MiB `MAX_CONSENSUS_MSG_SIZE` enforced via `read_protobuf_with_max_size` — PASS. |
| `qbft_decided_resends` | ABSENT-OK | Pin reading holds: pluto rebroadcasts DECIDED on **every** post-decision ROUND-CHANGE from another source — no 16-per-source cap, no increasing-round dedup (`crates/core/src/qbft/mod.rs`). Same `null` ladder entry. Flip = pluto added a limiter → re-triage against the spec's expectations. |
| `parsigex_sender_binding` | cases ABSENT-OK; peer_map UNREACHABLE | `new_eth2_verifier`'s closure has no sender parameter — 2/6 cases pinned divergent (ladder "Sender-bound share indices in the DKG lock-hash exchange", `null`). Peer-map validation exists only in private code (`crates/dkg/src/frostp2p/transport.rs::validate_peer_share_indices`) or is position-derived (`Peer::share_idx`) → UNREACHABLE coverage gap (predates v1.7.1, no ladder cover). |
| `coverage` | — | Guard: published `test_vectors/*.json` must equal the covered-suite table. A fail here means a suite was added/removed — the harness needs a new test (out of this skill's scope; report it). |

## Known FAIL-class findings (recognize, don't re-derive)

1. **prost map-entry default-value omission** (`qbft_hashing`): pluto skips an empty
string key / empty bytes value inside `UnsignedDataSet` map entries; charon always
writes both. Reachable only via malformed/malicious wire input, but a real
cross-implementation hash divergence.
2. **Missing `#[serde(default)]`** (`cluster_hashing`): pluto rejects verbatim
charon artifacts with `null` operators/validators, an omitted
`partial_deposit_data`, or an omitted `timestamp`. Real interop risk for tools
handing pluto charon-produced JSON.

If a triage agent rediscovers one of these, it is the known finding, not news —
unless the pinned bytes/error changed, which is a pin flip.

## Triage sub-agent brief (template)

Dispatch one per red suite, in parallel:

> Triage a conformance divergence. Suite: `<suite>`. Pluto commit under test: `<sha>`
> (checkout at `../pluto` — read-only; never modify it). Captured test output:
> `<log path>`. Read the failing test in `consumers/rust/tests/<suite>.rs` to see
> what is asserted and which pins exist.
>
> Classify each failing case using this taxonomy: PASS / FAIL (no excuse) /
> ABSENT-OK (matches a `charon_anchor.json` → `behaviours` entry with
> `first_charon_release` > v1.7.1 or `null` — name the entry) / UNREACHABLE
> (no public API path; give file:line). Baseline expectation for this suite:
> `<baseline row>`. A failing *pinned known-divergence* test means pluto's behaviour
> CHANGED — describe the old pinned behaviour, the new behaviour, and whether pluto
> now matches the spec (likely caught up; ladder entry satisfied) or diverges a new
> way. Never propose weakening an assertion. Return: per-case verdict + one-paragraph
> note with pluto file:line evidence.

## Report format

```markdown
# Pluto conformance — <sha> — <date>

Pluto commit under test: `<full sha>` (requested: `<commit-ish arg>`)
Harness: consumers/rust @ <spec repo HEAD sha>

| Suite | Verdict | Notes |
| --- | --- | --- |
| ... one row per suite, plan-Results style ... |

## Findings
- <divergence, pluto file:line, ladder cover or not, new vs known>

## Pin flips
- <pinned test that changed behaviour, and what it means>
```

## Red flags — never do these

- Editing a test to make a red case green (the one exception: flipping a pin to the
strict spec assertion after verifying pluto genuinely caught up — and only when the
user asks for the fix, not during validation).
- Modifying anything in the pluto checkout, or leaving it on the tested commit
(the script restores it; verify with `git -C ../pluto status` if a run was killed).
- Extrapolating verdicts from a partial run — report incomplete as incomplete.
- Committing `reports/` output or any changes made during validation.
94 changes: 94 additions & 0 deletions .claude/skills/pluto-conformance/scripts/run_suites.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Run the spec's Rust conformance harness against a chosen pluto commit.
#
# Usage: run_suites.sh <commit-ish|latest> [output-dir]
#
# commit-ish Any pluto commit, tag, or branch. "latest" means the tip of
# origin's default branch after a fetch.
# output-dir Where per-suite logs and summary.tsv land (default: mktemp -d).
#
# The pluto checkout (sibling of this repo, ../pluto) is put on a detached
# checkout of the requested commit for the duration of the run and restored
# unconditionally on exit, including on interrupt. A dirty pluto tree aborts
# before anything is touched.
#
# Exit codes: 0 = run completed (test failures are data, see summary.tsv);
# non-zero = infrastructure error (dirty tree, bad commit, missing dirs).
#
# summary.tsv columns: suite <TAB> pass|fail|build-failed <TAB> log-path
# The resolved pluto SHA is written to pluto_sha.txt and echoed.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# Not configurable: the harness Cargo.toml hard-codes path dependencies to this
# sibling checkout, so testing any other location would silently compile the wrong code.
PLUTO_DIR="$(cd "$REPO_ROOT/.." && pwd)/pluto"
HARNESS_DIR="$REPO_ROOT/consumers/rust"

TARGET="${1:?usage: run_suites.sh <commit-ish|latest> [output-dir]}"
OUT_DIR="${2:-$(mktemp -d)}"
mkdir -p "$OUT_DIR"

fail() { echo "error: $*" >&2; exit 1; }

[ -e "$PLUTO_DIR/.git" ] || fail "pluto checkout not found at $PLUTO_DIR"
[ -f "$HARNESS_DIR/Cargo.toml" ] || fail "harness not found at $HARNESS_DIR (re-validate-only skill; see git history)"
command -v cargo >/dev/null || fail "cargo not on PATH"

# Never touch a dirty pluto tree.
if [ -n "$(git -C "$PLUTO_DIR" status --porcelain)" ]; then
fail "pluto working tree at $PLUTO_DIR is dirty; commit or stash it yourself first"
fi

# Record where pluto is now (branch name, or SHA if already detached),
# and restore it no matter how this script exits.
ORIG_REF="$(git -C "$PLUTO_DIR" symbolic-ref -q --short HEAD || git -C "$PLUTO_DIR" rev-parse HEAD)"
restore() { git -C "$PLUTO_DIR" checkout -q "$ORIG_REF" || echo "warning: failed to restore pluto to $ORIG_REF" >&2; }
trap restore EXIT

git -C "$PLUTO_DIR" fetch -q origin

if [ "$TARGET" = "latest" ]; then
DEFAULT_BRANCH="$(git -C "$PLUTO_DIR" symbolic-ref -q --short refs/remotes/origin/HEAD || true)"
if [ -z "$DEFAULT_BRANCH" ]; then
git -C "$PLUTO_DIR" remote set-head origin -a >/dev/null
DEFAULT_BRANCH="$(git -C "$PLUTO_DIR" symbolic-ref --short refs/remotes/origin/HEAD)"
fi
TARGET="$DEFAULT_BRANCH"
fi

SHA="$(git -C "$PLUTO_DIR" rev-parse --verify "${TARGET}^{commit}")" \
|| fail "cannot resolve pluto commit-ish '$TARGET'"
git -C "$PLUTO_DIR" checkout -q --detach "$SHA"
echo "$SHA" > "$OUT_DIR/pluto_sha.txt"
echo "pluto under test: $SHA"

SUMMARY="$OUT_DIR/summary.tsv"
: > "$SUMMARY"

# One compile for everything; a build failure against the new pluto is an
# API-drift finding covering every suite, not something to fix here.
if ! (cd "$HARNESS_DIR" && cargo test --no-run) > "$OUT_DIR/build.log" 2>&1; then
for t in "$HARNESS_DIR"/tests/*.rs; do
printf '%s\tbuild-failed\t%s\n' "$(basename "$t" .rs)" "$OUT_DIR/build.log" >> "$SUMMARY"
done
echo "build failed against $SHA — see $OUT_DIR/build.log"
cat "$SUMMARY"
exit 0
fi

for t in "$HARNESS_DIR"/tests/*.rs; do
suite="$(basename "$t" .rs)"
log="$OUT_DIR/$suite.log"
if (cd "$HARNESS_DIR" && cargo test --test "$suite") > "$log" 2>&1; then
status=pass
else
status=fail
fi
printf '%s\t%s\t%s\n' "$suite" "$status" "$log" >> "$SUMMARY"
done

echo "summary: $SUMMARY"
cat "$SUMMARY"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,5 @@ site/
# Ruff
.ruff_cache/

# Pluto conformance validation reports (local artifacts, see .claude/skills/pluto-conformance)
/reports/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ targets `v1.7.1`): some specified behaviors landed after `v1.7.1`:
| ------------------------------------------------------------------ | -------------------- |
| `MsgSync.nickname` field (DKG sync) | `v1.9.0` |
| Deterministic (genesis-derived) eager double linear round deadlines | `v1.9.0` |
| Extended 1.5s proposer round-1 timeout (`proposal_timeout`) | `v1.9.0` |
| Linear round timer subsequent-round timeout fix | unreleased (`v1.11.0` RCs) |
| QBFT DECIDED-resend rate limit and message size/count limits | unreleased (`v1.11.0` RCs) |
| Preferred priority protocol ID `/charon/priority/2.0.0` | unreleased (`main`) |
Expand Down
6 changes: 6 additions & 0 deletions charon_anchor.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@
"first_charon_release": "v1.9.0",
"spec": "docs/dv-spec/consensus.md"
},
{
"name": "Extended 1.5s proposer round-1 timeout (proposal_timeout)",
"first_charon_release": "v1.9.0",
"spec": "docs/dv-spec/consensus.md",
"note": "The behaviour itself is older (charon #3739) but sat behind the alpha proposal_timeout feature flag; v1.9.0 (charon #4296) enabled it by default. The timer_deadlines vectors assume it is enabled."
},
{
"name": "Linear round timer subsequent-round timeout fix",
"first_charon_release": null,
Expand Down
44 changes: 43 additions & 1 deletion consumers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ executes them: a suite nobody runs is a document, not a test.
| Consumer | Status | Location |
| --------------------------------- | ---------------------------------------------- | ----------- |
| Charon (Go) | All 9 suites, 314 subtests, verified at anchor `6054bcb2` | [`go/`](go) |
| Pluto (Rust) | Not written yet | — |
| Pluto (Rust) | All 9 suites checked against pluto `67088a2` — 2 FAIL (`qbft_hashing`, `cluster_hashing`), rest PASS, ABSENT-OK, or UNREACHABLE; see plans/pluto-conformance.md | [rust/](rust) |

These files live here rather than in Charon because this repository cannot merge
into Charon. They are laid out to mirror Charon's own tree, so placing them is a
Expand Down Expand Up @@ -81,3 +81,45 @@ produced by Charon and then published, so a failure means Charon has moved away
from the protocol every other implementation was told to implement. The
regeneration path is deliberately not automatic — see the spec's
`test_vectors/README.md`.

## Pluto consumer

`rust/` is a standalone Cargo package that path-depends on a pluto checkout
placed as a sibling of this repository (`../pluto` relative to
`distributed-validator-specs/`, i.e. `../../../pluto` from `consumers/rust/`).
Unlike the Go consumer, nothing is copied into pluto and pluto is never
modified — the harness reads pluto's crates in place.

### Running it

```bash
# pluto checked out as a sibling of this repo (default: ../pluto)
cd consumers/rust && cargo test
```

Vectors are read from `../../test_vectors/` at test time (no vendoring), or
from `SPEC_VECTORS_DIR` when set (same variable as the Go consumer), for
running against an unreleased build without moving files around.

### Verdicts

All nine published suites were run against pluto commit `67088a2`. This is a
snapshot of one pluto commit, not a continuously tracked target: `PASS` means
pluto reproduced every case the vector covers at that commit; `ABSENT-OK` and
`UNREACHABLE` are not passes — they record, respectively, a divergence excused
by pluto's charon-v1.7.1 parity pin, and a coverage gap where no public pluto
API reaches the behaviour at all. Full per-case detail, findings, and pluto
file:line citations are in `plans/pluto-conformance.md`'s Results table and
Findings section.

| Suite | Verdict |
| --- | --- |
| `secp256k1_signatures` | PASS (2/2) |
| `qbft_hashing` | **FAIL** — 23/25 cases agree; 2 pinned divergences (prost omits default-valued protobuf map-entry key/value fields that charon's Go marshaler always emits) |
| `bls_threshold` | PASS (all 5 groups) |
| `cluster_hashing` | **FAIL** — pluto rejects charon-legitimate JSON shapes (`operators: null`, an absent `partial_deposit_data`, and a masked third gap on `timestamp`) |
| `priority_scoring` | PASS (18/18) |
| `timer_deadlines` | `round_timeout_nanos` PASS **only with `ProposalTimeout` explicitly enabled** (9 PROPOSER/round-1 cases diverge under pluto's default config — ABSENT-OK, ladder entry "Extended 1.5s proposer round-1 timeout (proposal_timeout)"); `deadline_nanos` ABSENT-OK; `duty_start_delay_nanos` UNREACHABLE |
| `qbft_msg_limits` | `counts`: 5 MATCH + 6 ABSENT-OK; `wire_size`: 4/4 MATCH |
| `qbft_decided_resends` | ABSENT-OK (pluto has no DECIDED-rebroadcast limiter) |
| `parsigex_sender_binding` | `cases` ABSENT-OK; `peer_map` UNREACHABLE |
2 changes: 2 additions & 0 deletions consumers/rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/target
Cargo.lock
Loading
Loading