diff --git a/crates/psyche-core/tests/fixtures/reference-client/profile.v1.json b/crates/psyche-core/tests/fixtures/reference-client/profile.v1.json new file mode 100644 index 0000000..e4c8ed6 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/profile.v1.json @@ -0,0 +1,153 @@ +{ + "profile": "psyche.reference-client.profile.v1", + "title": "Psyche reference-client conformance profile", + "issue": "https://github.com/OpenCoven/psyche/issues/13", + "protocol": { + "registry": "psyche.schema.registry.v1", + "supported_major": 1, + "kinds": [ + "identity_snapshot", + "intent", + "surface_event", + "graph", + "graph_node", + "delegation", + "budget", + "approval", + "execution_binding", + "evidence", + "verdict", + "recovery", + "addon", + "surface_effect", + "delivery", + "error" + ] + }, + "vectors": [ + { + "id": "positive-intent", + "file": "vectors/positive-intent.json", + "digest": "sha256:524e359a0fbacd053eef01a2da0d62363d8053c8457516db14f66c44d41cd4ed", + "class": "positive", + "kind": "intent", + "persistable": true + }, + { + "id": "positive-node", + "file": "vectors/positive-node.json", + "digest": "sha256:8db89dedb1ab2caf3e5362c410a41aade47dff5bfd8fb1d9f1b0339a7162a243", + "class": "positive", + "kind": "graph_node", + "persistable": true + }, + { + "id": "positive-surface-event", + "file": "vectors/positive-surface-event.json", + "digest": "sha256:d445b2a5cf1058461bb82bcc7c2e97f265b7c019d99a3f1d99a7a93b9f0b97df", + "class": "positive", + "kind": "surface_event", + "persistable": true + }, + { + "id": "positive-surface-effect", + "file": "vectors/positive-surface-effect.json", + "digest": "sha256:7acb7f0b214dd2a8fefd618f3b53e61c76475c7cf84e689d80a22a12b7c9a474", + "class": "positive", + "kind": "surface_effect", + "persistable": true + }, + { + "id": "positive-delivery", + "file": "vectors/positive-delivery.json", + "digest": "sha256:a716800e3437a1d09ba36a24ef1da693fd7c787e2a346ab25af155e038c7f3ae", + "class": "positive", + "kind": "delivery", + "persistable": true + }, + { + "id": "positive-error-envelope", + "file": "vectors/positive-error-envelope.json", + "digest": "sha256:d315330f8dc6756c2e0b194ddfe787c40eacbdb0f063d0ebffaa55e64056fef2", + "class": "positive", + "kind": "error", + "persistable": false + }, + { + "id": "denial-downgrade-major-v0", + "file": "vectors/denial-downgrade-major-v0.json", + "digest": "sha256:b0113ac62eba9d20d18576fac59ac985d20963d07e1954d965f8e93e77b04289", + "class": "denial", + "reason": "unsupported_major", + "major": 0, + "persistable": false + }, + { + "id": "denial-unknown-major-v2", + "file": "vectors/denial-unknown-major-v2.json", + "digest": "sha256:d62129f736a34cdeca3913c5dc7196f3592f6a4e4d3383b0f87b4e0ce88d9aa6", + "class": "denial", + "reason": "unsupported_major", + "major": 2, + "persistable": false + }, + { + "id": "denial-unknown-kind", + "file": "vectors/denial-unknown-kind.json", + "digest": "sha256:e9b0856ad6892d252159e78c36d0589a4215d158565e1ae7a7900365687d0ad3", + "class": "denial", + "reason": "unknown_schema", + "persistable": false + }, + { + "id": "denial-widened-intent", + "file": "vectors/denial-widened-intent.json", + "digest": "sha256:ef9dd00b638121d2610a08569d60a4890c45932c2ca85896412f75eb0ef33434", + "class": "denial", + "reason": "invalid_shape", + "schema": "intent", + "field": "document", + "persistable": false + }, + { + "id": "denial-unknown-enum-node-state", + "file": "vectors/denial-unknown-enum-node-state.json", + "digest": "sha256:1423377ce3cabb0c117788ad33dc31216403a54f64022a4a98a0dcb324a0e9ca", + "class": "denial", + "reason": "unknown_enum_value", + "schema": "graph_node", + "field": "state", + "persistable": false + }, + { + "id": "denial-unknown-error-code", + "file": "vectors/denial-unknown-error-code.json", + "digest": "sha256:d0bda0cd41ad25437716a2af0080eaf088d67e732cd3a974f2ac50b6e3b44fa0", + "class": "denial", + "reason": "unknown_enum_value", + "schema": "error", + "field": "code", + "persistable": false + }, + { + "id": "denial-stale-effect-digest", + "file": "vectors/denial-stale-effect-digest.json", + "digest": "sha256:a30e46a816e345b7b721aff1b272ed55780705b22efad7350da0d16bc0886368", + "class": "denial", + "reason": "invalid_shape", + "schema": "delivery", + "field": "effect_digest", + "persistable": false + }, + { + "id": "denial-ambiguous-duplicate-field", + "file": "vectors/denial-ambiguous-duplicate-field.json", + "digest": "sha256:0bd053b48f59b0da2ce7df4794437729bfa5683b355efa6c2b746a97c9ad4053", + "class": "denial", + "reason": "invalid_shape", + "schema": "error", + "field": "json", + "persistable": false + } + ] +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-ambiguous-duplicate-field.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-ambiguous-duplicate-field.json new file mode 100644 index 0000000..451ad87 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-ambiguous-duplicate-field.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.intent.v1", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-downgrade-major-v0.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-downgrade-major-v0.json new file mode 100644 index 0000000..200312e --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-downgrade-major-v0.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.intent.v0", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-stale-effect-digest.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-stale-effect-digest.json new file mode 100644 index 0000000..38d4254 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-stale-effect-digest.json @@ -0,0 +1,32 @@ +{ + "schema_version": "psyche.delivery.v1", + "delivery_id": "del_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "intent_id": "int_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "action_class": "telegram.reply.send", + "account_id": "main", + "chat_id": "-1001234567890", + "topic": {"kind": "forum", "id": "42"}, + "relationship": "reply_same_topic", + "effect": { + "schema_version": "psyche.telegram_effect.v1", + "type": "send_message", + "format": "html", + "text": "Review complete!!", + "reply_to_message_id": "314", + "buttons": [], + "link_preview": {"enabled": true} + }, + "effect_digest": "sha256:26fae759f51eafdfa1277616327d942068948182fb0aaa6b34bbedb0a8ca7dc7", + "surface_decision": { + "decision_id": "decision_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "request_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "policy_revision": "policy:sha256:0123456789abcdef", + "expires_at": "2026-08-01T00:05:00Z", + "state": "reserved" + }, + "logical_response_id": "response_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "logical_part": 0, + "state": "ready", + "attempt_count": 0, + "telegram_message_id": null +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-enum-node-state.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-enum-node-state.json new file mode 100644 index 0000000..93619e3 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-enum-node-state.json @@ -0,0 +1,12 @@ +{ + "schema_version": "psyche.graph_node.v1", + "node_id": "nod_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "graph_id": "grf_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "familiar_snapshot_id": "ids_01C3F7YQ4R2M8N6P5K1J9H0GTS", + "dependencies": [], + "delegation_id": null, + "budget_id": "bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV", + "required_evidence": ["tests", "diff_review"], + "state": "done", + "version": 1 +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-error-code.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-error-code.json new file mode 100644 index 0000000..db790fb --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-error-code.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_broken", + "message": "Storage is temporarily unavailable.", + "retryable": true, + "correlation_id": "corr-storage-1", + "details": { + "component": "sqlite", + "operation": "write" + } + } +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-kind.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-kind.json new file mode 100644 index 0000000..1cb876d --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-kind.json @@ -0,0 +1,5 @@ +{ + "schema_version": "psyche.task.v1", + "task_id": "task-7f3a", + "cockpit_pane_id": "pane-1" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-major-v2.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-major-v2.json new file mode 100644 index 0000000..4e1a4e7 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-major-v2.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.intent.v2", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-widened-intent.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-widened-intent.json new file mode 100644 index 0000000..7bf5649 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/denial-widened-intent.json @@ -0,0 +1,14 @@ +{ + "schema_version": "psyche.intent.v1", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "cockpit_task_id": "task-7f3a", + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-delivery.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-delivery.json new file mode 100644 index 0000000..9e97e95 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-delivery.json @@ -0,0 +1,32 @@ +{ + "schema_version": "psyche.delivery.v1", + "delivery_id": "del_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "intent_id": "int_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "action_class": "telegram.reply.send", + "account_id": "main", + "chat_id": "-1001234567890", + "topic": {"kind": "forum", "id": "42"}, + "relationship": "reply_same_topic", + "effect": { + "schema_version": "psyche.telegram_effect.v1", + "type": "send_message", + "format": "html", + "text": "Review complete.", + "reply_to_message_id": "314", + "buttons": [], + "link_preview": {"enabled": true} + }, + "effect_digest": "sha256:26fae759f51eafdfa1277616327d942068948182fb0aaa6b34bbedb0a8ca7dc7", + "surface_decision": { + "decision_id": "decision_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "request_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "policy_revision": "policy:sha256:0123456789abcdef", + "expires_at": "2026-08-01T00:05:00Z", + "state": "reserved" + }, + "logical_response_id": "response_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "logical_part": 0, + "state": "ready", + "attempt_count": 0, + "telegram_message_id": null +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-error-envelope.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-error-envelope.json new file mode 100644 index 0000000..9a76130 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-error-envelope.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_unavailable", + "message": "Storage is temporarily unavailable.", + "retryable": true, + "correlation_id": "corr-storage-1", + "details": { + "component": "sqlite", + "operation": "write" + } + } +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-intent.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-intent.json new file mode 100644 index 0000000..e7d1c74 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-intent.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.intent.v1", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-node.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-node.json new file mode 100644 index 0000000..8fe76b8 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-node.json @@ -0,0 +1,12 @@ +{ + "schema_version": "psyche.graph_node.v1", + "node_id": "nod_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "graph_id": "grf_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "familiar_snapshot_id": "ids_01C3F7YQ4R2M8N6P5K1J9H0GTS", + "dependencies": [], + "delegation_id": null, + "budget_id": "bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV", + "required_evidence": ["tests", "diff_review"], + "state": "ready", + "version": 1 +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-effect.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-effect.json new file mode 100644 index 0000000..da430da --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-effect.json @@ -0,0 +1,16 @@ +{ + "schema_version": "psyche.surface_effect.v1", + "surface_effect_id": "sfx_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "intent_id": "int_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "graph_id": "grf_01C3F7YQ4R2M8N6P5K1J9H0GTS", + "node_id": "nod_01D4G8ZR5S3N9P7Q6M2K0J1HTV", + "attempt_id": "att_01E5H90S6T4P0Q8R7N3M1K2JVW", + "familiar_snapshot_id": "ids_01F6JA1T7V5Q1R9S8P4N2M3KWX", + "project_id": "project:sha256:0123456789abcdef", + "action_class": "telegram.reply.send", + "account_id": "main", + "locator": {"chat_id": "-100123", "message_id": "42"}, + "effect": {"type": "message", "text": "Review complete."}, + "effect_digest": "sha256:c16ac6fce1ddecbddc6bf0b51544975741901289ec2248ddeffe6951fc8be995", + "created_at": "2026-08-01T00:01:00Z" +} diff --git a/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-event.json b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-event.json new file mode 100644 index 0000000..87afa51 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-event.json @@ -0,0 +1,11 @@ +{ + "schema_version": "psyche.surface_event.v1", + "surface_event_id": "sev_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "adapter_id": "telegram", + "account_id": "main", + "actor": {"type": "user", "id": "123"}, + "locator": {"type": "message", "chat_id": "-100123", "message_id": "42"}, + "adapter_event_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "received_at": "2026-08-01T00:00:00Z", + "content": {"type": "text", "text": "Please review this."} +} diff --git a/crates/psyche-core/tests/reference_client_canary.rs b/crates/psyche-core/tests/reference_client_canary.rs new file mode 100644 index 0000000..fdc1a23 --- /dev/null +++ b/crates/psyche-core/tests/reference_client_canary.rs @@ -0,0 +1,286 @@ +//! Pinned reference-client conformance profile and canary vectors. +//! +//! Issue OpenCoven/psyche#13 asks Psyche Build to pin one immutable Psyche +//! protocol artifact and to fail on unknown majors, widening, stale +//! correlation, ambiguous effects, and incompatible downgrades. The pinned +//! artifact lives in `tests/fixtures/reference-client/`: a profile manifest +//! that digest-pins every vector, plus positive and denial vectors at the +//! canonical document boundary. These tests enforce the pin and prove every +//! declared vector outcome against the public decoder and quarantine +//! classification, so a consumer pinning the same artifact can rely on the +//! outcomes reproduced here. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::HashSet; +use std::fmt::Write as _; + +use psyche_core::contracts::{ + RecordKind, RejectedDocument, RejectionReason, SchemaKind, SchemaVersion, decode_document, +}; +use psyche_core::digest::canonical_bytes; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +const PROFILE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/reference-client"); +/// SHA-256 over the exact bytes of `profile.v1.json`. Bumping this constant is +/// the reviewed act that republishes the profile; a consumer pinning the +/// previous digest keeps its pinned behavior. +const PROFILE_SHA256: &str = + "sha256:0d4851e449f622f2ec27a3c0ec68c42b295ad25dc68234b42643e292fd76a934"; +const PROFILE_NAME: &str = "psyche.reference-client.profile.v1"; +const REGISTRY_NAME: &str = "psyche.schema.registry.v1"; +const SUPPORTED_MAJOR: u16 = 1; + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Profile { + profile: String, + title: String, + issue: String, + protocol: ProtocolPin, + vectors: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ProtocolPin { + registry: String, + supported_major: u16, + kinds: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Vector { + id: String, + file: String, + digest: String, + class: String, + kind: Option, + reason: Option, + major: Option, + schema: Option, + field: Option, + persistable: bool, +} + +fn artifact_bytes(relative: &str) -> Vec { + std::fs::read(format!("{PROFILE_DIR}/{relative}")).unwrap() +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hex = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +fn load_profile() -> Profile { + serde_json::from_slice(&artifact_bytes("profile.v1.json")).unwrap() +} + +fn manifest_schema(vector: &Vector) -> SchemaKind { + let name = vector.schema.as_deref().unwrap_or_default(); + let requested = format!("psyche.{name}.v{SUPPORTED_MAJOR}"); + match SchemaVersion::parse(&requested) { + Ok(version) => version.kind, + Err(error) => panic!("vector {} names unknown schema {name}: {error}", vector.id), + } +} + +fn assert_declared_rejection(vector: &Vector, reason: RejectionReason) { + let declared = vector.reason.as_deref().unwrap_or_default(); + match (declared, reason) { + ("unknown_schema", RejectionReason::UnknownSchema) => {} + ("unsupported_major", RejectionReason::UnsupportedMajor { found, supported }) => { + assert_eq!(found, vector.major.unwrap_or_default(), "vector {}", vector.id); + assert_eq!(supported, SUPPORTED_MAJOR, "vector {}", vector.id); + } + ("unknown_enum_value", RejectionReason::UnknownEnumValue { schema, field }) => { + assert_eq!(schema, manifest_schema(vector), "vector {}", vector.id); + assert_eq!( + field, + vector.field.as_deref().unwrap_or_default(), + "vector {}", + vector.id + ); + } + ("invalid_shape", RejectionReason::InvalidShape { schema, field }) => { + assert_eq!(schema, manifest_schema(vector), "vector {}", vector.id); + assert_eq!( + field, + vector.field.as_deref().unwrap_or_default(), + "vector {}", + vector.id + ); + } + (declared, reason) => panic!( + "denial vector {} declared {declared} but was rejected as {reason:?}", + vector.id + ), + } +} + +#[test] +fn profile_pin_and_every_vector_digest_are_immutable() { + let profile_bytes = artifact_bytes("profile.v1.json"); + assert_eq!( + format!("sha256:{}", sha256_hex(&profile_bytes)), + PROFILE_SHA256, + "the pinned profile changed without a reviewed pin bump" + ); + let profile: Profile = serde_json::from_slice(&profile_bytes).unwrap(); + assert_eq!(profile.profile, PROFILE_NAME); + assert!(!profile.title.is_empty()); + assert!(!profile.issue.is_empty()); + + let mut ids = HashSet::new(); + for vector in &profile.vectors { + assert!(ids.insert(vector.id.as_str()), "duplicate vector id {}", vector.id); + let bytes = artifact_bytes(&vector.file); + assert_eq!( + format!("sha256:{}", sha256_hex(&bytes)), + vector.digest, + "vector {} drifted from its pinned digest", + vector.id + ); + match vector.class.as_str() { + "positive" => { + assert!( + vector.kind.is_some(), + "positive vector {} must declare its kind", + vector.id + ); + assert!( + vector.reason.is_none(), + "positive vector {} must not declare a denial reason", + vector.id + ); + } + "denial" => { + let reason = vector.reason.as_deref().unwrap_or_default(); + let known_reasons = [ + "unsupported_major", + "unknown_schema", + "unknown_enum_value", + "invalid_shape", + ]; + assert!( + known_reasons.contains(&reason), + "denial vector {} declares an unknown reason {reason}", + vector.id + ); + assert!( + vector.kind.is_none(), + "denial vector {} must not declare a kind", + vector.id + ); + if reason == "unsupported_major" { + assert!( + vector.major.is_some(), + "denial vector {} must declare the rejected major", + vector.id + ); + } else { + assert!( + vector.major.is_none(), + "denial vector {} must not declare a major", + vector.id + ); + if reason != "unknown_schema" { + assert!( + vector.schema.is_some() && vector.field.is_some(), + "denial vector {} must declare the rejected schema and field", + vector.id + ); + } + } + } + other => panic!("vector {} declares an unknown class {other}", vector.id), + } + } +} + +#[test] +fn profile_freezes_the_v1_registry_and_supported_major() { + let profile = load_profile(); + assert_eq!(profile.protocol.registry, REGISTRY_NAME); + assert_eq!(profile.protocol.supported_major, SUPPORTED_MAJOR); + assert_eq!( + profile.protocol.kinds.len(), + RecordKind::ALL.len() + 1, + "the v1 registry freezes sixteen kinds: every record kind plus error" + ); + + let mut mapped = HashSet::new(); + for kind in &profile.protocol.kinds { + let requested = format!("psyche.{kind}.v{SUPPORTED_MAJOR}"); + let version = match SchemaVersion::parse(&requested) { + Ok(version) => version, + Err(error) => panic!("manifest kind {kind} is outside the v1 registry: {error}"), + }; + match version.kind.record_kind() { + Some(record_kind) => assert!( + mapped.insert(record_kind), + "manifest kind {kind} maps onto an already covered record kind" + ), + None => assert_eq!(*kind, "error", "only the error kind is non-persistable"), + } + } + let persisted: HashSet<_> = RecordKind::ALL.iter().copied().collect(); + assert_eq!(mapped, persisted, "the manifest must cover every record kind exactly once"); +} + +#[test] +fn positive_vectors_decode_validate_and_round_trip_canonically() { + let profile = load_profile(); + for vector in profile.vectors.iter().filter(|vector| vector.class == "positive") { + let bytes = artifact_bytes(&vector.file); + let document = match decode_document(&bytes) { + Ok(document) => document, + Err(error) => panic!("positive vector {} must decode: {error}", vector.id), + }; + let kind = vector.kind.as_deref().unwrap_or_default(); + assert_eq!( + document.schema_version().to_string(), + format!("psyche.{kind}.v{SUPPORTED_MAJOR}"), + "vector {} decoded as the wrong kind", + vector.id + ); + if let Err(error) = document.validate() { + panic!("positive vector {} must validate: {error}", vector.id); + } + assert_eq!( + document.persistable_record_id().is_some(), + vector.persistable, + "vector {} persistability disagrees with the profile", + vector.id + ); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + canonical_bytes(&value).unwrap(), + canonical_bytes(&document).unwrap(), + "vector {} does not round-trip canonically", + vector.id + ); + } +} + +#[test] +fn denial_vectors_reject_with_the_declared_quarantine_class() { + let profile = load_profile(); + for vector in profile.vectors.iter().filter(|vector| vector.class == "denial") { + let bytes = artifact_bytes(&vector.file); + let Err(error) = decode_document(&bytes) else { + panic!("denial vector {} must be rejected, but decoded successfully", vector.id); + }; + let rejected = RejectedDocument::from_decode_error(&bytes, error); + assert!( + rejected.is_authentic(), + "denial vector {} produced inauthentic quarantine input", + vector.id + ); + assert_declared_rejection(vector, rejected.reason); + } +} diff --git a/docs/REFERENCE-CLIENT.md b/docs/REFERENCE-CLIENT.md new file mode 100644 index 0000000..9ca013d --- /dev/null +++ b/docs/REFERENCE-CLIENT.md @@ -0,0 +1,161 @@ +# Reference-Client Conformance (Psyche Build) + +Issue [OpenCoven/psyche#13](https://github.com/OpenCoven/psyche/issues/13) proves +Psyche Build as the first conforming Psyche reference client through a pinned, +versioned adapter and compatibility canary. The cross-repository owner is +[OpenCoven/psyche-build#201](https://github.com/OpenCoven/psyche-build/issues/201); +the protocol repo (this repository) owns the immutable artifact the adapter pins. +This document records the pinned profile, the canary design, the adapter plan, +and the current state of the track. + +## Current state + +- **Stage 0 mapping only** on the Psyche Build side: the cross-repository mirror + is live in `OpenCoven/psyche-build#253`, with draft PR `#254` (head + `264a5f68e64cbdc393c1f4a15b6c770da6bdbd5a`) carrying the current-main + inventory of product-local task/trace/operation/lane, lease, approval, receipt, + journal, fencing, and recovery seams, the mapping matrix, the semantic-gap + list, and the staged path. +- **No immutable profile, no compatibility pin, no adapter migration, and no + conformance claim existed** before this slice. Slices 3–5 remain gated on + issue #11 (protocol v1 publication) and issue #12 (ownership and + compatibility decision). +- **This slice publishes the pin** the second delivery slice requires: one + immutable Psyche protocol artifact/profile, positive and negative canary + vectors, and CI enforcement in this repository. It is deliberately decode- + boundary scoped: nothing here rewrites product behavior, replaces tmux, + worktrees, or UI, or makes Psyche integration a blocker for supported macOS + operation. + +## The pinned artifact + +`crates/psyche-core/tests/fixtures/reference-client/` is the artifact: + +| File | Role | +|---|---| +| `profile.v1.json` | The profile: registry pin, every vector with its class, expected outcome, and SHA-256 digest | +| `vectors/positive-*.json` | Six canonical documents a reference client must accept | +| `vectors/denial-*.json` | Eight documents a reference client must reject and quarantine | + +The profile digest is pinned twice, deliberately: + +- `sha256:0d4851e449f622f2ec27a3c0ec68c42b295ad25dc68234b42643e292fd76a934` — + here, for humans and consumers; +- the `PROFILE_SHA256` constant in `crates/psyche-core/tests/ + reference_client_canary.rs` — for CI, which fails on any byte of drift. + +Immutability rules: + +1. Every vector's exact bytes are digest-pinned inside the profile; any edit to + a vector fails the canary test. +2. The profile itself is digest-pinned by the test constant; changing the + artifact means bumping that constant in the same reviewed change. Consumers + pinning the previous digest keep the previous behavior until they re-pin. +3. The profile freezes the v1 registry: sixteen kinds at supported major 1. The + test derives the expected registry from the code (`RecordKind::ALL` plus the + non-persistable error kind), so the manifest cannot silently drift from the + schema registry described in [SCHEMAS.md](SCHEMAS.md). + +Consumption contract for Psyche Build CI: copy the artifact directory at the +pinned digest, run every vector through the adapter's document-decode path, and +compare the outcome (accept + declared kind + persistability, or reject + +declared quarantine class) with the profile. A mismatch is a canary failure and +blocks the migration slice that produced it. + +## Canary design + +The issue requires positive, denial, restart, ambiguity, and downgrade +canaries, failing on unknown major, widening, stale correlation, ambiguous +effect, or incompatible downgrade. At the decode boundary every denial becomes +a `RejectedDocument` quarantine input carrying a payload-light +`RejectionReason`; the profile pins that class per vector. + +| Canary class | Vector(s) | Expected outcome | +|---|---|---| +| Positive | `positive-intent`, `positive-node`, `positive-surface-event`, `positive-surface-effect`, `positive-delivery`, `positive-error-envelope` | Decode, validate, canonical (RFC 8785) round-trip, and declared persistability (error envelopes are never persistable) | +| Unknown major (forward) | `denial-unknown-major-v2` | `UnsupportedMajor { found: 2, supported: 1 }` | +| Incompatible downgrade | `denial-downgrade-major-v0` | `UnsupportedMajor { found: 0, supported: 1 }` | +| Product-local kind | `denial-unknown-kind` | `UnknownSchema` — a `psyche.task.v1` kind does not exist and can never become protocol identity | +| Widening | `denial-widened-intent` | `InvalidShape { schema: intent, field: document }` — a product-local ID smuggled in as a new field is denied by `deny_unknown_fields` | +| Raw status as state | `denial-unknown-enum-node-state` | `UnknownEnumValue { schema: graph_node, field: state }` | +| Unknown error code | `denial-unknown-error-code` | `UnknownEnumValue { schema: error, field: code }` | +| Stale correlation | `denial-stale-effect-digest` | `InvalidShape { schema: delivery, field: effect_digest }` — the effect moved on but the claimed digest did not | +| Ambiguous effect | `denial-ambiguous-duplicate-field` | `InvalidShape { schema: error, field: json }` — a duplicated key makes the document ambiguous | + +Lifecycle canaries map onto the existing suites rather than decode vectors: + +- **Restart** — C-S11 scripted restart persistence plus the store crash + recovery suite prove durable identity across disconnect/restart; adapter-level + restart vectors land with the #11 conformance runner. +- **Ambiguity (dispatch-level)** — C-S6 ambiguity fence proves immutable + correlation yields a durable return or a durable fence and never a + redispatch without one. +- **Denial (structured)** — C-S12 proves known denials preserve the canonical + structured error while unknown enums, kinds, and majors quarantine. + +## Pinned adapter plan + +Migration principle: Psyche Build remains the coding cockpit; Psyche becomes +the canonical orchestration protocol. Existing behavior is wrapped behind +adapters and migrated in bounded slices, never rewritten in one step. + +| Slice | Content | Owner | Status | +|---|---|---|---| +| 1. Inventory and mapping | Map records/state transitions to Psyche records; classify exact matches, adapters, gaps, intentional product-local state; identify authority inferred from UI/process state | `psyche-build#201` | Stage 0 in `psyche-build#253`/`#254` | +| 2. Pinned conformance canary | Pin the immutable profile; run positive and negative vectors in Psyche Build CI | `psyche-build#201`, artifact by this repo | Artifact and enforcement shipped here; Psyche Build adoption pending | +| 3. Identity adapter | Protocol-owned task/lane/attempt/action/receipt IDs alongside local IDs; preserve persistence and UI; reversible migration and rollback | `psyche-build#201` | Blocked on #11/#12 | +| 4. Lifecycle adapter | Map lease, approval, receipt, cancellation, recovery, terminal states; keep fail-closed and idempotent; remove duplication only after canaries prove equivalence | `psyche-build#201` | Blocked on #11/#12 | +| 5. Reference flow | Desktop task start → isolated lane → execution/evidence → approval → receipt → disconnect/restart → same identity resumes | `psyche-build#201` | After slices 3–4 | + +Proposed record mapping (to be confirmed by the cross-repo owner): + +| Psyche Build seam | Psyche protocol owner | +|---|---| +| Task / lane / attempt | `psyche.intent.v1`, `psyche.graph.v1`, `psyche.graph_node.v1`, `psyche.execution_binding.v1` (stored as `att_`) | +| Lease and cancellation | Execution binding adoption/cancellation states with core-owned acknowledgement or unresolved evidence | +| Approval | `psyche.approval.v1` | +| Receipt / consequential action | `psyche.delivery.v1`, `psyche.surface_effect.v1`, `psyche.evidence.v1`, `psyche.verdict.v1` | +| Journal / state history | Store-owned append-only transitions | +| Pane, tmux, worktree, branch, provider, transport, UI selection | **Product-local state — never protocol identity** | + +Identity boundary rules, enforced by the canary rather than by convention: + +- Protocol identity exists only as `RecordId` values with the frozen + `_` shapes; product-local IDs (pane, worktree, tmux, provider, + Beads, GitHub) cannot be widened into protocol documents + (`denial-widened-intent`) and cannot mint record kinds + (`denial-unknown-kind`). +- Product-local state may still reference protocol records; the reverse is + refused at the decode boundary, which is what keeps the mapping reversible. + +Rollback requirements carried into slices 3–4: dual-write protocol IDs beside +local IDs, keep the mapping a pure function of durable state, and remove +product-local duplication only after canaries prove equivalence. Because the +pin is digest-addressed, a failed adapter migration rolls back by reverting to +the previous pinned artifact without data loss or identity fork — the protocol +side cannot change under a running adapter without a reviewed pin bump. + +## Evidence checklist (issue acceptance criteria) + +| Criterion | Status | +|---|---| +| Psyche Build pins an immutable Psyche protocol/profile artifact | **Artifact published here**; Psyche Build re-pin pending | +| CI runs positive, denial, restart, ambiguity, and downgrade canaries | Decode-boundary canaries run in this repo's CI; restart/ambiguity map to C-S6/C-S11 + crash suites; Psyche Build CI adoption pending | +| Product-local pane/worktree/tmux/provider IDs cannot become protocol identity | Enforced by closed registry + widening/unknown-kind vectors | +| macOS `v0.0.1` behavior and support claims remain intact | Untouched: this slice is additive test artifact + docs | +| Every migrated mutation retains scope, authority, approval, idempotency, receipt, revocation, recovery semantics | Slice 3–4 work, pending #11/#12 | +| A failed adapter migration can roll back without data loss or identity fork | Design recorded; proof is slice 3–4 evidence | +| One executable reference flow preserves identity through restart | Slice 5, pending | +| `OpenCoven/psyche-build#201` links the final conformance evidence | Link to be added by the cross-repo owner when the pin is adopted | + +## What remains + +This PR is **slice 1 of the canary track (canary artifact + enforcement)**; +the track itself is early. Remaining: + +1. Psyche Build adopts the pin in its CI (cross-repo; `psyche-build#201`). +2. Lifecycle canary vectors (restart, dispatch ambiguity) once #11 publishes + the conformance runner and #12 settles ownership/compatibility. +3. Identity and lifecycle adapters (slices 3–4) with dual-write, rollback, and + equivalence evidence. +4. The executable reference flow (slice 5) and the cross-repo evidence link. diff --git a/docs/TESTING.md b/docs/TESTING.md index e8443f4..fa14b04 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -56,3 +56,13 @@ blob URLs and SHA-256 values bind the evidence to the reviewed sources. cannot claim success. - C-S12 positive: known denials preserve the canonical structured error; negative: unknown enums/kinds/majors quarantine rather than dispatch. + +## Reference-client canary + +Issue #13 asks Psyche Build to pin one immutable Psyche protocol artifact and +run positive and negative canaries against it. The pinned profile and its +vectors live in `crates/psyche-core/tests/fixtures/reference-client/`; +`psyche-core`'s `reference_client_canary` target enforces the digest pin, the +frozen v1 registry, and every declared vector outcome. The canary design, the +adapter plan it unblocks, and the acceptance-criteria mapping are recorded in +[REFERENCE-CLIENT.md](REFERENCE-CLIENT.md).