Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,31 @@ jobs:
# work. Both were confirmed to produce the same five files locally.
- name: Pack dry run
run: npm pack ./packages/psyche-npm --dry-run

protocol:
name: Protocol artifacts (Node 22)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# Regenerating must reproduce every committed artifact byte-for-byte.
# Any drift — a hand-edited schema, vector, manifest, or type file —
# fails here instead of silently diverging from
# scripts/protocol/definitions.mjs.
- name: Regenerate protocol artifacts
run: node scripts/protocol/generate.mjs --check
# The runner is dependency-free, so like the npm distribution job there
# is nothing to install before testing.
- name: Conformance runner unit tests
run: npm --prefix packages/psyche-protocol test
# The runner consumes the published artifact set end to end: schema
# validation of every golden vector plus the consumer-v1 semantic
# profile. See docs/PROTOCOL.md section 5.
- name: Conformance against published artifacts
run: node packages/psyche-protocol/bin/psyche-conformance.js run --root protocol/v1
# Downstream pinning contract: the checksummed manifest must match the
# committed tree exactly.
- name: Verify artifact manifest
run: node packages/psyche-protocol/bin/psyche-conformance.js verify --root protocol/v1
239 changes: 239 additions & 0 deletions crates/psyche-core/tests/protocol_golden.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
//! Cross-checks the published protocol v1 golden vectors against the real
//! canonical decoder: every positive vector must decode, re-canonicalize to
//! exactly the published bytes, and every negative vector must be denied with
//! the class the artifact set promises. This is the Rust-side half of the
//! drift gate described in docs/PROTOCOL.md section 6.
#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)]

use psyche_core::contracts::{CanonicalDocument, ContractError, decode_document};
use psyche_core::digest::canonical_bytes;

const PROTOCOL_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../protocol/v1/golden");

/// What the registry decoder should do with a positive vector's identity.
#[derive(Debug, PartialEq, Eq)]
enum IdExpectation {
/// A persistable registry record whose id carries this prefix.
Prefix(&'static str),
/// Decodes as a registry document but never persists (`psyche.error.v1`).
NotPersistable,
/// Store-owned and not a registry document at all (`psyche.transition`):
/// the decoder must deny it for want of a schema_version.
NotADocument,
}

/// (vector file, identity expectation). The Coven boundary types are pinned
/// by crates/psyche-coven and do not appear here.
const POSITIVE_VECTORS: &[(&str, IdExpectation)] = &[
(
"positive/identity-snapshot.json",
IdExpectation::Prefix("ids_"),
),
("positive/intent.json", IdExpectation::Prefix("int_")),
("positive/surface-event.json", IdExpectation::Prefix("sev_")),
("positive/graph.json", IdExpectation::Prefix("grf_")),
("positive/graph-node.json", IdExpectation::Prefix("nod_")),
("positive/delegation.json", IdExpectation::Prefix("dlg_")),
("positive/budget.json", IdExpectation::Prefix("bud_")),
("positive/approval.json", IdExpectation::Prefix("apr_")),
(
"positive/execution-binding-revision-1.json",
IdExpectation::Prefix("att_"),
),
(
"positive/execution-binding-acknowledged.json",
IdExpectation::Prefix("att_"),
),
("positive/evidence.json", IdExpectation::Prefix("evd_")),
("positive/verdict.json", IdExpectation::Prefix("vrd_")),
("positive/recovery.json", IdExpectation::Prefix("rcv_")),
("positive/addon.json", IdExpectation::Prefix("adn_")),
(
"positive/surface-effect.json",
IdExpectation::Prefix("sfx_"),
),
(
"positive/delivery-ready.json",
IdExpectation::Prefix("del_"),
),
(
"positive/error-storage-unavailable.json",
IdExpectation::NotPersistable,
),
("positive/transition.json", IdExpectation::NotADocument),
(
"positive/crash-restart/revision-1.json",
IdExpectation::Prefix("att_"),
),
(
"positive/crash-restart/revision-2.json",
IdExpectation::Prefix("att_"),
),
];

/// (vector file, expected classification). Classifications follow
/// `ContractError` exactly as `RejectedDocument::from_decode_error` maps them
/// onto `RejectionReason`; `psyche.error.v1` decodes exhaustively, so an
/// unknown envelope field is an `InvalidShape`, not an unknown-enum one.
const NEGATIVE_VECTORS: &[(&str, RejectionClass)] = &[
(
"negative/denial-unknown-kind.json",
RejectionClass::UnknownSchema,
),
(
"negative/denial-unknown-enum.json",
RejectionClass::UnknownEnumValue,
),
(
"negative/denial-unknown-field.json",
RejectionClass::InvalidShape,
),
(
"negative/denial-unknown-code.json",
RejectionClass::UnknownEnumValue,
),
(
"negative/stale-correlation-ack-outside-window.json",
RejectionClass::InvalidShape,
),
(
"negative/stale-correlation-expired-request.json",
RejectionClass::InvalidShape,
),
(
"negative/stale-correlation-wrong-session.json",
RejectionClass::InvalidShape,
),
(
"negative/unknown-version-intent-v2.json",
RejectionClass::UnsupportedMajor,
),
(
"negative/downgrade-major-graph-v2.json",
RejectionClass::UnsupportedMajor,
),
];

#[derive(Debug, PartialEq, Eq)]
enum RejectionClass {
UnknownSchema,
UnsupportedMajor,
UnknownEnumValue,
InvalidShape,
}

fn vector_bytes(relative: &str) -> Vec<u8> {
std::fs::read(format!("{PROTOCOL_ROOT}/{relative}"))
.unwrap_or_else(|error| panic!("cannot read golden vector {relative}: {error}"))
}

#[test]
fn every_published_positive_vector_decodes_and_recanonicalizes_to_the_published_bytes() {
for (file, expectation) in POSITIVE_VECTORS {
let bytes = vector_bytes(file);
// `psyche.transition` is store-owned and not a registry document: it
// has no schema_version of its own, so the registry decoder must deny
// it even though its canonical bytes are published. Everything else
// must decode through the real registry decoder.
let document = match expectation {
IdExpectation::NotADocument => {
assert!(
decode_document(&bytes).is_err(),
"{file}: store-owned records are not registry documents"
);
None
}
_ => Some(decode_document(&bytes).unwrap_or_else(|error| {
panic!("{file} must decode as a positive vector: {error}")
})),
};
let Some(document) = document.as_ref() else {
continue;
};
if let IdExpectation::Prefix(expected_prefix) = expectation {
match document.persistable_record_id() {
Some(id) => assert!(
id.as_str().starts_with(expected_prefix),
"{file}: record id {} lacks the {expected_prefix} prefix",
id.as_str(),
),
None => panic!("{file}: decoded without a durable record id"),
}
} else {
// IdExpectation::NotPersistable: psyche.error.v1 decodes
// exhaustively but never persists, so it carries no record id.
assert!(
document.persistable_record_id().is_none(),
"{file}: non-persistable document carries a record id"
);
}
// Byte-exactness: the published file must be the canonical rendering
// of the decoded document, proving byte parity between the JavaScript
// canonicalizer that produced the artifact set and the Rust one.
let recanonical = canonical_bytes(document).unwrap_or_else(|error| {
panic!("{file}: decoded document failed canonicalization: {error}")
});
assert_eq!(
recanonical, bytes,
"{file}: canonical bytes drifted from the published artifact"
);
}
}

#[test]
fn every_published_negative_vector_is_denied_with_the_promised_classification() {
for (file, expected) in NEGATIVE_VECTORS {
let bytes = vector_bytes(file);
let error = match decode_document(&bytes) {
Ok(_) => panic!("{file} is a negative vector and must be denied"),
Err(error) => error,
};
let classified = match error {
ContractError::UnknownSchema => RejectionClass::UnknownSchema,
ContractError::UnsupportedMajor { .. } => RejectionClass::UnsupportedMajor,
ContractError::UnknownEnumValue { .. } => RejectionClass::UnknownEnumValue,
ContractError::InvalidShape { .. }
| ContractError::CancellationEvidenceMismatch
| ContractError::WrongRecordPrefix { .. }
| ContractError::MalformedIdentifier
| ContractError::InvalidUlid
| ContractError::UnsupportedDigestPrefix
| ContractError::MalformedDigest
| ContractError::CanonicalizationFailed
| ContractError::NonInteroperableNumber
| ContractError::SchemaMismatch { .. }
| ContractError::WrongRecordKind { .. }
| ContractError::DigestMismatch { .. }
| ContractError::DocumentTooLarge => RejectionClass::InvalidShape,
};
assert_eq!(&classified, expected, "{file}: unexpected classification");
}
}

#[test]
fn the_crash_restart_chain_binds_revision_2_to_revision_1_canonical_bytes() {
let revision1 = vector_bytes("positive/crash-restart/revision-1.json");
let revision2 = vector_bytes("positive/crash-restart/revision-2.json");
let first = decode_document(&revision1).unwrap();
let second = decode_document(&revision2).unwrap();

let claimed_previous = match &second {
CanonicalDocument::ExecutionBinding(binding) => binding
.previous_revision_digest
.as_ref()
.expect("revision 2 binds a previous digest"),
other => panic!("expected an execution binding, got {other:?}"),
};
// The store binds previous_revision_digest to the previous revision's
// canonical digest (crates/psyche-store/src/execution_bindings.rs), which
// equals the digest over revision 1's published canonical bytes.
let expected_previous = psyche_core::digest::digest(&first)
.unwrap_or_else(|error| panic!("revision 1 digest failed: {error}"));
assert_eq!(
claimed_previous.as_str(),
expected_previous.as_str(),
"revision 2 must bind revision 1's canonical bytes"
);
assert_eq!(canonical_bytes(&first).unwrap(), revision1);
assert_eq!(canonical_bytes(&second).unwrap(), revision2);
}
Loading
Loading