From c4e6a9a43d9637c5b6413e411ae668e8fc3ea67b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:58:59 +0000 Subject: [PATCH] feat: pin the reference-client conformance profile and canary vectors Issue #13 asks Psyche Build to prove itself as the first conforming Psyche reference client by pinning one immutable Psyche protocol artifact and failing on unknown majors, widening, stale correlation, ambiguous effects, and incompatible downgrades. The cross-repository mapping work is at Stage 0 in OpenCoven/psyche-build#253/#254; the protocol repo's contribution is the artifact the adapter pins. Publish that artifact: a reference-client profile manifest that freezes the v1 registry (sixteen kinds at major 1) and digest-pins six positive vectors (byte-exact copies of the proven canonical goldens) and eight denial vectors (single deliberate mutations), plus an enforcement test. The test rejects any byte of drift in the pinned profile or vectors, derives the expected registry from the code, proves every positive vector decodes, validates, round-trips canonically, and matches its declared persistability, and proves every denial vector is rejected with the declared quarantine class through the public RejectedDocument path. docs/REFERENCE-CLIENT.md records the pin, the canary design, the pinned adapter plan, and the current state of the issue. Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .../fixtures/reference-client/profile.v1.json | 153 ++++++++++ .../denial-ambiguous-duplicate-field.json | 13 + .../vectors/denial-downgrade-major-v0.json | 13 + .../vectors/denial-stale-effect-digest.json | 32 ++ .../denial-unknown-enum-node-state.json | 12 + .../vectors/denial-unknown-error-code.json | 13 + .../vectors/denial-unknown-kind.json | 5 + .../vectors/denial-unknown-major-v2.json | 13 + .../vectors/denial-widened-intent.json | 14 + .../vectors/positive-delivery.json | 32 ++ .../vectors/positive-error-envelope.json | 13 + .../vectors/positive-intent.json | 13 + .../vectors/positive-node.json | 12 + .../vectors/positive-surface-effect.json | 16 + .../vectors/positive-surface-event.json | 11 + .../tests/reference_client_canary.rs | 286 ++++++++++++++++++ docs/REFERENCE-CLIENT.md | 161 ++++++++++ docs/TESTING.md | 10 + 18 files changed, 822 insertions(+) create mode 100644 crates/psyche-core/tests/fixtures/reference-client/profile.v1.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-ambiguous-duplicate-field.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-downgrade-major-v0.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-stale-effect-digest.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-enum-node-state.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-error-code.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-kind.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-unknown-major-v2.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/denial-widened-intent.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-delivery.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-error-envelope.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-intent.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-node.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-effect.json create mode 100644 crates/psyche-core/tests/fixtures/reference-client/vectors/positive-surface-event.json create mode 100644 crates/psyche-core/tests/reference_client_canary.rs create mode 100644 docs/REFERENCE-CLIENT.md 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).