From 2f05596513fb67ef3d4821907ab3d822a43bcdde Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:57:57 +0000 Subject: [PATCH 1/4] feat(protocol): publish protocol v1 schemas, golden vectors, and runner Publish the consumer-ready Psyche protocol v1 release surface required by issue #11 on top of the G2 foundation: - protocol/v1/: generated artifact set - self-contained JSON Schema (draft 2020-12) for all sixteen registry kinds plus the Coven boundary types and the store-owned transition, a generated TypeScript surface, byte-exact canonical golden vectors (positive, denial, stale-correlation, unknown-version, crash/restart, downgrade) with pinned SHA-256 digests, a machine-readable inventory with stability classes, and a sha256sum manifest for downstream pinning. - packages/psyche-protocol/: standalone, dependency-free conformance runner (psyche-conformance) implementing the consumer-v1 profile - negotiation, correlation, snapshot/attempt binding, adoption proof, fencing shape, cursor/restart, terminal authority shape, the full cancellation evidence matrix, result/artifact binding, append-only revision chains, structured-denial classification, and the canonical JSON domain. Emits bounded machine-readable results; no network, no credentials. - scripts/protocol/: single source of truth (definitions.mjs, vectors.mjs) and a deterministic generator; CI re-runs generation and fails on uncommitted drift. - crates/psyche-core/tests/protocol_golden.rs: cross-checks every golden vector through the real decoder and byte-compares canonical_bytes against the published artifacts. - docs/PROTOCOL.md: inventory, classification, canonical bytes, semantic versioning, compatibility windows, unknown-field/enum fail-closed behavior, migration/downgrade/rollback, conformance profile, and decision records. Canonicalization is RFC 8785; the JavaScript implementation is pinned to byte parity against the Rust-pinned request digests (sha256:75d651c5... launch, sha256:c8c3d0ca... input) and the decoder-recomputed effect digests. Refs OpenCoven/psyche#11 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .github/workflows/ci.yml | 28 + crates/psyche-core/tests/protocol_golden.rs | 181 ++++ docs/PROTOCOL.md | 380 +++++++ docs/SCHEMAS.md | 6 + packages/psyche-protocol/README.md | 23 + .../psyche-protocol/bin/psyche-conformance.js | 70 ++ packages/psyche-protocol/lib/canonical.js | 99 ++ packages/psyche-protocol/lib/digest.js | 52 + packages/psyche-protocol/lib/profile.js | 612 +++++++++++ packages/psyche-protocol/lib/run.js | 350 +++++++ packages/psyche-protocol/lib/schema.js | 329 ++++++ packages/psyche-protocol/package.json | 14 + .../psyche-protocol/test/canonical.test.mjs | 73 ++ packages/psyche-protocol/test/cli.test.mjs | 68 ++ .../psyche-protocol/test/profile.test.mjs | 433 ++++++++ packages/psyche-protocol/test/schema.test.mjs | 104 ++ protocol/README.md | 45 + protocol/v1/MANIFEST.sha256 | 55 + .../negative/crash-restart-broken-chain.json | 1 + .../golden/negative/denial-unknown-code.json | 1 + .../golden/negative/denial-unknown-enum.json | 1 + .../golden/negative/denial-unknown-field.json | 1 + .../golden/negative/denial-unknown-kind.json | 1 + .../negative/downgrade-major-graph-v2.json | 1 + .../stale-correlation-ack-outside-window.json | 1 + .../stale-correlation-expired-request.json | 1 + .../stale-correlation-wrong-session.json | 1 + .../negative/unknown-version-intent-v2.json | 1 + protocol/v1/golden/positive/addon.json | 1 + protocol/v1/golden/positive/approval.json | 1 + protocol/v1/golden/positive/budget.json | 1 + .../positive/crash-restart/revision-1.json | 1 + .../positive/crash-restart/revision-2.json | 1 + protocol/v1/golden/positive/delegation.json | 1 + .../v1/golden/positive/delivery-ready.json | 1 + .../positive/error-storage-unavailable.json | 1 + protocol/v1/golden/positive/evidence.json | 1 + .../execution-binding-acknowledged.json | 1 + .../execution-binding-revision-1.json | 1 + .../positive/execution-request-input.json | 1 + .../positive/execution-request-launch.json | 1 + protocol/v1/golden/positive/graph-node.json | 1 + protocol/v1/golden/positive/graph.json | 1 + .../v1/golden/positive/identity-snapshot.json | 1 + protocol/v1/golden/positive/intent.json | 1 + protocol/v1/golden/positive/recovery.json | 1 + .../v1/golden/positive/result-bundle.json | 1 + .../v1/golden/positive/surface-effect.json | 1 + .../v1/golden/positive/surface-event.json | 1 + protocol/v1/golden/positive/transition.json | 1 + protocol/v1/golden/positive/verdict.json | 1 + protocol/v1/golden/vectors.json | 470 +++++++++ protocol/v1/inventory.json | 522 ++++++++++ .../v1/schemas/psyche.addon.v1.schema.json | 69 ++ .../v1/schemas/psyche.approval.v1.schema.json | 60 ++ .../v1/schemas/psyche.budget.v1.schema.json | 66 ++ .../schemas/psyche.delegation.v1.schema.json | 62 ++ .../v1/schemas/psyche.delivery.v1.schema.json | 184 ++++ .../v1/schemas/psyche.error.v1.schema.json | 102 ++ .../v1/schemas/psyche.evidence.v1.schema.json | 82 ++ .../psyche.execution_binding.v1.schema.json | 313 ++++++ .../psyche.execution_request.v1.schema.json | 287 ++++++ .../v1/schemas/psyche.graph.v1.schema.json | 70 ++ .../schemas/psyche.graph_node.v1.schema.json | 109 ++ .../psyche.identity_snapshot.v1.schema.json | 101 ++ .../v1/schemas/psyche.intent.v1.schema.json | 91 ++ .../v1/schemas/psyche.recovery.v1.schema.json | 78 ++ .../schemas/psyche.result_bundle.schema.json | 236 +++++ .../psyche.surface_effect.v1.schema.json | 97 ++ .../psyche.surface_event.v1.schema.json | 69 ++ .../v1/schemas/psyche.transition.schema.json | 81 ++ .../v1/schemas/psyche.verdict.v1.schema.json | 81 ++ protocol/v1/types/psyche-protocol.v1.d.ts | 458 +++++++++ scripts/protocol/definitions.mjs | 949 ++++++++++++++++++ scripts/protocol/generate.mjs | 455 +++++++++ scripts/protocol/vectors.mjs | 671 +++++++++++++ 76 files changed, 8718 insertions(+) create mode 100644 crates/psyche-core/tests/protocol_golden.rs create mode 100644 docs/PROTOCOL.md create mode 100644 packages/psyche-protocol/README.md create mode 100755 packages/psyche-protocol/bin/psyche-conformance.js create mode 100644 packages/psyche-protocol/lib/canonical.js create mode 100644 packages/psyche-protocol/lib/digest.js create mode 100644 packages/psyche-protocol/lib/profile.js create mode 100644 packages/psyche-protocol/lib/run.js create mode 100644 packages/psyche-protocol/lib/schema.js create mode 100644 packages/psyche-protocol/package.json create mode 100644 packages/psyche-protocol/test/canonical.test.mjs create mode 100644 packages/psyche-protocol/test/cli.test.mjs create mode 100644 packages/psyche-protocol/test/profile.test.mjs create mode 100644 packages/psyche-protocol/test/schema.test.mjs create mode 100644 protocol/README.md create mode 100644 protocol/v1/MANIFEST.sha256 create mode 100644 protocol/v1/golden/negative/crash-restart-broken-chain.json create mode 100644 protocol/v1/golden/negative/denial-unknown-code.json create mode 100644 protocol/v1/golden/negative/denial-unknown-enum.json create mode 100644 protocol/v1/golden/negative/denial-unknown-field.json create mode 100644 protocol/v1/golden/negative/denial-unknown-kind.json create mode 100644 protocol/v1/golden/negative/downgrade-major-graph-v2.json create mode 100644 protocol/v1/golden/negative/stale-correlation-ack-outside-window.json create mode 100644 protocol/v1/golden/negative/stale-correlation-expired-request.json create mode 100644 protocol/v1/golden/negative/stale-correlation-wrong-session.json create mode 100644 protocol/v1/golden/negative/unknown-version-intent-v2.json create mode 100644 protocol/v1/golden/positive/addon.json create mode 100644 protocol/v1/golden/positive/approval.json create mode 100644 protocol/v1/golden/positive/budget.json create mode 100644 protocol/v1/golden/positive/crash-restart/revision-1.json create mode 100644 protocol/v1/golden/positive/crash-restart/revision-2.json create mode 100644 protocol/v1/golden/positive/delegation.json create mode 100644 protocol/v1/golden/positive/delivery-ready.json create mode 100644 protocol/v1/golden/positive/error-storage-unavailable.json create mode 100644 protocol/v1/golden/positive/evidence.json create mode 100644 protocol/v1/golden/positive/execution-binding-acknowledged.json create mode 100644 protocol/v1/golden/positive/execution-binding-revision-1.json create mode 100644 protocol/v1/golden/positive/execution-request-input.json create mode 100644 protocol/v1/golden/positive/execution-request-launch.json create mode 100644 protocol/v1/golden/positive/graph-node.json create mode 100644 protocol/v1/golden/positive/graph.json create mode 100644 protocol/v1/golden/positive/identity-snapshot.json create mode 100644 protocol/v1/golden/positive/intent.json create mode 100644 protocol/v1/golden/positive/recovery.json create mode 100644 protocol/v1/golden/positive/result-bundle.json create mode 100644 protocol/v1/golden/positive/surface-effect.json create mode 100644 protocol/v1/golden/positive/surface-event.json create mode 100644 protocol/v1/golden/positive/transition.json create mode 100644 protocol/v1/golden/positive/verdict.json create mode 100644 protocol/v1/golden/vectors.json create mode 100644 protocol/v1/inventory.json create mode 100644 protocol/v1/schemas/psyche.addon.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.approval.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.budget.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.delegation.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.delivery.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.error.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.evidence.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.execution_binding.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.execution_request.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.graph.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.graph_node.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.identity_snapshot.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.intent.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.recovery.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.result_bundle.schema.json create mode 100644 protocol/v1/schemas/psyche.surface_effect.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.surface_event.v1.schema.json create mode 100644 protocol/v1/schemas/psyche.transition.schema.json create mode 100644 protocol/v1/schemas/psyche.verdict.v1.schema.json create mode 100644 protocol/v1/types/psyche-protocol.v1.d.ts create mode 100644 scripts/protocol/definitions.mjs create mode 100644 scripts/protocol/generate.mjs create mode 100644 scripts/protocol/vectors.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5253bb6..c129dc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/crates/psyche-core/tests/protocol_golden.rs b/crates/psyche-core/tests/protocol_golden.rs new file mode 100644 index 0000000..ee06506 --- /dev/null +++ b/crates/psyche-core/tests/protocol_golden.rs @@ -0,0 +1,181 @@ +//! 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"); + +/// (vector file, expected id prefix). The registry-kind positive vectors; +/// `psyche.transition` is store-owned (not a registry document) and the Coven +/// boundary types are pinned by crates/psyche-coven, so neither appears here. +const POSITIVE_VECTORS: &[(&str, Option<&str>)] = &[ + ("positive/identity-snapshot.json", Some("ids_")), + ("positive/intent.json", Some("int_")), + ("positive/surface-event.json", Some("sev_")), + ("positive/graph.json", Some("grf_")), + ("positive/graph-node.json", Some("nod_")), + ("positive/delegation.json", Some("dlg_")), + ("positive/budget.json", Some("bud_")), + ("positive/approval.json", Some("apr_")), + ("positive/execution-binding-revision-1.json", Some("att_")), + ("positive/execution-binding-acknowledged.json", Some("att_")), + ("positive/evidence.json", Some("evd_")), + ("positive/verdict.json", Some("vrd_")), + ("positive/recovery.json", Some("rcv_")), + ("positive/addon.json", Some("adn_")), + ("positive/surface-effect.json", Some("sfx_")), + ("positive/delivery-ready.json", Some("del_")), + ("positive/error-storage-unavailable.json", None), + ("positive/transition.json", None), + ("positive/crash-restart/revision-1.json", Some("att_")), + ("positive/crash-restart/revision-2.json", Some("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 { + 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, expected_prefix) in POSITIVE_VECTORS { + let bytes = vector_bytes(file); + let document = decode_document(&bytes) + .unwrap_or_else(|error| panic!("{file} must decode as a positive vector: {error}")); + if let Some(expected_prefix) = expected_prefix { + // `psyche.error.v1` never persists, so only some vectors carry a + // durable record id. + if let Some(id) = document.persistable_record_id() { + assert!( + id.as_str().starts_with(expected_prefix), + "{file}: record id {} lacks the {expected_prefix} prefix", + id.as_str(), + ); + } + } + // 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); +} diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..544ed89 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,380 @@ +# Psyche Protocol v1 — Publication, Compatibility, and Conformance + +**Status:** proposed (Phase 1) +**Artifact set:** `protocol/v1/` (generated; `MANIFEST.sha256`-checksummed) +**Implements:** the protocol publication contract in +[OpenCoven/psyche#11](https://github.com/OpenCoven/psyche/issues/11), on the G2 +foundation delivered by #4–#8 and the roadmap in #9/#15. +**Accountable owner:** @BunsDev (per #11); cross-repository boundaries pending +[#12](https://github.com/OpenCoven/psyche/issues/12). + +This document is the specification artifact for issue #11: it inventories the +public protocol surface covered by the G2 foundation, classifies every element, +pins canonical bytes and digests, defines the compatibility policy, and defines +the consumer conformance profile and its standalone runner. Every claim cites +the enforcing code path at today's `main` (`1e47b40`). + +--- + +## 1. What is published, and where + +| Surface | Location | Generated from | +|---|---|---| +| JSON Schemas (draft 2020-12), one per record, self-contained | `protocol/v1/schemas/` | `scripts/protocol/definitions.mjs` | +| TypeScript surface (interfaces + enum unions) | `protocol/v1/types/psyche-protocol.v1.d.ts` | generated from the same tables | +| Byte-exact canonical golden vectors + digests | `protocol/v1/golden/` | `scripts/protocol/vectors.mjs` | +| Machine-readable inventory + stability classes | `protocol/v1/inventory.json` | definitions.mjs | +| Checksummed artifact set | `protocol/v1/MANIFEST.sha256` | generator | +| Conformance runner (CLI + library) | `packages/psyche-protocol/` | hand-written, tested | + +Generation is deterministic: `node scripts/protocol/generate.mjs` rewrites the +tree byte-identically, and `node scripts/protocol/generate.mjs --check` +(or CI's regenerate-then-`git diff --exit-code`) fails on drift. The generator +itself is the drift authority for schemas, TypeScript, vector digests, the +inventory, and the manifest. + +**Why generated artifacts instead of hand-maintained files.** The issue +requires deterministic drift detection for schemas, fixtures, and generated +language. With one source table, a schema edit, a TypeScript edit, and a +digest edit cannot disagree; the CI gate (`node scripts/protocol/generate.mjs && git diff --exit-code -- protocol/`) fails if the committed tree was not +produced by the committed sources. + +## 2. Inventory and stability classification + +`protocol/v1/inventory.json` is the machine-readable inventory; the tables +below are the same classification with the enforcing code paths. Stability +classes follow #11: `stable-v1` (frozen, decoder-enforced today), +`experimental` (published, boundary authority pending #12), `internal` +(documented, never conformance-checked), `deprecated` (none today). + +### 16.1 Document registry kinds (stable-v1) + +The closed v1 registry is exactly these sixteen schema kinds — +`crates/psyche-core/src/contracts/mod.rs` (`SchemaKind::ALL`, test +`schema_version_accepts_exactly_the_sixteen_known_strings`). Fifteen are +persistable (`RecordKind::ALL`); `psyche.error.v1` is never persistable. + +| Schema | Id prefix | Record kind | Enforced by | +|---|---|---|---| +| `psyche.identity_snapshot.v1` | `ids_` | `IdentitySnapshot` | `crates/psyche-core/src/contracts/identity.rs` | +| `psyche.intent.v1` | `int_` | `Intent` | `contracts/intent.rs` | +| `psyche.surface_event.v1` | `sev_` | `SurfaceEvent` | `contracts/surface.rs` (SurfaceEvent) | +| `psyche.graph.v1` | `grf_` | `Graph` | `contracts/graph.rs` | +| `psyche.graph_node.v1` | `nod_` | `GraphNode` | `contracts/graph.rs` | +| `psyche.delegation.v1` | `dlg_` | `Delegation` | `contracts/foundation.rs` | +| `psyche.budget.v1` | `bud_` | `Budget` | `contracts/foundation.rs` | +| `psyche.approval.v1` | `apr_` | `Approval` | `contracts/foundation.rs` | +| `psyche.execution_binding.v1` | `att_` | `Attempt` | `contracts/execution.rs` | +| `psyche.evidence.v1` | `evd_` | `Evidence` | `contracts/foundation.rs` | +| `psyche.verdict.v1` | `vrd_` | `Verdict` | `contracts/foundation.rs` | +| `psyche.recovery.v1` | `rcv_` | `Recovery` | `contracts/foundation.rs` | +| `psyche.addon.v1` | `adn_` | `Addon` | `contracts/foundation.rs` | +| `psyche.surface_effect.v1` | `sfx_` | `SurfaceEffect` | `contracts/surface.rs` | +| `psyche.delivery.v1` | `del_` | `Delivery` | `contracts/surface.rs` | +| `psyche.error.v1` | — (never persisted) | none | `contracts/error.rs` | + +Identifier prefixes are the frozen `RecordKind::prefix` values +(`ids_ int_ grf_ nod_ att_ dlg_ bud_ apr_ evd_ vrd_ rcv_ adn_ sev_ sfx_ del_`), +validated by `crates/psyche-core/src/id.rs`: `` + exactly 26 uppercase +Crockford-Base32 characters whose first character is `0..=7`. `dly_` is never +accepted for delivery and `del_` never for delegation. `req_` identifies +ephemeral `RequestId`s, which are never stored records +(`crates/psyche-core/src/id.rs`). + +The execution-binding special case is normative: `psyche.execution_binding.v1` +maps onto `RecordKind::Attempt` — an execution binding *is* an attempt record — +and there is deliberately no duplicate binding-named record kind +(`contracts/mod.rs`, `SchemaKind::record_kind`). + +### Coven boundary types (experimental) + +| Schema | Source | Note | +|---|---|---| +| `psyche.execution_request.v1` (launch/input) | `crates/psyche-coven/src/port.rs` (`ExecutionRequestInput`) | Not in the core registry: `decode_document` answers `UnknownSchema`. Digest-bound request; golden bytes and SHA-256 pinned by `crates/psyche-coven/tests/request_digest.rs`. | +| result bundle (`psyche.result_bundle`) | `crates/psyche-coven/src/port.rs` (`ResultBundle`) | No `.v1` suffix: not yet a versioned registry kind. C-S10 fixture round-trips in `crates/psyche-coven/tests/bindings.rs`. | + +These two are published as **experimental** schemas because adapters need their +shapes (C-S1, C-S10 evidence), but their cross-repository authority is exactly +what #12 must approve before they can be declared stable. They are excluded +from the core decoder registry by design (`docs/SCHEMAS.md`: closed v1 +registry). + +### Store-owned storage record (stable-v1, storage surface) + +| Record | Source | Notes | +|---|---|---| +| Transition | `crates/psyche-store/src/transitions.rs` | `psyche.transition` (no schema_version of its own; `kind` names a registry kind, never `error`). `transition_digest` = SHA-256 over every other field's canonical bytes (`TransitionDigestInput`). `record_version == 1 ⟺ from_state == null`; history is append-only with strictly increasing versions. | + +### Enum vocabularies (all stable-v1 spellings) + +`graph_state` (11), `node_state` (22), `adoption_state` (6), +`cancellation_state` (5), `cancellation_acknowledgement_kind` (2), +`delivery_relationship` (5), `delivery_decision_state` (2), `delivery_state` +(10), `error_code` (36), `capability` (5), `rejection_reason` (5) — owners +cited in `inventory.json`; spellings are frozen by +`contracts/mod.rs::inspect_typed_enums` and `error.rs::ErrorCode::ALL`. + +### Internal surfaces (documented, not published as schemas) + +- `psyche.config.v1` — daemon configuration gate + (`crates/psyche-core/src/schema.rs`); denies unknown versions with no + compatibility range and no coercion. +- Store SQL layout (`crates/psyche-store/migrations/001_foundation.sql`) — the + interoperable surface is the `canonical_json` column this artifact set pins. +- Raw Coven ledger statuses (`created`, `running`, `idle`, `completed`, + `failed`, `killed`, `orphaned`) — never on the Psyche wire; they can never + manufacture cancellation acknowledgement evidence + (`docs/SCHEMAS.md`, "Cancellation and results"). + +### Identifiers, digests, and the canonical JSON domain + +- **Record ids:** `<4-char prefix>_<26-char canonical ULID>` per + `crates/psyche-core/src/id.rs`. Fifteen prefixes (`ids_ int_ grf_ nod_ att_ + dlg_ bud_ apr_ evd_ vrd_ rcv_ adn_ sev_ sfx_ del_`) plus the non-record + `req_`. Delivery is `del_` (`dly_` is never accepted); delegation is + distinctly `dlg_`; execution binding is the one Attempt kind. +- **Digests:** `sha256:` + 64 lowercase hex over RFC 8785 canonical JSON bytes + (`crates/psyche-core/src/digest.rs`). The store recomputes every claimed + digest before authority or persistence accepts it + (`records.rs insert_canonical_in_transaction`, + `execution_bindings.rs insert_in_transaction`, + `transitions.rs Transition::validate`). +- **Structured denials:** `psyche.error.v1` exhaustively decodes the 36-code + `ErrorCode::ALL`; unknown kinds/versions/enums quarantine as + `RejectionReason::{unknown_schema, unsupported_major, unknown_enum_value, + invalid_shape, too_large}` (`contracts/mod.rs`, `store/src/records.rs + IngestOutcome::Quarantined`). +- **Transitions:** store-owned, append-only, strictly increasing + `record_version`, `transition_digest` over every other field + (`store/src/transitions.rs`). + +### What the JSON Schemas express vs. what the decoders own + +The published schemas express the **structural** contract: exact field sets +(`additionalProperties: false`), types, identifier prefixes, digest and +timestamp shapes, byte-length bounds, and enum vocabularies. The **semantic** +contract — digest recomputation, correlation, evidence windows, revision +append-only rules — is enforced by the Rust decoders and mirrored, check by +check, in the conformance profile (`packages/psyche-protocol/lib/profile.js`). +The schemas are a faithful projection, not a second authority: where a schema +must be more permissive than the decoder (e.g. `dependencies` has no length +cap; key/value byte bounds inside open objects), the profile check closes the +gap and the inventory records it. Fail-closed authority remains the Rust +decoder; the artifact set never weakens it. + +## 3. Canonical bytes and golden vectors + +Canonical JSON is RFC 8785 (JSON Canonicalization Scheme) as implemented by +`psyche_core::digest::canonical_bytes` (`serde_json_canonicalizer`): object +keys sorted by code-unit order, ECMAScript number serialization, UTF-8, no +whitespace, no trailing newline. `digest(value)` = `sha256:` over those bytes. +The JavaScript implementation (`packages/psyche-protocol/lib/canonical.js`) is +pinned to byte parity against Rust-produced fixtures whose SHA-256 values +Psyche's own CI pins (`crates/psyche-coven/tests/request_digest.rs`): +`sha256:75d651c5…` (launch) and `sha256:c8c3d0ca…` (input), plus both +decoder-recomputed effect digests. + +Golden vectors (`protocol/v1/golden/`): + +- **positive** — one or more byte-exact canonical documents per record kind, + each pinned in `golden/vectors.json` with its SHA-256. Byte copies of the + Rust goldens (`execution-request-launch/input`, `result-bundle`) are + byte-identical to `crates/psyche-coven/tests/fixtures/`, so their file + digests *are* the Rust-pinned request digests. +- **denial** — unknown kind, unknown enum, unknown field, unknown error code. +- **stale-correlation** — acknowledgement outside the termination window, + expired request window, evidence session mismatch. +- **unknown-version** — `psyche.intent.v2` against the v1-only registry. +- **crash/restart** — an append-only revision chain (revision 2 binds + `previous_revision_digest` to revision 1's canonical bytes with a strictly + later `revision_created_at` and frozen correlation fields) plus a + broken-chain fixture that is schema-valid but profile-rejected (the store + answers `DatabaseCorruption`, `store/src/execution_bindings.rs + validate_revision_chain`). +- **downgrade** — a newer major against a v1-only consumer must fail closed; + the registry has no rollback path. + +## 4. Compatibility policy + +### Versioning model + +- `psyche..v` is the wire contract; `SUPPORTED_MAJOR` is a single + constant (`contracts/mod.rs`) and every kind is at major 1. A kind reaching + major 2 is a deliberate, reviewed change to that file — the registry has no + silent range extension. +- The **artifact set** itself is versioned `1.0.0` (`protocolVersion` in + `definitions.mjs`, recorded in `inventory.json`). Schema-content changes + that preserve decode compatibility bump the minor; additive kinds are minor + bumps; any change that would make a previously valid document decode + differently is a major bump of that record's schema major, which requires a + new registry entry in Rust (fail-closed by construction: `SUPPORTED_MAJOR` + accepts exactly one major per kind today). +- Schemas carry `x-psyche-stability`. Only `stable-v1` entries are + compatibility promises. `experimental` (Coven boundary) may change without a + major bump until #12 assigns ownership. `internal` surfaces + (`psyche.config.v1`, SQL migrations, raw ledger statuses) are documented but + never conformance-checked. + +### Unknown fields, enums, kinds, and versions + +- Unknown envelope fields are denied (`deny_unknown_fields` in every wire + struct; `additionalProperties: false` in every published schema). +- Unknown kind, unknown major, and unknown enum spellings are strict decode + failures that quarantine the bytes (`RejectedDocument::from_bytes`, + `RejectionReason`) — they are never dispatched, never coerced, and rejected + values never propagate into error payloads (payload-light errors, + `contracts/mod.rs`). +- Unknown major versions and authority-widening changes **fail closed**: the + registry has no compatibility range (`schema.rs`: "denial is unconditional; + there is no compatibility range and no coercion"), and authority-widening + denials are a frozen error vocabulary entry (`delegation_widened`). + +### Minor-version evolution without expression-order coupling + +Consumers must not couple to source-text order or expression order: the +published contract is (a) the schema const spellings, (b) the frozen enum +vocabularies, and (c) canonical byte digests. Adding a registry kind or enum +value is a *minor* artifact-set release that consumers detect by version, not +by parsing behavior: unknown enum spellings and kinds fail closed in v1 +consumers by design, so a consumer that pins artifact set N accepts exactly +the vocabulary of set N, and learning new vocabulary is an explicit upgrade. +No consumer rule depends on declaration order, source text, or struct layout. + +### Migration, downgrade, rollback + +- **Forward migration** (consumer adopts a newer minor): regenerate-or-refetch + the artifact set; vectors and digests re-verify. There is no data migration + at v1 — the store's `canonical_json` is the durable form and unknown + *kinds/majors* were never persisted (they quarantine). +- **Downgrade** — not supported and fail-closed: a v1-only registry rejects + `v2` documents outright (`UnsupportedMajor`), and a future consumer that + supports a newer major must not silently accept older majors for records + whose semantics changed; a rollback therefore requires a deliberate registry + decision, never range matching. The `downgrade` fixture class pins this. +- **Deprecation** — a record marked `deprecated` in `inventory.json` keeps its + schema published for one artifact-set minor cycle, is conformance-checked + until removal, and is flagged in `inventory.json` (`stability: + "deprecated"`). None today. +- **Rollback** — the store is the durable authority: canonical bytes are + append-only (`execution_binding_revisions`, `transitions`), so rolling the + *daemon* back re-opens the same store only if the registry major it supports + is >= the stored data's; otherwise startup fails closed rather than + widening. Quarantined documents are never auto-promoted on rollback. + +### Compatibility windows + +- Within a major: unknown-field and unknown-enum denials are permanent + policy — no "tolerate new fields" window, because adapters must be able to + fail closed on widening. +- New enum values: a minor artifact-set release may add spellings; consumers + that pin an older set keep failing closed (their pinned schema is the + window). That is the intended fail-closed behavior for authority-bearing + vocabularies; presentation-only vocabularies may be re-classified + `experimental` at publication time to get a wider window. +- A record kind reaches `deprecated` only by a reviewed decision recorded in + `definitions.mjs` + inventory; the deprecation window is one artifact-set + minor cycle with the golden vectors retained. + +## 5. Conformance profile (consumer-v1) + +The runner (`packages/psyche-protocol`, CLI `psyche-conformance`) validates an +adapter-facing artifact directory against this profile. The required consumer +profile from #11 maps to named checks: + +| Check | Covers | Enforcing source | +|---|---|---| +| C1 | contract and capability negotiation (schema const, harness, canonical absolute paths, window, unique artifact bindings) | `psyche-coven/src/port.rs ExecutionRequestInput::validate`, `validate_absolute_path`, `validate_artifact_bindings` | +| C2 | stable session/execution correlation (request_id/request_digest/session binding via evidence) | `contracts/execution.rs` | +| C3 | snapshot/attempt binding (`ids_` snapshot + `att_` binding correlation) | `contracts/execution.rs`, `surface.rs` | +| C4 | adoption + durable non-adoption proof (vocabulary; evidence session binding) | `contracts/execution.rs`, `contracts/mod.rs` enum inspection | +| C5 | ambiguity reconciliation/fencing — record shape only; recovery policy is a deferred owner | `contracts/foundation.rs (Recovery)`, docs/SCHEMAS.md | +| C6 | ordered cursor/restart (event_cursor bounds; chain timestamps strictly increase) | `execution_bindings.rs validate_revision_chain` | +| C7 | terminal authority (shape; authority lives in the store's revision ledger) | `contracts/execution.rs` | +| C8 | cancellation acknowledgement and unresolved outcomes (full evidence matrix) | `ExecutionBinding::validate_cancellation` | +| C9 | result/artifact binding (digest/media/size/expiry/correlation) | `port.rs ResultBundle::validate` | +| C10 | crash/restart persistence (append-only revision chains, frozen fields, transition digests) | `execution_bindings.rs`, `transitions.rs` | +| C11 | structured denial and quarantine classification (`RejectionReason` vocabulary) | `contracts/mod.rs RejectionReason` | +| CD | canonical JSON domain (safe integers, depth ≤ 64, ≤ 1 MiB canonical size) | `digest.rs`, `contracts/mod.rs` | + +`run` output is bounded JSON: + +```json +{ + "runner": { "name": "psyche-conformance", "version": "0.1.0" }, + "profile": "consumer-v1", + "artifact_set": "1.0.0", + "checks": [ { "id": "C8-cancellation-evidence", "status": "pass", "failures": [] } ], + "summary": { "status": "pass", "vectors_total": 33, "vectors_failed": 0, "checks_failed": 0 } +} +``` + +Exit codes: `0` pass, `2` failures, `1` usage/IO error. The runner never needs +a daemon, a network, or credentials (non-goal in #11: "Requiring a networked +daemon for ordinary conformance" is explicitly a non-goal). + +The C-S1 through C-S12 suites remain the *evidence baseline* (they test live +Rust boundaries); this artifact set is the *publication format* built on top: +each profile check cites the Rust check it mirrors, and the `crates/psyche-core` +cross-check test decodes the published golden bytes with the real decoder in +CI. + +## 6. Reproducibility and drift gates + +- `node scripts/protocol/generate.mjs` regenerates the entire artifact tree. +- CI job `protocol` runs: generate → `git diff --exit-code -- protocol/` + (uncommitted drift fails the build), the runner's unit tests, conformance + against the published artifacts, and manifest verification. +- The Rust job additionally cross-checks every registry golden vector through + `psyche_core::contracts::decode_document` and byte-compares + `canonical_bytes(&decoded)` against the published file + (`crates/psyche-core/tests/protocol_golden.rs`), so schema drift from the + Rust decoder is caught in both directions. + +## 7. Decision records + +1. **JSON Schema 2020-12 + generated TypeScript, not a binary IDL.** + Alternatives: protobuf/FlatBuffers (rejected: the wire is canonical JSON; + a second schema system would be a second drift surface), hand-written TS + (rejected: drifts). Rust types remain the enforcement authority; schemas are + published projections with per-field citations. +2. **Node runner instead of a cargo binary.** A cargo-run conformance binary + would require a Rust toolchain and building Psyche source, violating "run + conformance without checking out Psyche source". Node is already the + distribution vehicle (`packages/psyche-npm`), and the runner needs only + sha256 + JSON. A future Rust runner could consume the same vectors. +3. **Artifacts under `protocol/` rather than a Rust crate.** A + `psyche-schema` crate would couple consumers to cargo and re-open the + "publishing to crates.io" non-goal; the npm package is the existing + distribution vehicle (`Cargo.toml`: `publish = false; distributed via npm`). +4. **Bundled subset validator instead of Ajv.** The package must be + dependency-free (mirrors `packages/psyche-npm`; no install step in CI) and + bounded. The validator implements exactly the keyword set the published + schemas use and fails closed on anything else, including remote `$ref`s. + Alternative rejected: vendoring Ajv (~large, unnecessary surface). +5. **`psyche.result_bundle` / `psyche.execution_request.v1` classification.** + The core registry does not own these (`decode_document` → `UnknownSchema`); + they are Coven-boundary types pinned by Coven-side goldens. Published as + `experimental` pending #12. Alternative considered: omit them (rejected — + C-S1/C-S10 profiles need them, and withholding the shapes would force + hand-copying, exactly what #11 forbids). +6. **`psyche.transition` has no `.v1` suffix.** It is a store-owned record + with no `schema_version` field of its own; giving it a registry-style id + would imply a document-kind versioning it does not have. The schema id is + stable, and its classification is `stable-v1` (storage surface). + +## 8. Release evidence (template) + +Release evidence must record: source SHA, artifact digests (`MANIFEST.sha256`), +toolchain (node version from CI logs), command (`node scripts/protocol/generate.mjs` ++ runner commands), and result. CI supplies the toolchain/command/result +columns; the tagged release records the resolved values. This PR carries the +draft evidence; the tag finalizes it for #13 pinning. + +## 9. What remains after this PR + +- Tag the first immutable artifact release once #12 assigns cross-repository + ownership (the `experimental` schemas are the boundary under review). +- Publish `@opencoven/psyche-protocol` to npm and record its digests in the + release evidence (#13 pins the set). +- The `downstream dry run` evidence item (a consumer repo executing the + runner against a pinned release) lands with that release. diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index 427a16e..7d85401 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -1,5 +1,11 @@ # G2 Schemas +The published consumer surface for these contracts — versioned JSON Schemas, +canonical golden vectors, the compatibility policy, and the standalone +conformance runner — lives under [`protocol/`](../protocol/README.md) with the +policy in [PROTOCOL.md](PROTOCOL.md). This document remains the description of +the in-tree registry and its decoding rules. + ## Registry and decoding The closed v1 registry is `psyche.identity_snapshot.v1`, `psyche.intent.v1`, diff --git a/packages/psyche-protocol/README.md b/packages/psyche-protocol/README.md new file mode 100644 index 0000000..c2819f2 --- /dev/null +++ b/packages/psyche-protocol/README.md @@ -0,0 +1,23 @@ +# @opencoven/psyche-protocol + +Standalone conformance runner for the published [Psyche protocol v1 +artifact set](../../../protocol/v1). Zero runtime dependencies, no network, no +credentials: point it at an extracted `protocol/v1` directory and it validates +golden vectors and adapter-supplied documents against the published JSON +Schemas and the `consumer-v1` semantic profile. + +```console +$ npx @opencoven/psyche-protocol run --root /path/to/protocol/v1 +$ npx @opencoven/psyche-protocol verify --root /path/to/protocol/v1 +``` + +- `run` evaluates every golden vector in `golden/vectors.json` against the + published schemas plus the semantic checks the Rust decoders enforce, and + exits `0` (pass) or `2` (failures). Validate your own adapter artifacts with + the library API (`lib/run.js`) or by adding them to a copy of the golden + directory and re-running. +- `verify` checks `MANIFEST.sha256` and `golden/vectors.json` digests for + downstream pinning (exit `0` intact, `2` drift). + +The emitted result is a bounded JSON document; see `docs/PROTOCOL.md` for the +profile definition, compatibility policy, and pinning workflow. Node >= 20. diff --git a/packages/psyche-protocol/bin/psyche-conformance.js b/packages/psyche-protocol/bin/psyche-conformance.js new file mode 100755 index 0000000..72a3463 --- /dev/null +++ b/packages/psyche-protocol/bin/psyche-conformance.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// psyche-conformance — standalone conformance runner for the published Psyche +// protocol v1 artifact set. +// +// Commands: +// psyche-conformance run --root [--profile consumer-v1] +// Evaluate every golden vector against the published JSON Schemas and +// the consumer-v1 semantic profile. Exit 0 = pass, 2 = failures. +// psyche-conformance verify --root +// Verify MANIFEST.sha256 and golden/vectors.json digests for downstream +// pinning. Exit 0 = intact, 2 = drift. +// +// Output is a bounded JSON document on stdout; diagnostics go to stderr. +// The runner never performs I/O beyond reading the artifact root, never +// opens a network connection, and never requires credentials. + +import { runConformance, verifyArtifactSet, RUNNER_VERSION } from "../lib/run.js"; + +function usage() { + console.error( + `psyche-conformance ${RUNNER_VERSION} +usage: + psyche-conformance run --root [--profile consumer-v1] + psyche-conformance verify --root `, + ); +} + +function parseArguments(argv) { + const [command, ...rest] = argv; + if (command !== "run" && command !== "verify") return null; + let root = null; + let profile = "consumer-v1"; + for (let index = 0; index < rest.length; index += 1) { + const flag = rest[index]; + if (flag === "--root") { + root = rest[index + 1]; + index += 1; + } else if (flag === "--profile" && command === "run") { + profile = rest[index + 1]; + index += 1; + } else { + return null; + } + } + if (!root) return null; + return { command, root, profile }; +} + +async function main() { + const parsed = parseArguments(process.argv.slice(2)); + if (parsed === null) { + usage(); + process.exit(1); + } + try { + if (parsed.command === "run") { + const result = await runConformance({ root: parsed.root, profile: parsed.profile }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(result.summary.status === "pass" ? 0 : 2); + } + const result = await verifyArtifactSet({ root: parsed.root }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(result.summary.status === "pass" ? 0 : 2); + } catch (error) { + console.error(`psyche-conformance: ${error.message}`); + process.exit(1); + } +} + +await main(); diff --git a/packages/psyche-protocol/lib/canonical.js b/packages/psyche-protocol/lib/canonical.js new file mode 100644 index 0000000..b606f12 --- /dev/null +++ b/packages/psyche-protocol/lib/canonical.js @@ -0,0 +1,99 @@ +// RFC 8785 (JSON Canonicalization Scheme) canonical bytes for Psyche documents. +// +// Psyche's canonicalization authority is `psyche_core::digest::canonical_bytes` +// (crates/psyche-core/src/digest.rs), which serializes into an ordered +// representation and then applies `serde_json_canonicalizer`. RFC 8785 is +// defined by ECMAScript serialization plus code-unit-sorted object keys, so the +// equivalent here is `JSON.stringify` over a deep key-sorted copy. Byte parity +// with the Rust implementation is pinned by tests against Rust-produced +// fixtures whose SHA-256 digests Psyche's own CI pins +// (crates/psyche-coven/tests/request_digest.rs). +// +// The Psyche JSON domain admits only finite numbers whose magnitude fits in +// IEEE-754 double interop (I-JSON safe integers); `canonicalize` rejects +// anything outside it instead of silently emitting a divergent byte stream. + +const MAX_SAFE_INTEGER = 9_007_199_254_740_991n; +const MIN_SAFE_INTEGER = -MAX_SAFE_INTEGER; + +function assertDomain(value, depth, path) { + if (depth > 64) { + throw new TypeError(`json domain violation: nesting deeper than 64 at ${path}`); + } + if (value === null) return; + switch (typeof value) { + case "boolean": + case "string": + return; + case "number": { + if (!Number.isFinite(value)) { + throw new TypeError(`json domain violation: non-finite number at ${path}`); + } + if (Number.isInteger(value)) { + const big = BigInt(value); + if (big > MAX_SAFE_INTEGER || big < MIN_SAFE_INTEGER) { + throw new TypeError(`json domain violation: unsafe integer at ${path}`); + } + return; + } + // Fractional doubles are in-domain; canonical bytes come from + // JSON.stringify's ECMAScript number formatting, exactly like RFC 8785. + return; + } + case "object": { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + assertDomain(value[index], depth + 1, `${path}[${index}]`); + } + return; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`json domain violation: non-plain object at ${path}`); + } + for (const key of Object.keys(value)) { + assertDomain(value[key], depth + 1, `${path}.${key}`); + } + return; + } + default: + throw new TypeError(`json domain violation: unsupported value at ${path}`); + } +} + +// Sort keys by UTF-16 code unit order, the order RFC 8785 §3.2.3 requires. +// JavaScript's default Array#sort is exactly that order for strings. +function sorted(value) { + if (Array.isArray(value)) { + return value.map(sorted); + } + if (value !== null && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sorted(value[key]); + } + return out; + } + return value; +} + +/** + * Returns the RFC 8785 canonical JSON bytes (a UTF-8 `Uint8Array`) for `value`. + * Throws `TypeError` on any value outside the Psyche JSON domain. + * @param {unknown} value + * @returns {Uint8Array} + */ +export function canonicalBytes(value) { + assertDomain(value, 1, "$"); + const text = JSON.stringify(sorted(value)); + return new TextEncoder().encode(text); +} + +/** + * Convenience wrapper returning the canonical JSON as a string. + * @param {unknown} value + * @returns {string} + */ +export function canonicalJson(value) { + return new TextDecoder().decode(canonicalBytes(value)); +} diff --git a/packages/psyche-protocol/lib/digest.js b/packages/psyche-protocol/lib/digest.js new file mode 100644 index 0000000..874b931 --- /dev/null +++ b/packages/psyche-protocol/lib/digest.js @@ -0,0 +1,52 @@ +// SHA-256 digest helper matching `psyche_core::digest`. +// +// Every Psyche digest is `sha256:` followed by 64 lowercase hex characters of +// SHA-256 over complete canonical JSON bytes (crates/psyche-core/src/digest.rs, +// `Sha256Digest` and `digest()`). `sha256OfBytes` covers raw byte payloads +// (the `ContentAddressedReference` form); `sha256OfValue` covers canonicalized +// documents. + +import { createHash } from "node:crypto"; +import { canonicalBytes } from "./canonical.js"; + +export const DIGEST_PREFIX = "sha256:"; +export const DIGEST_PATTERN = "^sha256:[0-9a-f]{64}$"; +export const HEX_DIGEST_PATTERN = "[0-9a-f]{64}"; + +/** + * SHA-256 of raw bytes, hex-encoded lowercase. + * @param {Uint8Array | string} bytes + * @returns {string} + */ +export function hexSha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * `sha256:` of raw bytes. + * @param {Uint8Array | string} bytes + * @returns {string} + */ +export function sha256OfBytes(bytes) { + return `${DIGEST_PREFIX}${hexSha256(bytes)}`; +} + +/** + * `sha256:` over the RFC 8785 canonical bytes of `value`. + * @param {unknown} value + * @returns {string} + */ +export function sha256OfValue(value) { + return sha256OfBytes(canonicalBytes(value)); +} + +/** + * Structural digest validation only (prefix + 64 lowercase hex), matching + * `Sha256Digest::parse` without recomputation. + * @param {string} value + * @returns {boolean} + */ +export function isDigestShape(value) { + return typeof value === "string" && value.length === 71 && value.startsWith(DIGEST_PREFIX) && + /^[0-9a-f]{64}$/.test(value.slice(DIGEST_PREFIX.length)); +} diff --git a/packages/psyche-protocol/lib/profile.js b/packages/psyche-protocol/lib/profile.js new file mode 100644 index 0000000..be69f8c --- /dev/null +++ b/packages/psyche-protocol/lib/profile.js @@ -0,0 +1,612 @@ +// Consumer conformance profile (consumer-v1) for the Psyche protocol v1 +// artifact set. +// +// JSON Schemas pin structure; this profile pins the semantic rules the +// decoders enforce beyond structure. Every rule here is a faithful port of an +// enforcing check in Rust, cited per check. Rules a later authority owner +// still owns (docs/SCHEMAS.md "Deferred owners") are deliberately absent — +// the profile never invents policy. +// +// Check ids follow the required consumer profile in issue +// OpenCoven/psyche#11: +// C1 contract and capability negotiation +// C2 stable session/execution correlation +// C3 snapshot/attempt binding +// C4 adoption and durable non-adoption proof +// C5 ambiguity reconciliation and fencing (shape; policy deferred) +// C6 ordered cursor/restart behavior +// C7 terminal authority (shape; authority rules live in the store) +// C8 cancellation acknowledgement and unresolved outcomes +// C9 result/artifact binding +// C10 crash/restart persistence (append-only revision chains) +// C11 structured denial and quarantine classification +// CD canonical JSON domain (safe integers, depth, canonical size) + +import { canonicalBytes } from "./canonical.js"; +import { sha256OfValue, sha256OfBytes } from "./digest.js"; + +export const PROFILE_ID = "consumer-v1"; + +const MAX_DOCUMENT_BYTES = 1_048_576; +const MAX_SAFE_INTEGER = 9_007_199_254_740_991; +const MAX_JSON_DEPTH = 64; + +const FROZEN_EXECUTION_FIELDS = [ + "attempt_id", + "familiar_snapshot_id", + "project_id", + "request_id", + "request_digest", + "request_created_at", + "request_valid_until", + "coven_contract_version", +]; + +class Failures { + constructor() { + this.list = []; + } + + add(check, detail) { + this.list.push({ check, detail }); + } + + unless(condition, check, detail) { + if (!condition) this.add(check, detail); + return condition; + } +} + +function timestampMs(value) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +// --------------------------------------------------------------------------- +// CD — canonical JSON domain. Ports validate_json_domain and +// MAX_DOCUMENT_BYTES (crates/psyche-core/src/contracts/mod.rs, digest.rs). +// --------------------------------------------------------------------------- + +export function checkJsonDomain(record, doc, canonical, failures) { + const walk = (value, depth, path) => { + if (depth > MAX_JSON_DEPTH) { + failures.add("CD-json-domain", `${path}: nesting deeper than ${MAX_JSON_DEPTH}`); + return; + } + if (value === null || typeof value === "boolean" || typeof value === "string") return; + if (typeof value === "number") { + if (!Number.isFinite(value)) { + failures.add("CD-json-domain", `${path}: non-finite number`); + } else if (Number.isInteger(value)) { + const magnitude = Math.abs(value); + if (magnitude > MAX_SAFE_INTEGER) { + failures.add("CD-json-domain", `${path}: integer outside the safe range`); + } + } + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => walk(entry, depth + 1, `${path}[${index}]`)); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (record?.key === "intent" && path === "$" && key === "constraints") { + // Intent::validate bounds constraint keys at 256 bytes. + for (const constraintKey of Object.keys(entry ?? {})) { + if (new TextEncoder().encode(constraintKey).length > 256) { + failures.add("CD-json-domain", `$.constraints key longer than 256 bytes`); + } + } + walk(entry, depth + 1, `${path}.${key}`); + continue; + } + if (record?.key === "error" && path === "$.error" && key === "details") { + // ErrorBody bounds detail keys at 256 bytes (values are bounded by + // the schema's additionalProperties maxLength). + for (const detailKey of Object.keys(entry ?? {})) { + if (new TextEncoder().encode(detailKey).length > 256) { + failures.add("CD-json-domain", `$.error.details key longer than 256 bytes`); + } + } + walk(entry, depth + 1, `${path}.${key}`); + continue; + } + walk(entry, depth + 1, `${path}.${key}`); + } + }; + walk(doc, 1, "$"); + if (canonical.length > MAX_DOCUMENT_BYTES) { + failures.add("CD-json-domain", `document exceeds ${MAX_DOCUMENT_BYTES} canonical bytes`); + } +} + +// --------------------------------------------------------------------------- +// C8 — cancellation evidence matrix. Ports ExecutionBinding::validate +// and validate_cancellation (crates/psyche-core/src/contracts/execution.rs). +// --------------------------------------------------------------------------- + +const TERMINATION_STATE_MAP = { + acknowledged_terminated: "terminated", + acknowledged_already_terminal: "already_authoritatively_terminal", +}; + +function isAllZeroDigest(digest) { + return digest.slice("sha256:".length) === "0".repeat(64); +} + +function terminationRequestMatches(wire, request) { + return ( + wire.termination_request_id === request.termination_request_id && + wire.created_at === request.created_at && + wire.valid_until === request.valid_until + ); +} + +export function checkBindingCancellation(record, doc, canonical, failures) { + const evidenceFields = [ + "termination_request", + "termination_reason_code", + "cancellation_acknowledgement", + "cancellation_unresolved", + ]; + const empty = evidenceFields.every((field) => doc[field] === null || doc[field] === undefined); + if (doc.request_valid_until === undefined || doc.request_created_at === undefined) return; + + // validate(): request window must be forward-looking. + failures.unless( + timestampMs(doc.request_valid_until) > timestampMs(doc.request_created_at), + "C8-cancellation-evidence", + "request_valid_until is not after request_created_at", + ); + + if (doc.cancellation_state === "not_requested") { + failures.unless( + empty, + "C8-cancellation-evidence", + "cancellation_state not_requested with cancellation evidence present", + ); + return; + } + + const correlation = doc.termination_request; + if (!failures.unless(correlation !== null && correlation !== undefined, "C8-cancellation-evidence", "cancellation evidence without a termination request correlation")) return; + const reason = doc.termination_reason_code; + if (!failures.unless(typeof reason === "string", "C8-cancellation-evidence", "cancellation evidence without a termination reason code")) return; + failures.unless( + timestampMs(correlation.created_at) >= timestampMs(doc.request_created_at), + "C8-cancellation-evidence", + "termination request predates the execution request", + ); + failures.unless( + correlation.termination_request_id !== doc.request_id, + "C8-cancellation-evidence", + "termination_request_id equals the execution request_id", + ); + + const ack = doc.cancellation_acknowledgement; + const unresolved = doc.cancellation_unresolved; + switch (doc.cancellation_state) { + case "termination_requested": + failures.unless( + ack === null && unresolved === null, + "C8-cancellation-evidence", + "termination_requested must not carry acknowledgement or unresolved evidence", + ); + break; + case "acknowledged_terminated": + case "acknowledged_already_terminal": { + failures.unless( + unresolved === null, + "C8-cancellation-evidence", + "acknowledged state carries unresolved evidence", + ); + if (ack === null || ack === undefined) { + failures.add("C8-cancellation-evidence", "acknowledged state without acknowledgement evidence"); + return; + } + failures.unless( + ack.kind === TERMINATION_STATE_MAP[doc.cancellation_state], + "C8-cancellation-evidence", + `acknowledgement kind ${JSON.stringify(ack.kind)} does not match ${doc.cancellation_state}`, + ); + failures.unless( + !isAllZeroDigest(ack.authority_evidence_digest), + "C8-cancellation-evidence", + "authority_evidence_digest is the all-zero digest", + ); + checkEvidenceBindings(doc, ack, failures); + failures.unless( + inWindow(ack.acknowledged_at, correlation), + "C8-cancellation-evidence", + "acknowledgement time falls outside the termination request window", + ); + break; + } + case "termination_unknown": + failures.unless( + ack === null, + "C8-cancellation-evidence", + "termination_unknown carries acknowledgement evidence", + ); + if (unresolved === null || unresolved === undefined) { + failures.add("C8-cancellation-evidence", "termination_unknown without unresolved evidence"); + return; + } + checkEvidenceBindings(doc, unresolved, failures); + failures.unless( + inWindow(unresolved.recorded_at, correlation), + "C8-cancellation-evidence", + "unresolved disposition time falls outside the termination request window", + ); + break; + default: + failures.add("C8-cancellation-evidence", `unknown cancellation_state ${doc.cancellation_state}`); + } +} + +function checkEvidenceBindings(doc, evidence, failures) { + failures.unless( + evidence.termination_request_id === doc.termination_request?.termination_request_id, + "C8-cancellation-evidence", + "evidence termination_request_id does not match the binding's termination request", + ); + failures.unless( + evidence.session_id === doc.coven_session_id, + "C8-cancellation-evidence", + "evidence session_id does not match coven_session_id (validate_evidence_bindings)", + ); + failures.unless( + evidence.execution_request_id === doc.request_id, + "C8-cancellation-evidence", + "evidence execution_request_id does not match request_id", + ); + failures.unless( + evidence.execution_request_digest === doc.request_digest, + "C8-cancellation-evidence", + "evidence execution_request_digest does not match request_digest", + ); +} + +function inWindow(at, correlation) { + const value = timestampMs(at); + return ( + value !== null && + value >= timestampMs(correlation.created_at) && + value <= timestampMs(correlation.valid_until) + ); +} + +// --------------------------------------------------------------------------- +// Binding revision shape + C4 adoption vocabulary. Ports +// ExecutionBinding::validate revision rules (contracts/execution.rs). +// --------------------------------------------------------------------------- + +export function checkBindingRevision(record, doc, canonical, failures) { + const hasPrevious = doc.previous_revision_digest !== null && doc.previous_revision_digest !== undefined; + failures.unless( + (doc.revision === 1) === !hasPrevious, + "C10-revision-chain", + `revision ${doc.revision} with previous_revision_digest ${hasPrevious ? "present" : "absent"} (revision 1 must have none; later revisions must bind one)`, + ); +} + +// --------------------------------------------------------------------------- +// C9 — result/artifact binding. Ports ResultBundle::validate +// (crates/psyche-coven/src/port.rs). +// --------------------------------------------------------------------------- + +export function checkResultBinding(record, doc, canonical, failures) { + const correlation = doc.correlation; + failures.unless( + timestampMs(correlation.valid_until) > timestampMs(correlation.created_at), + "C9-result-binding", + "correlation valid_until is not after created_at", + ); + const result = doc.result; + failures.unless( + timestampMs(result.expires_at) > timestampMs(correlation.created_at) && + timestampMs(result.expires_at) <= timestampMs(correlation.valid_until), + "C9-result-binding", + "result expires_at outside the correlation lifetime", + ); + failures.unless( + doc.artifacts.length <= 1024, + "C9-result-binding", + "more than 1024 artifacts", + ); + const seen = new Set(); + for (const artifact of doc.artifacts) { + if (!failures.unless(!seen.has(artifact.artifact_id), "C9-result-binding", `duplicate artifact_id ${artifact.artifact_id}`)) continue; + seen.add(artifact.artifact_id); + failures.unless( + artifact.session_id === doc.session_id, + "C9-result-binding", + `artifact ${artifact.artifact_id} session_id differs from the bundle session`, + ); + failures.unless( + jsonDeepEqual(artifact.correlation, correlation), + "C9-result-binding", + `artifact ${artifact.artifact_id} correlation differs from the bundle correlation`, + ); + failures.unless( + timestampMs(artifact.content.expires_at) > timestampMs(correlation.created_at) && + timestampMs(artifact.content.expires_at) <= timestampMs(result.expires_at) && + timestampMs(artifact.content.expires_at) <= timestampMs(correlation.valid_until), + "C9-result-binding", + `artifact ${artifact.artifact_id} content expiry outside (created_at, result.expires_at] and the correlation window`, + ); + } +} + +function jsonDeepEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +// --------------------------------------------------------------------------- +// C1 — negotiation. Ports ExecutionRequestInput::validate +// (crates/psyche-coven/src/port.rs). +// --------------------------------------------------------------------------- + +export function checkNegotiation(record, doc, canonical, failures) { + failures.unless( + doc.harness === undefined || doc.harness === "codex", + "C1-negotiation", + `unsupported harness ${JSON.stringify(doc.harness)}`, + ); + for (const field of ["project_root", "cwd"]) { + if (doc[field] !== undefined) { + failures.unless( + isValidAbsolutePath(doc[field]), + "C1-negotiation", + `${field} is not a canonical absolute path`, + ); + } + } + failures.unless( + timestampMs(doc.valid_until) > timestampMs(doc.created_at), + "C1-negotiation", + "valid_until is not after created_at", + ); + const ids = new Set(); + for (const binding of doc.required_artifact_bindings ?? []) { + failures.unless( + !ids.has(binding.artifact_id), + "C1-negotiation", + `duplicate artifact binding ${binding.artifact_id}`, + ); + ids.add(binding.artifact_id); + } + failures.unless( + (doc.required_artifact_bindings ?? []).length <= 1024, + "C1-negotiation", + "more than 1024 artifact bindings", + ); +} + +function isValidAbsolutePath(value) { + if (value === "/") return true; + if (value.length === 0 || value.length > 4096 || !value.startsWith("/")) return false; + if (value.includes("\0") || value.includes("//")) return false; + if (value.endsWith("/")) return false; + return value + .split("/") + .slice(1) + .every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +// --------------------------------------------------------------------------- +// Effect digest recomputation. Ports SurfaceEffect::validate / +// Delivery::validate (crates/psyche-core/src/contracts/surface.rs). +// --------------------------------------------------------------------------- + +export function checkEffectDigest(record, doc, canonical, failures) { + failures.unless( + sha256OfValue(doc.effect) === doc.effect_digest, + "C9-effect-digest", + "effect_digest does not match the canonical digest of effect", + ); +} + +export function checkDeliverySentBinding(record, doc, canonical, failures) { + const sent = doc.state === "sent"; + const hasMessageId = doc.telegram_message_id !== null && doc.telegram_message_id !== undefined; + failures.unless( + sent === hasMessageId, + "C9-effect-digest", + "state sent must coincide with telegram_message_id presence", + ); +} + +// --------------------------------------------------------------------------- +// Transition digest. Ports Transition::computed_digest +// (crates/psyche-store/src/transitions.rs): every field except the digest +// itself, canonically digested. +// --------------------------------------------------------------------------- + +export function checkTransitionDigest(record, doc, canonical, failures) { + const { transition_digest: _claimed, ...input } = doc; + failures.unless( + sha256OfValue(input) === doc.transition_digest, + "C10-transition-digest", + "transition_digest does not match the canonical digest of the other fields", + ); + const first = doc.from_state === null || doc.from_state === undefined; + failures.unless( + (doc.record_version === 1) === first, + "C10-transition-digest", + "record_version 1 must have no from_state; later versions must name one", + ); + if (!first) { + failures.unless( + doc.from_state !== doc.to_state, + "C10-transition-digest", + "from_state equals to_state", + ); + } +} + +// --------------------------------------------------------------------------- +// C10 — crash/restart revision chains. Ports +// validate_revision_chain + the *_binding_is_append_only family +// (crates/psyche-store/src/execution_bindings.rs). +// --------------------------------------------------------------------------- + +export function checkRevisionChain(revision1, revision2) { + const failures = new Failures(); + failures.unless( + revision2.revision === revision1.revision + 1, + "C10-revision-chain", + "revisions are not consecutive", + ); + failures.unless( + revision2.previous_revision_digest === sha256OfBytes(canonicalBytes(revision1)), + "C10-revision-chain", + "previous_revision_digest does not bind revision 1's canonical bytes", + ); + failures.unless( + timestampMs(revision2.revision_created_at) > timestampMs(revision1.revision_created_at), + "C10-revision-chain", + "revision_created_at is not strictly increasing", + ); + for (const field of FROZEN_EXECUTION_FIELDS) { + failures.unless( + jsonDeepEqual(revision1[field], revision2[field]), + "C10-revision-chain", + `frozen field ${field} changed between revisions`, + ); + } + // session_binding_is_append_only + if (revision1.coven_session_id !== null) { + failures.unless( + revision2.coven_session_id === revision1.coven_session_id, + "C10-revision-chain", + "session binding was cleared or rebound", + ); + } + // termination_binding_is_append_only + if (revision1.termination_request !== null) { + failures.unless( + revision2.termination_request !== null && + terminationRequestMatches(revision2.termination_request, revision1.termination_request), + "C10-revision-chain", + "termination request correlation was removed or changed", + ); + failures.unless( + revision2.termination_reason_code === revision1.termination_reason_code, + "C10-revision-chain", + "termination_reason_code changed between revisions", + ); + } + // cancellation_binding_is_append_only + const from = revision1.cancellation_state; + const to = revision2.cancellation_state; + const allowed = { + not_requested: ["not_requested", "termination_requested"], + termination_requested: [ + "termination_requested", + "acknowledged_terminated", + "acknowledged_already_terminal", + "termination_unknown", + ], + acknowledged_terminated: ["acknowledged_terminated"], + acknowledged_already_terminal: ["acknowledged_already_terminal"], + termination_unknown: ["termination_unknown"], + }[from] ?? []; + failures.unless( + allowed.includes(to), + "C10-revision-chain", + `cancellation_state ${JSON.stringify(from)} cannot transition to ${JSON.stringify(to)}`, + ); + if (from === to && from !== "not_requested" && from !== "termination_requested") { + failures.unless( + jsonDeepEqual(revision1.cancellation_acknowledgement, revision2.cancellation_acknowledgement) && + jsonDeepEqual(revision1.cancellation_unresolved, revision2.cancellation_unresolved), + "C10-revision-chain", + "terminal cancellation evidence changed between revisions", + ); + } + return failures.list; +} + +// --------------------------------------------------------------------------- +// C11 — quarantine classification of rejected documents, mirroring +// RejectionReason and RejectedDocument::from_decode_error +// (crates/psyche-core/src/contracts/mod.rs). +// --------------------------------------------------------------------------- + +export function classifyRejection(documentText, recordKeys) { + let doc; + try { + doc = JSON.parse(documentText); + } catch { + return { quarantine_class: "invalid_shape", detail: "not valid JSON" }; + } + const declared = doc?.schema_version; + if (typeof declared !== "string") { + return { quarantine_class: "invalid_shape", detail: "schema_version missing or not a string" }; + } + const match = /^psyche\.([a-z_]+)\.v(\d+)$/.exec(declared); + if (!match || !recordKeys.includes(match[1])) { + return { quarantine_class: "unknown_schema", detail: `kind outside the registry: ${declared}` }; + } + if (match[2] !== "1") { + return { + quarantine_class: "unsupported_major", + detail: `major ${match[2]} is not supported by this artifact set`, + }; + } + return { quarantine_class: "invalid_shape", detail: "document shape rejected by the decoder" }; +} + +// Per-record profile check wiring. Record keys come from definitions.mjs +// (published in inventory.json). +export const DOCUMENT_CHECKS = { + identity_snapshot: ["CD-json-domain"], + intent: ["CD-json-domain"], + surface_event: ["CD-json-domain"], + graph: [], + graph_node: [], + delegation: [], + budget: [], + approval: [], + execution_binding: ["CD-json-domain", "C10-revision-chain", "C8-cancellation-evidence"], + evidence: [], + verdict: [], + recovery: [], + addon: [], + surface_effect: ["CD-json-domain", "C9-effect-digest"], + delivery: ["CD-json-domain", "C9-effect-digest"], + error: ["CD-json-domain"], + execution_request: ["CD-json-domain", "C1-negotiation"], + result_bundle: ["CD-json-domain", "C9-result-binding"], + transition: ["C10-transition-digest"], +}; + +const CHECK_IMPLEMENTATIONS = { + "CD-json-domain": checkJsonDomain, + "C10-revision-chain": checkBindingRevision, + "C8-cancellation-evidence": checkBindingCancellation, + "C9-result-binding": checkResultBinding, + "C1-negotiation": checkNegotiation, + "C9-effect-digest": checkEffectDigest, + "C10-transition-digest": checkTransitionDigest, +}; + +// delivery's sent-binding piggybacks on the effect digest check. +export function documentChecks(record, doc, canonical, failures) { + for (const checkId of DOCUMENT_CHECKS[record] ?? []) { + CHECK_IMPLEMENTATIONS[checkId](record, doc, canonical, failures); + } + if (record === "delivery") checkDeliverySentBinding(record, doc, canonical, failures); +} + +// Human-readable names used in the machine-readable run result. +export const CHECK_NAMES = { + "C1-negotiation": "C1 contract and capability negotiation", + "C8-cancellation-evidence": "C8 cancellation acknowledgement and unresolved outcomes", + "C9-effect-digest": "C9 result/effect content binding", + "C9-result-binding": "C9 result/artifact binding", + "C10-revision-chain": "C10 crash/restart revision chain discipline", + "C10-transition-digest": "C10 transition digest binding", + "CD-json-domain": "CD canonical JSON domain", +}; diff --git a/packages/psyche-protocol/lib/run.js b/packages/psyche-protocol/lib/run.js new file mode 100644 index 0000000..d5f737c --- /dev/null +++ b/packages/psyche-protocol/lib/run.js @@ -0,0 +1,350 @@ +// Conformance orchestration: consumes a published protocol artifact directory +// (schemas/, golden/, inventory.json, MANIFEST.sha256) and evaluates every +// golden vector against the published JSON Schemas plus the consumer-v1 +// semantic profile. Emits a bounded machine-readable result. + +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { canonicalBytes } from "./canonical.js"; +import { hexSha256, sha256OfBytes } from "./digest.js"; +import { + CHECK_NAMES, + PROFILE_ID, + checkRevisionChain, + classifyRejection, + documentChecks, +} from "./profile.js"; +import { validate } from "./schema.js"; + +export const RUNNER_VERSION = "0.1.0"; +const MAX_FAILURES_PER_CHECK = 20; + +class Failures { + constructor() { + this.list = []; + } + + add(check, detail) { + this.list.push({ check, detail }); + } + + unless(condition, check, detail) { + if (!condition) this.add(check, detail); + return condition; + } +} + +async function loadArtifacts(root) { + const inventory = JSON.parse(await readFile(path.join(root, "inventory.json"), "utf8")); + const vectors = JSON.parse(await readFile(path.join(root, "golden", "vectors.json"), "utf8")); + const schemas = new Map(); + for (const record of inventory.records) { + const text = await readFile(path.join(root, record.file), "utf8"); + schemas.set(record.key, JSON.parse(text)); + } + return { inventory, vectors, schemas }; +} + +function schemaErrorsToString(errors) { + return errors + .slice(0, 3) + .map((error) => `${error.path}: ${error.keyword} (${error.message})`) + .join("; "); +} + +// Maps schema validation failures onto the RejectionReason vocabulary +// (crates/psyche-core/src/contracts/mod.rs). A known kind at major 1 that +// fails enum membership is UnknownEnumValue; every other structural failure +// maps onto InvalidShape, matching how the typed decoder reports serde +// denials. +function classifySchemaErrors(errors) { + if (errors.some((error) => error.keyword === "enum")) { + return { quarantine_class: "unknown_enum_value", detail: "unknown enum spelling" }; + } + return { quarantine_class: "invalid_shape", detail: "document shape rejected" }; +} + +async function evaluateVector(vector, root, schemas, baseline) { + const file = path.join(root, vector.file); + const bytes = await readFile(file); + const failures = new Failures(); + + failures.unless( + `sha256:${hexSha256(bytes)}` === vector.sha256, + "manifest-digests", + `${vector.file}: published digest does not match the file bytes`, + ); + + const text = bytes.toString("utf8"); + let doc = null; + let parseError = null; + try { + doc = JSON.parse(text); + } catch (error) { + parseError = error; + } + + if (vector.expect === "accept") { + if (!failures.unless(parseError === null, "vectors", `${vector.file}: ${parseError}`)) { + return failures.list; + } + // Byte-copied execution-request vectors carry an ":operation" suffix to + // identify their variant; the schema is selected by the record key. + const recordKey = vector.record.split(":")[0]; + const schema = schemas.get(recordKey); + if (!failures.unless(schema !== undefined, "vectors", `${vector.file}: no schema for ${recordKey}`)) { + return failures.list; + } + const result = validate(schema, doc); + if (!failures.unless(result.valid, "vectors", `${vector.file}: schema rejected a positive vector: ${schemaErrorsToString(result.errors)}`)) { + return failures.list; + } + const canonical = canonicalBytes(doc); + if (!failures.unless( + Buffer.from(canonical).equals(bytes), + "vectors", + `${vector.file}: file bytes are not the canonical rendering of the document`, + )) { + return failures.list; + } + documentChecks(recordKey, doc, canonical, failures); + if (vector.class === "crash-restart" && vector.file.endsWith("revision-2.json")) { + for (const failure of checkRevisionChain(baseline.revision1, doc)) { + failures.add(failure.check, `${vector.file}: ${failure.detail}`); + } + } + return failures.list; + } + + // Reject vectors. + if (parseError !== null) { + // Undecodable bytes are already a denial; classify as invalid shape. + failures.unless( + vector.quarantine_class === "invalid_shape", + "vectors", + `${vector.file}: unparsable vector must classify as invalid_shape`, + ); + return failures.list; + } + const recordKey = vector.record === null ? null : vector.record.split(":")[0]; + const schema = recordKey ? schemas.get(recordKey) : undefined; + const schemaResult = schema ? validate(schema, doc) : { valid: false, errors: [] }; + + if (vector.failure_surface === "profile") { + // Schema-valid, profile-rejected: the schema must accept the document and + // the semantic profile (or a cross-check) must fail. The expected + // rejection is success, not failure. + if (failures.unless(schemaResult.valid, "vectors", `${vector.file}: expected a schema-valid document`)) { + const canonical = canonicalBytes(doc); + const semantic = new Failures(); + documentChecks(recordKey, doc, canonical, semantic); + let rejected = semantic.list.length > 0; + let detail = semantic.list.map((failure) => failure.detail).join("; "); + if (vector.class === "crash-restart") { + const chainFailures = checkRevisionChain(baseline.revision1, doc); + if (chainFailures.length > 0) { + rejected = true; + detail = chainFailures.map((failure) => failure.detail).join("; "); + failures.unless( + chainFailures.every((failure) => failure.check === "C10-revision-chain"), + "vectors", + `${vector.file}: chain failure reported under an unexpected check`, + ); + } + } + failures.unless( + rejected, + "vectors", + `${vector.file}: expected a profile rejection but every check passed`, + ); + if (rejected) { + failures.unless( + vector.reason !== null, + "vectors", + `${vector.file}: rejection without an expected reason`, + ); + } + } + return failures.list; + } + + // Decode-surface rejection: the published schema or the decoder-enforced + // profile must fail closed, and the classification must match the + // RejectionReason vocabulary. + let rejected = false; + let classification = { quarantine_class: "invalid_shape", detail: "" }; + let detail = ""; + if (!schema) { + // No published schema: the decoder answers UnknownSchema or + // UnsupportedMajor straight from the declared schema_version. + classification = classifyRejection(text, baseline.recordKeys); + rejected = + classification.quarantine_class === "unknown_schema" || + classification.quarantine_class === "unsupported_major"; + detail = classification.detail; + } else if (!schemaResult.valid) { + rejected = true; + classification = classifySchemaErrors(schemaResult.errors); + detail = schemaErrorsToString(schemaResult.errors); + } else { + const canonical = canonicalBytes(doc); + const semantic = new Failures(); + documentChecks(recordKey, doc, canonical, semantic); + if (semantic.list.length > 0) { + rejected = true; + // A schema-valid document the decoder denies maps onto + // RejectionReason::InvalidShape (RejectedDocument::from_decode_error). + classification = { quarantine_class: "invalid_shape", detail: "decoder-denied semantics" }; + detail = semantic.list.map((failure) => failure.detail).join("; "); + } + } + failures.unless(rejected, "vectors", `${vector.file}: rejection vector was accepted`); + if (rejected) { + failures.unless( + classification.quarantine_class === vector.quarantine_class, + "vectors", + `${vector.file}: classified ${classification.quarantine_class}, expected ${vector.quarantine_class}`, + ); + } + return failures.list; +} + +export async function runConformance({ root, profile = PROFILE_ID }) { + const { inventory, vectors, schemas } = await loadArtifacts(root); + const recordKeys = inventory.records.map((record) => record.key); + const revision1Vector = vectors.vectors.find( + (vector) => vector.file === "golden/positive/crash-restart/revision-1.json", + ); + const revision1 = JSON.parse( + (await readFile(path.join(root, "golden", "positive", "crash-restart", "revision-1.json"), "utf8")), + + ); + + const failuresByCheck = new Map(); + const collect = (failures) => { + for (const failure of failures) { + const list = failuresByCheck.get(failure.check) ?? []; + if (list.length < MAX_FAILURES_PER_CHECK) list.push(failure.detail); + failuresByCheck.set(failure.check, list); + } + }; + + const baseline = { revision1, recordKeys }; + let vectorsFailed = 0; + for (const vector of vectors.vectors) { + const failures = await evaluateVector(vector, root, schemas, baseline); + if (failures.length > 0) vectorsFailed += 1; + collect(failures); + } + + const checks = []; + for (const [checkId, details] of [...failuresByCheck.entries()].sort()) { + checks.push({ + id: checkId, + name: CHECK_NAMES[checkId] ?? checkId, + status: "fail", + failures: details, + }); + } + for (const checkId of Object.keys(CHECK_NAMES).concat(["manifest-digests", "vectors"])) { + if (!failuresByCheck.has(checkId)) { + checks.push({ id: checkId, name: CHECK_NAMES[checkId] ?? checkId, status: "pass", failures: [] }); + } + } + checks.sort((a, b) => a.id.localeCompare(b.id)); + + const vectorsTotal = vectors.vectors.length; + return { + runner: { name: "psyche-conformance", version: RUNNER_VERSION }, + profile, + artifact_set: inventory.artifact_set, + root: path.resolve(root), + checks, + summary: { + status: vectorsFailed === 0 && failuresByCheck.size === 0 ? "pass" : "fail", + vectors_total: vectorsTotal, + vectors_failed: vectorsFailed, + checks_failed: checks.filter((check) => check.status === "fail").length, + }, + }; +} + +// --------------------------------------------------------------------------- +// verify — artifact-set integrity for downstream pinning. +// --------------------------------------------------------------------------- + +async function listFiles(dir, prefix = "") { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listFiles(path.join(dir, entry.name), relative))); + } else { + files.push(relative); + } + } + return files; +} + +export async function verifyArtifactSet({ root }) { + const failures = new Failures(); + const manifestPath = path.join(root, "MANIFEST.sha256"); + const manifestText = await readFile(manifestPath, "utf8"); + const entries = new Map(); + for (const line of manifestText.split("\n")) { + if (line.trim() === "") continue; + const [digest, file] = line.split(" "); + if (!digest || !file || entries.has(file)) { + failures.add("manifest-integrity", `malformed manifest line: ${line}`); + continue; + } + entries.set(file, digest); + } + + const files = (await listFiles(root)).filter((file) => file !== "MANIFEST.sha256"); + for (const file of files) { + const expected = entries.get(file); + if (!failures.unless(expected !== undefined, "manifest-integrity", `${file}: not covered by MANIFEST.sha256`)) { + continue; + } + const digest = hexSha256(await readFile(path.join(root, file))); + failures.unless( + digest === expected, + "manifest-integrity", + `${file}: digest drift (manifest ${expected}, actual ${digest})`, + ); + } + for (const file of entries.keys()) { + if (!files.includes(file)) { + failures.add("manifest-integrity", `${file}: listed in MANIFEST.sha256 but missing`); + } + } + + const vectors = JSON.parse(await readFile(path.join(root, "golden", "vectors.json"), "utf8")); + for (const vector of vectors.vectors) { + const bytes = await readFile(path.join(root, "golden", vector.file.replace(/^golden\//, ""))); + failures.unless( + `sha256:${hexSha256(bytes)}` === vector.sha256, + "vector-digests", + `${vector.file}: vectors.json digest does not match file bytes`, + ); + } + + const checks = ["manifest-integrity", "vector-digests"].map((id) => ({ + id, + status: failures.list.some((failure) => failure.check === id) ? "fail" : "pass", + failures: failures.list.filter((failure) => failure.check === id).map((failure) => failure.detail), + })); + return { + runner: { name: "psyche-conformance", version: RUNNER_VERSION }, + root: path.resolve(root), + checks, + summary: { + status: failures.list.length === 0 ? "pass" : "fail", + files_verified: files.length, + vectors_verified: vectors.vectors.length, + failures: failures.list.length, + }, + }; +} diff --git a/packages/psyche-protocol/lib/schema.js b/packages/psyche-protocol/lib/schema.js new file mode 100644 index 0000000..0c47545 --- /dev/null +++ b/packages/psyche-protocol/lib/schema.js @@ -0,0 +1,329 @@ +// Minimal, dependency-free JSON Schema (draft 2020-12 subset) validator for +// the published Psyche protocol schemas. +// +// The published schemas only use the keywords implemented here. Anything +// outside the allowlist — including remote $refs — fails closed with an +// "unsupported keyword" error rather than passing silently. String length +// keywords measure UTF-8 bytes, matching the Rust decoders, which bound byte +// length rather than UTF-16 code units. + +export const SUPPORTED_DIALECT = "https://json-schema.org/draft/2020-12/schema"; + +const MAX_ERRORS = 32; +const MAX_DEPTH = 64; +const MAX_REF_DEPTH = 64; +// Published patterns are linear and their fields are short (identifiers, +// digests, timestamps). A string longer than this bound is rejected on the +// length keywords long before pattern evaluation, so regex input is bounded. +const MAX_PATTERN_INPUT_BYTES = 65_536; + +const SUPPORTED_KEYWORDS = new Set([ + "$schema", "$id", "title", "description", "type", "const", "enum", "pattern", + "minLength", "maxLength", "minimum", "maximum", "items", "minItems", + "maxItems", "properties", "required", "additionalProperties", + "minProperties", "anyOf", "oneOf", "$ref", "$defs", "format", +]); + +const UNSUPPORTED_KEYWORD_RE = + /^(?:\$(?:schema|id)|title|description|x-[A-Za-z0-9-]+)$/; + +function isAllowedKeyword(keyword) { + return SUPPORTED_KEYWORDS.has(keyword) || UNSUPPORTED_KEYWORD_RE.test(keyword); +} + +/** + * Parses schema text and fails closed on any unsupported construct. + * @param {string} text schema JSON text + * @returns {object} parsed schema + */ +export function loadSchema(text) { + const schema = JSON.parse(text); + assertSupported(schema, "#"); + return schema; +} + +function assertSupported(node, path) { + if (node === null || typeof node !== "object" || Array.isArray(node)) return; + if (node.$schema !== undefined && node.$schema !== SUPPORTED_DIALECT) { + throw new Error( + `${path}: unsupported dialect ${JSON.stringify(node.$schema)}`, + ); + } + if (node.$ref !== undefined && !node.$ref.startsWith("#/")) { + throw new Error(`${path}: only local "#/..." $refs are supported`); + } + if (node.format !== undefined && node.format !== "date-time") { + throw new Error(`${path}: unsupported format ${JSON.stringify(node.format)}`); + } + for (const [key, value] of Object.entries(node)) { + if (key === "properties" || key === "$defs") { + // Values are subschemas keyed by arbitrary names, not keywords. + for (const [name, sub] of Object.entries(value)) { + assertSupported(sub, `${path}/${key}/${name}`); + } + continue; + } + if (key === "items" || key === "additionalProperties") { + assertSupported(value, `${path}/${key}`); + continue; + } + if (key === "anyOf" || key === "oneOf") { + value.forEach((sub, index) => assertSupported(sub, `${path}/${key}/${index}`)); + continue; + } + if (!isAllowedKeyword(key)) { + throw new Error(`${path}: unsupported schema keyword "${key}"`); + } + assertSupported(value, `${path}/${key}`); + } +} + +function utf8Length(value) { + return new TextEncoder().encode(value).length; +} + +function typeOf(instance) { + if (instance === null) return "null"; + if (Array.isArray(instance)) return "array"; + if (typeof instance === "number") { + return Number.isInteger(instance) ? "integer" : "number"; + } + return typeof instance; +} + +function matchesType(expected, instance) { + const wanted = Array.isArray(expected) ? expected : [expected]; + const actual = typeOf(instance); + return wanted.some((entry) => + entry === "number" + ? actual === "number" || actual === "integer" + : entry === actual, + ); +} + +function jsonEquals(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +function isValidRfc3339(value) { + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|z|[+-]\d{2}:\d{2})$/.exec( + value, + ); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12) return false; + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const monthLengths = [ + 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + ]; + if (day < 1 || day > monthLengths[month - 1]) return false; + if (hour > 23 || minute > 59 || second > 60) return false; + if (match[8] !== "Z" && match[8] !== "z") { + if (Number(match[8].slice(1, 3)) > 23 || Number(match[8].slice(4, 6)) > 59) { + return false; + } + } + return true; +} + +function resolveRef(ref, root) { + let target = root; + for (const rawSegment of ref.slice(2).split("/")) { + const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~"); + target = target?.[segment]; + if (target === undefined) { + throw new Error(`unresolvable $ref ${ref}`); + } + } + return target; +} + +const patternCache = new WeakMap(); + +function patternFor(schema) { + let regex = patternCache.get(schema); + if (regex === undefined) { + regex = new RegExp(schema.pattern); + patternCache.set(schema, regex); + } + return regex; +} + +/** + * Validates `instance` against `rootSchema`. + * @param {object} rootSchema schema loaded via {@link loadSchema} + * @param {unknown} instance JSON value + * @returns {{valid: boolean, errors: Array<{path: string, keyword: string, message: string}>}} + */ +export function validate(rootSchema, instance) { + const errors = []; + check(rootSchema, instance, "#", rootSchema, 0, errors); + return { valid: errors.length === 0, errors }; +} + +function check(schema, value, path, root, depth, errors) { + if (errors.length >= MAX_ERRORS) return; + if (depth > MAX_DEPTH) { + errors.push({ + path, + keyword: "depth", + message: "schema evaluation depth limit exceeded", + }); + return; + } + if (schema === true) return; + if (schema === false) { + errors.push({ path, keyword: "schema", message: "no value is allowed here" }); + return; + } + if (schema.$ref !== undefined) { + if (depth > MAX_REF_DEPTH) { + errors.push({ path, keyword: "$ref", message: "reference nesting limit exceeded" }); + return; + } + check(resolveRef(schema.$ref, root), value, path, root, depth + 1, errors); + return; + } + if (schema.type !== undefined && !matchesType(schema.type, value)) { + errors.push({ + path, + keyword: "type", + message: `expected ${JSON.stringify(schema.type)}, got ${typeOf(value)}`, + }); + return; + } + if (schema.const !== undefined && !jsonEquals(schema.const, value)) { + errors.push({ + path, + keyword: "const", + message: `expected ${JSON.stringify(schema.const)}`, + }); + } + if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEquals(entry, value))) { + errors.push({ + path, + keyword: "enum", + message: `value is not one of ${schema.enum.length} permitted spellings`, + }); + } + if (typeof value === "string") { + if (schema.minLength !== undefined && utf8Length(value) < schema.minLength) { + errors.push({ + path, + keyword: "minLength", + message: `shorter than ${schema.minLength} bytes`, + }); + } + if (schema.maxLength !== undefined && utf8Length(value) > schema.maxLength) { + errors.push({ + path, + keyword: "maxLength", + message: `longer than ${schema.maxLength} bytes`, + }); + } + if (schema.pattern !== undefined) { + if (utf8Length(value) > MAX_PATTERN_INPUT_BYTES) { + errors.push({ + path, + keyword: "pattern", + message: "string exceeds the pattern evaluation bound", + }); + } else if (!patternFor(schema).test(value)) { + errors.push({ path, keyword: "pattern", message: "does not match the required pattern" }); + } + } + if (schema.format === "date-time" && !isValidRfc3339(value)) { + errors.push({ path, keyword: "format", message: "not a valid RFC 3339 date-time" }); + } + } + if (typeof value === "number") { + if (schema.minimum !== undefined && value < schema.minimum) { + errors.push({ path, keyword: "minimum", message: `below ${schema.minimum}` }); + } + if (schema.maximum !== undefined && value > schema.maximum) { + errors.push({ path, keyword: "maximum", message: `above ${schema.maximum}` }); + } + } + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + errors.push({ path, keyword: "minItems", message: `fewer than ${schema.minItems} items` }); + } + if (schema.maxItems !== undefined && value.length > schema.maxItems) { + errors.push({ path, keyword: "maxItems", message: `more than ${schema.maxItems} items` }); + } + if (schema.items !== undefined) { + value.forEach((entry, index) => { + check(schema.items, entry, `${path}/${index}`, root, depth + 1, errors); + }); + } + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.minProperties !== undefined && Object.keys(value).length < schema.minProperties) { + errors.push({ + path, + keyword: "minProperties", + message: `fewer than ${schema.minProperties} properties`, + }); + } + if (schema.properties !== undefined || schema.additionalProperties !== undefined) { + const properties = schema.properties ?? {}; + const additional = schema.additionalProperties; + for (const [key, entry] of Object.entries(value)) { + if (Object.hasOwn(properties, key)) { + check(properties[key], entry, `${path}/${key}`, root, depth + 1, errors); + } else if (additional === false) { + errors.push({ + path, + keyword: "additionalProperties", + message: `unknown field "${key}"`, + }); + } else if (additional !== undefined) { + check(additional, entry, `${path}/${key}`, root, depth + 1, errors); + } + } + } + for (const required of schema.required ?? []) { + if (!Object.hasOwn(value, required)) { + errors.push({ + path, + keyword: "required", + message: `missing required field "${required}"`, + }); + } + } + } + if (schema.anyOf !== undefined) { + const subErrors = schema.anyOf.map((branch) => { + const collected = []; + check(branch, value, path, root, depth + 1, collected); + return collected; + }); + if (!subErrors.some((collected) => collected.length === 0)) { + errors.push({ + path, + keyword: "anyOf", + message: "does not match any permitted branch", + }); + } + } + if (schema.oneOf !== undefined) { + const matches = schema.oneOf.map((branch) => { + const collected = []; + check(branch, value, path, root, depth + 1, collected); + return collected.length === 0; + }).filter(Boolean).length; + if (matches !== 1) { + errors.push({ + path, + keyword: "oneOf", + message: `matches ${matches} oneOf branches, expected exactly 1`, + }); + } + } +} diff --git a/packages/psyche-protocol/package.json b/packages/psyche-protocol/package.json new file mode 100644 index 0000000..8949b13 --- /dev/null +++ b/packages/psyche-protocol/package.json @@ -0,0 +1,14 @@ +{ + "name": "@opencoven/psyche-protocol", + "version": "0.1.0", + "description": "Standalone conformance runner for the published Psyche protocol v1 artifact set", + "license": "MIT", + "type": "module", + "repository": { "type": "git", "url": "git+https://github.com/OpenCoven/psyche.git" }, + "bin": { "psyche-conformance": "bin/psyche-conformance.js" }, + "files": ["bin/", "lib/", "test/", "README.md"], + "engines": { "node": ">=20" }, + "scripts": { + "test": "node --test" + } +} diff --git a/packages/psyche-protocol/test/canonical.test.mjs b/packages/psyche-protocol/test/canonical.test.mjs new file mode 100644 index 0000000..5ef08a4 --- /dev/null +++ b/packages/psyche-protocol/test/canonical.test.mjs @@ -0,0 +1,73 @@ +// Canonicalization and digest tests. The critical invariant: byte parity with +// the Rust canonicalizer, pinned against fixtures whose digests Psyche's own +// CI verifies (crates/psyche-coven/tests/request_digest.rs). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { canonicalBytes, canonicalJson } from "../lib/canonical.js"; +import { hexSha256, sha256OfValue, isDigestShape } from "../lib/digest.js"; + +const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); + +test("canonical bytes sort keys by code unit order at every depth", () => { + const value = { b: 1, a: { d: [3, { z: 1, y: 2 }], c: 2 } }; + assert.equal(canonicalJson(value), '{"a":{"c":2,"d":[3,{"y":2,"z":1}]},"b":1}'); +}); + +test("canonical bytes match the Rust-pinned execution request digests", async () => { + const cases = [ + ["crates/psyche-coven/tests/fixtures/execution-request-launch.json", + "75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04"], + ["crates/psyche-coven/tests/fixtures/execution-request-input.json", + "c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61"], + ]; + for (const [file, expected] of cases) { + const bytes = await readFile(`${repoRoot}${file}`); + // The fixture files are canonical bytes: their plain SHA-256 is the + // Rust-pinned request digest... + assert.equal(hexSha256(bytes), expected, file); + // ...and re-canonicalizing the parsed content reproduces those bytes. + assert.equal(hexSha256(canonicalBytes(JSON.parse(bytes.toString("utf8")))), expected, file); + } +}); + +test("effect digests recomputed in JavaScript match the Rust-generated fixtures", async () => { + const surfaceEffect = JSON.parse( + (await readFile(`${repoRoot}crates/psyche-core/tests/fixtures/surface-effect.json`)).toString("utf8"), + ); + assert.equal(sha256OfValue(surfaceEffect.effect), surfaceEffect.effect_digest); + const delivery = JSON.parse( + (await readFile(`${repoRoot}crates/psyche-core/tests/fixtures/delivery-ready.json`)).toString("utf8"), + ); + assert.equal(sha256OfValue(delivery.effect), delivery.effect_digest); +}); + +test("out-of-domain values are rejected instead of canonicalized divergently", () => { + assert.throws(() => canonicalBytes({ n: 9_007_199_254_740_992 }), /unsafe integer/); + assert.throws(() => canonicalBytes({ n: Number.NaN }), /non-finite/); + assert.throws(() => canonicalBytes({ n: Number.POSITIVE_INFINITY }), /non-finite/); + assert.throws(() => canonicalBytes(new Date()), /non-plain object/); + const deep = { v: 0 }; + let current = deep; + for (let i = 0; i < 70; i += 1) { + current.v = { v: 0 }; + current = current.v; + } + assert.throws(() => canonicalBytes(deep), /nesting deeper than 64/); +}); + +test("safe boundary integers and fractional numbers canonicalize", () => { + assert.equal( + canonicalJson({ max: 9_007_199_254_740_991, min: -9_007_199_254_740_991, fraction: 1.5 }), + '{"fraction":1.5,"max":9007199254740991,"min":-9007199254740991}', + ); +}); + +test("digest helpers validate and produce the canonical sha256 form", () => { + assert.ok(isDigestShape(`sha256:${"0".repeat(64)}`)); + assert.equal(isDigestShape(`sha256:${"0".repeat(63)}`), false); + assert.equal(isDigestShape(`sha256:${"A".repeat(64)}`), false); + assert.equal(hexSha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +}); diff --git a/packages/psyche-protocol/test/cli.test.mjs b/packages/psyche-protocol/test/cli.test.mjs new file mode 100644 index 0000000..3924085 --- /dev/null +++ b/packages/psyche-protocol/test/cli.test.mjs @@ -0,0 +1,68 @@ +// End-to-end CLI tests against the published protocol/v1 artifact set, plus +// tamper detection. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, cp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const bin = path.join(repoRoot, "packages/psyche-protocol/bin/psyche-conformance.js"); +const protocolRoot = path.join(repoRoot, "protocol/v1"); + +test("run passes against the published artifact set", async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, [bin, "run", "--root", protocolRoot]); + assert.equal(stderr, ""); + const result = JSON.parse(stdout); + assert.equal(result.summary.status, "pass"); + assert.equal(result.summary.vectors_failed, 0); + assert.ok(result.summary.vectors_total >= 30); + assert.ok(result.checks.some((check) => check.id === "C8-cancellation-evidence" && check.status === "pass")); +}); + +test("verify passes against the published artifact set", async () => { + const { stdout } = await execFileAsync(process.execPath, [bin, "verify", "--root", protocolRoot]); + const result = JSON.parse(stdout); + assert.equal(result.summary.status, "pass"); + assert.equal(result.summary.failures, 0); +}); + +test("tampered vector bytes fail run and verify", async () => { + const temp = await mkdtemp(path.join(tmpdir(), "psyche-conformance-")); + try { + const root = path.join(temp, "v1"); + await cp(protocolRoot, root, { recursive: true }); + const target = path.join(root, "golden/positive/intent.json"); + const original = await readFile(target, "utf8"); + const mutated = original.replace("Review and verify the scoped change.", "Tampered outcome."); + assert.notEqual(mutated, original); + await writeFile(target, mutated); + + const run = await execFileAsync(process.execPath, [bin, "run", "--root", root]) + .then(() => null) + .catch((error) => JSON.parse(error.stdout)); + assert.equal(run.summary.status, "fail"); + + const verify = await execFileAsync(process.execPath, [bin, "verify", "--root", root]) + .then(() => null) + .catch((error) => JSON.parse(error.stdout)); + assert.equal(verify.summary.status, "fail"); + assert.ok( + verify.checks.some((check) => check.id === "manifest-integrity" && check.status === "fail"), + ); + } finally { + await rm(temp, { recursive: true, force: true }); + } +}); + +test("usage errors exit 1 without JSON output", async () => { + await assert.rejects( + execFileAsync(process.execPath, [bin, "frobnicate"]), + (error) => error.code === 1 && /usage/.test(error.stderr), + ); +}); diff --git a/packages/psyche-protocol/test/profile.test.mjs b/packages/psyche-protocol/test/profile.test.mjs new file mode 100644 index 0000000..71da07e --- /dev/null +++ b/packages/psyche-protocol/test/profile.test.mjs @@ -0,0 +1,433 @@ +// Semantic profile tests: each check must pass on Rust-valid documents and +// fail closed on the specific mutations the enforcing Rust code rejects. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { canonicalBytes } from "../lib/canonical.js"; +import { sha256OfBytes } from "../lib/digest.js"; +import { + checkBindingCancellation, + checkBindingRevision, + checkDeliverySentBinding, + checkEffectDigest, + checkJsonDomain, + checkNegotiation, + checkResultBinding, + checkRevisionChain, + checkTransitionDigest, + classifyRejection, + documentChecks, +} from "../lib/profile.js"; + +const sha256 = (hex) => `sha256:${hex}`; +const hex = (n) => String(n).repeat(2).padEnd(64, "0").slice(0, 64); + +function failuresOf(check, record, doc) { + const failures = new (class { + constructor() { this.list = []; } + add(id, detail) { this.list.push({ check: id, detail }); } + unless(condition, id, detail) { if (!condition) this.add(id, detail); return condition; } + })(); + check(record, doc, canonicalBytes(doc), failures); + return failures.list; +} + +function baseBinding() { + return { + schema_version: "psyche.execution_binding.v1", + attempt_id: `att_${"0".repeat(25)}1`, + revision: 1, + previous_revision_digest: null, + revision_created_at: "2026-08-01T00:00:00Z", + familiar_snapshot_id: `ids_${"0".repeat(25)}2`, + project_id: "project:one", + request_id: `req_${"0".repeat(25)}1`, + request_digest: sha256(hex(1)), + request_created_at: "2026-08-01T00:00:00Z", + request_valid_until: "2026-08-01T00:10:00Z", + coven_contract_version: "coven.execution.v1", + coven_session_id: "session-9", + adoption_state: "adopted", + event_cursor: null, + cancellation_state: "acknowledged_terminated", + termination_request: { + termination_request_id: `req_${"0".repeat(25)}2`, + created_at: "2026-08-01T00:02:00Z", + valid_until: "2026-08-01T00:20:00Z", + }, + termination_reason_code: "operator_requested", + cancellation_acknowledgement: { + acknowledgement_id: "ack-1", + termination_request_id: `req_${"0".repeat(25)}2`, + session_id: "session-9", + execution_request_id: `req_${"0".repeat(25)}1`, + execution_request_digest: sha256(hex(1)), + kind: "terminated", + authority_evidence_digest: sha256(hex(9)), + acknowledged_at: "2026-08-01T00:05:00Z", + }, + cancellation_unresolved: null, + terminal_state: null, + }; +} + +test("a complete acknowledged cancellation passes the evidence matrix", () => { + assert.deepEqual(failuresOf(checkBindingCancellation, "execution_binding", baseBinding()), []); +}); + +test("every evidence mutation the Rust decoder rejects is rejected here", () => { + const cases = [ + ["acknowledgement outside the window", (b) => { + b.cancellation_acknowledgement.acknowledged_at = "2026-08-09T00:00:00Z"; + }], + ["acknowledgement before the window opens", (b) => { + b.cancellation_acknowledgement.acknowledged_at = "2026-08-01T00:01:00Z"; + }], + ["session mismatch", (b) => { + b.cancellation_acknowledgement.session_id = "session-7"; + }], + ["execution request mismatch", (b) => { + b.cancellation_acknowledgement.execution_request_id = `req_${"0".repeat(25)}3`; + }], + ["execution digest mismatch", (b) => { + b.cancellation_acknowledgement.execution_request_digest = sha256(hex(2)); + }], + ["termination request id mismatch", (b) => { + b.cancellation_acknowledgement.termination_request_id = `req_${"0".repeat(25)}3`; + }], + ["acknowledgement kind mismatch", (b) => { + b.cancellation_acknowledgement.kind = "already_authoritatively_terminal"; + }], + ["all-zero authority evidence digest", (b) => { + b.cancellation_acknowledgement.authority_evidence_digest = sha256("0".repeat(64)); + }], + ["unresolved evidence under an acknowledged state", (b) => { + b.cancellation_unresolved = { + disposition_id: "disp-1", + termination_request_id: `req_${"0".repeat(25)}2`, + session_id: "session-9", + execution_request_id: `req_${"0".repeat(25)}1`, + execution_request_digest: sha256(hex(1)), + reason_code: "surface_unreachable", + recorded_at: "2026-08-01T00:06:00Z", + }; + }], + ["expired execution request window", (b) => { + b.request_valid_until = "2026-08-01T00:00:00Z"; + }], + ["termination request predating the execution request", (b) => { + b.termination_request.created_at = "2026-07-31T23:00:00Z"; + }], + ["termination request equal to the execution request", (b) => { + b.termination_request.termination_request_id = b.request_id; + }], + ]; + for (const [name, mutate] of cases) { + const binding = baseBinding(); + mutate(binding); + const failures = failuresOf(checkBindingCancellation, "execution_binding", binding); + assert.ok(failures.length > 0, `expected rejection: ${name}`); + assert.ok( + failures.every((failure) => failure.check === "C8-cancellation-evidence"), + name, + ); + } +}); + +test("termination_unknown requires unresolved evidence inside the window", () => { + const binding = baseBinding(); + binding.cancellation_state = "termination_unknown"; + binding.cancellation_acknowledgement = null; + binding.cancellation_unresolved = { + disposition_id: "disp-1", + termination_request_id: `req_${"0".repeat(25)}2`, + session_id: "session-9", + execution_request_id: `req_${"0".repeat(25)}1`, + execution_request_digest: sha256(hex(1)), + reason_code: "surface_unreachable", + recorded_at: "2026-08-01T00:06:00Z", + }; + assert.deepEqual(failuresOf(checkBindingCancellation, "execution_binding", binding), []); + + binding.cancellation_unresolved.recorded_at = "2026-08-01T00:21:00Z"; + assert.ok(failuresOf(checkBindingCancellation, "execution_binding", binding).length > 0); + + binding.cancellation_unresolved = null; + binding.cancellation_acknowledgement = null; + const failures = failuresOf(checkBindingCancellation, "execution_binding", binding); + assert.ok(failures.length > 0); +}); + +test("not_requested forbids any cancellation evidence", () => { + const binding = baseBinding(); + binding.cancellation_state = "not_requested"; + assert.ok(failuresOf(checkBindingCancellation, "execution_binding", binding).length > 0); + binding.termination_request = null; + binding.termination_reason_code = null; + binding.cancellation_acknowledgement = null; + binding.cancellation_unresolved = null; + assert.deepEqual(failuresOf(checkBindingCancellation, "execution_binding", binding), []); +}); + +test("revision discipline: revision 1 has no previous digest and later revisions bind one", () => { + const binding = baseBinding(); + assert.deepEqual(failuresOf(checkBindingRevision, "execution_binding", binding), []); + binding.revision = 2; + assert.ok(failuresOf(checkBindingRevision, "execution_binding", binding).length > 0); + binding.previous_revision_digest = sha256(hex(3)); + assert.deepEqual(failuresOf(checkBindingRevision, "execution_binding", binding), []); +}); + +function revision1() { + return baseBinding(); +} + +function revision2(overrides = {}) { + const r1 = revision1(); + return { + ...r1, + revision: 2, + previous_revision_digest: sha256OfBytes(canonicalBytes(r1)), + revision_created_at: "2026-08-01T00:00:30Z", + event_cursor: "cursor-0001", + ...overrides, + }; +} + +test("a well-formed revision chain passes every append-only rule", () => { + assert.deepEqual(checkRevisionChain(revision1(), revision2()), []); +}); + +test("broken chains are detected under C10", () => { + const cases = [ + ["wrong previous digest", (r2) => { + r2.previous_revision_digest = sha256(hex(4)); + }], + ["non-consecutive revision", (r2) => { + r2.revision = 3; + }], + ["timestamp regression", (r2) => { + r2.revision_created_at = "2026-07-31T23:00:00Z"; + }], + ["frozen correlation change", (r2) => { + r2.request_digest = sha256(hex(5)); + }], + ["session cleared", (r2) => { + r2.coven_session_id = null; + }], + ["session rebound", (r2) => { + r2.coven_session_id = "session-10"; + }], + ["illegal cancellation transition", (r2) => { + r2.cancellation_state = "acknowledged_already_terminal"; + }], + ]; + for (const [name, mutate] of cases) { + const r2 = revision2(); + mutate(r2); + const failures = checkRevisionChain(revision1(), r2); + assert.ok(failures.length > 0, `expected rejection: ${name}`); + assert.ok(failures.every((failure) => failure.check === "C10-revision-chain"), name); + } +}); + +test("effect digests and the delivery sent-binding are recomputed", () => { + const effect = { type: "message", text: "hello" }; + const doc = { + effect, + effect_digest: sha256OfBytes(canonicalBytes(effect)), + state: "ready", + telegram_message_id: null, + }; + assert.deepEqual(failuresOf(checkEffectDigest, "surface_effect", doc), []); + assert.deepEqual(failuresOf(checkDeliverySentBinding, "delivery", doc), []); + + doc.effect_digest = sha256(hex(8)); + assert.ok(failuresOf(checkEffectDigest, "surface_effect", doc).length > 0); + + doc.state = "sent"; + assert.ok(failuresOf(checkDeliverySentBinding, "delivery", doc).length > 0); + doc.telegram_message_id = "42"; + assert.deepEqual(failuresOf(checkDeliverySentBinding, "delivery", doc), []); +}); + +function resultBundle() { + const correlation = { + request_id: `req_${"0".repeat(25)}1`, + request_digest: sha256(hex(1)), + familiar_snapshot_id: `ids_${"0".repeat(25)}2`, + project_id: "project:one", + graph_id: `grf_${"0".repeat(25)}1`, + node_id: `nod_${"0".repeat(25)}1`, + attempt_id: `att_${"0".repeat(25)}1`, + created_at: "2026-08-05T14:00:00Z", + valid_until: "2026-08-05T14:05:00Z", + }; + return { + session_id: "session-1", + correlation, + result: { + digest: sha256(hex(6)), + media_type: "application/json", + size_bytes: 2, + expires_at: "2026-08-05T14:04:00Z", + }, + artifacts: [ + { + artifact_id: "artifact-1", + session_id: "session-1", + // A distinct copy, as on the wire: equality with the bundle + // correlation is a value comparison, not aliasing. + correlation: structuredClone(correlation), + content: { + digest: sha256(hex(7)), + media_type: "text/plain", + size_bytes: 5, + expires_at: "2026-08-05T14:03:00Z", + }, + }, + ], + }; +} + +test("result bundle binding rules fail closed on each mutation", () => { + assert.deepEqual(failuresOf(checkResultBinding, "result_bundle", resultBundle()), []); + const cases = [ + ["result expiry before the window", (b) => { + b.result.expires_at = "2026-08-05T13:59:00Z"; + }], + ["result expiry after the window", (b) => { + b.result.expires_at = "2026-08-05T14:06:00Z"; + }], + ["artifact session mismatch", (b) => { + b.artifacts[0].session_id = "session-2"; + }], + ["artifact correlation mismatch", (b) => { + b.artifacts[0].correlation.request_id = `req_${"0".repeat(25)}9`; + }], + ["artifact expiry after result expiry", (b) => { + b.artifacts[0].content.expires_at = "2026-08-05T14:04:30Z"; + }], + ["duplicate artifact ids", (b) => { + b.artifacts.push(structuredClone(b.artifacts[0])); + }], + ]; + for (const [name, mutate] of cases) { + const bundle = resultBundle(); + mutate(bundle); + const failures = failuresOf(checkResultBinding, "result_bundle", bundle); + assert.ok(failures.length > 0, `expected rejection: ${name}`); + assert.ok(failures.every((failure) => failure.check === "C9-result-binding"), name); + } +}); + +test("negotiation rules enforce canonical paths, harness, window, and unique artifacts", () => { + const request = { + request_id: `req_${"0".repeat(25)}1`, + project_root: "/workspace/project", + cwd: "/workspace/project/sub", + harness: "codex", + created_at: "2026-08-05T14:00:00Z", + valid_until: "2026-08-05T14:05:00Z", + required_artifact_bindings: [{ artifact_id: "a1", digest: sha256(hex(1)), media_type: "text/plain", size: 3 }], + }; + assert.deepEqual(failuresOf(checkNegotiation, "execution_request", request), []); + + for (const [name, path] of [ + ["relative path", "workspace/project"], + ["double slash", "/workspace//project"], + ["trailing slash", "/workspace/"], + ["dot segment", "/workspace/./project"], + ["dotdot segment", "/workspace/../project"], + ]) { + const mutated = structuredClone(request); + mutated.project_root = path; + assert.ok(failuresOf(checkNegotiation, "execution_request", mutated).length > 0, name); + } + + const mutated = structuredClone(request); + mutated.harness = "other"; + assert.ok(failuresOf(checkNegotiation, "execution_request", mutated).length > 0); + + const expired = structuredClone(request); + expired.valid_until = expired.created_at; + assert.ok(failuresOf(checkNegotiation, "execution_request", expired).length > 0); + + const duplicate = structuredClone(request); + duplicate.required_artifact_bindings.push(structuredClone(duplicate.required_artifact_bindings[0])); + assert.ok(failuresOf(checkNegotiation, "execution_request", duplicate).length > 0); +}); + +test("transition digests cover every other field", () => { + const doc = { + kind: "graph", + record_id: `grf_${"0".repeat(25)}1`, + record_version: 1, + from_state: null, + to_state: "draft", + transition_digest: "placeholder", + created_at: "2026-08-01T00:00:00Z", + }; + const { transition_digest: _omit, ...input } = doc; + doc.transition_digest = sha256OfBytes(canonicalBytes(input)); + assert.deepEqual(failuresOf(checkTransitionDigest, "transition", doc), []); + doc.to_state = "running"; + assert.ok(failuresOf(checkTransitionDigest, "transition", doc).length > 0); +}); + +test("json domain checks bounded keys; out-of-domain values die in canonicalization", () => { + const record = { key: "intent" }; + assert.deepEqual(failuresOf(checkJsonDomain, record, { constraints: { ok: 1 } }), []); + assert.ok( + failuresOf(checkJsonDomain, record, { constraints: { [ "k".repeat(257) ]: 1 } }).length > 0, + "oversized constraint key", + ); + // Out-of-domain documents never reach the profile: canonicalization is the + // first gate (the runner canonicalizes before evaluating any check). + assert.throws(() => canonicalBytes({ n: 9_007_199_254_740_992 }), /unsafe integer/); + const deep = {}; + let cursor = deep; + for (let i = 0; i < 70; i += 1) { + cursor.next = {}; + cursor = cursor.next; + } + assert.throws(() => canonicalBytes(deep), /nesting deeper than 64/); + // The profile's own walker keeps the same bound for direct callers (no + // canonicalization gate in between). + const sink = { + list: [], + add(check, detail) { this.list.push({ check, detail }); }, + unless(condition, check, detail) { if (!condition) this.add(check, detail); return condition; }, + }; + checkJsonDomain(record, deep, Buffer.alloc(0), sink); + assert.ok(sink.list.length > 0, "depth"); +}); + +test("rejection classification mirrors the RejectionReason vocabulary", () => { + const recordKeys = ["intent", "graph", "delivery"]; + assert.equal( + classifyRejection(JSON.stringify({ schema_version: "psyche.unknown_kind.v1" }), recordKeys).quarantine_class, + "unknown_schema", + ); + assert.equal( + classifyRejection(JSON.stringify({ schema_version: "psyche.intent.v2" }), recordKeys).quarantine_class, + "unsupported_major", + ); + assert.equal( + classifyRejection(JSON.stringify({ schema_version: "psyche.intent.v1", intent_id: "bogus" }), recordKeys).quarantine_class, + "invalid_shape", + ); +}); + +test("documentChecks passes a Rust-valid binding with no cancellation evidence", () => { + const binding = baseBinding(); + binding.cancellation_state = "not_requested"; + binding.termination_request = null; + binding.termination_reason_code = null; + binding.cancellation_acknowledgement = null; + binding.cancellation_unresolved = null; + binding.terminal_state = null; + const sink = { list: [], add(check, detail) { this.list.push({ check, detail }); }, unless(c, id, d) { if (!c) this.add(id, d); return c; } }; + documentChecks("execution_binding", binding, canonicalBytes(binding), sink); + assert.deepEqual(sink.list, []); +}); diff --git a/packages/psyche-protocol/test/schema.test.mjs b/packages/psyche-protocol/test/schema.test.mjs new file mode 100644 index 0000000..ffd1167 --- /dev/null +++ b/packages/psyche-protocol/test/schema.test.mjs @@ -0,0 +1,104 @@ +// JSON Schema subset validator tests, including the fail-closed behavior for +// unsupported keywords. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loadSchema, validate } from "../lib/schema.js"; + +const ULID = "0".repeat(25) + "1"; + +test("type, const, enum, and pattern enforce exact shapes", () => { + const schema = loadSchema(`{ + "type": "object", + "properties": { + "schema_version": { "const": "psyche.intent.v1" }, + "state": { "type": "string", "enum": ["ready", "sent"] }, + "id": { "type": "string", "pattern": "^int_[0-7][0-9A-HJKMNP-TV-Z]{25}$" } + }, + "required": ["schema_version", "id"], + "additionalProperties": false + }`); + assert.equal(validate(schema, { schema_version: "psyche.intent.v1", id: `int_${ULID}` }).valid, true); + assert.equal( + validate(schema, { schema_version: "psyche.intent.v2", id: `int_${ULID}` }).valid, + false, + ); + assert.equal( + validate(schema, { schema_version: "psyche.intent.v1", state: "archived", id: `int_${ULID}` }).valid, + false, + ); + assert.equal( + validate(schema, { schema_version: "psyche.intent.v1", id: `req_${ULID}` }).valid, + false, + ); + assert.equal( + validate(schema, { schema_version: "psyche.intent.v1", id: `int_${ULID}`, extra: 1 }).valid, + false, + ); +}); + +test("string lengths are measured in UTF-8 bytes", () => { + const schema = loadSchema('{"type":"string","maxLength":3}'); + assert.equal(validate(schema, "ä").valid, true); // 2 bytes + assert.equal(validate(schema, "€").valid, true); // 3 bytes + assert.equal(validate(schema, "€u").valid, false); // 4 bytes +}); + +test("integer bounds and format date-time validate numerically and by calendar", () => { + const schema = loadSchema('{"type":"integer","minimum":1,"maximum":9007199254740991}'); + assert.equal(validate(schema, 1).valid, true); + assert.equal(validate(schema, 0).valid, false); + assert.equal(validate(schema, 1.5).valid, false); + assert.equal(validate(schema, 9_007_199_254_740_992).valid, false); + + const timeSchema = loadSchema('{"type":"string","format":"date-time"}'); + assert.equal(validate(timeSchema, "2026-08-01T00:00:00Z").valid, true); + assert.equal(validate(timeSchema, "2024-02-29T00:00:00Z").valid, true); // leap day + assert.equal(validate(timeSchema, "2026-02-29T00:00:00Z").valid, false); // not a leap year + assert.equal(validate(timeSchema, "2026-13-01T00:00:00Z").valid, false); + assert.equal(validate(timeSchema, "2026-08-01 00:00:00Z").valid, false); +}); + +test("anyOf, oneOf, local $ref, and $defs resolve", () => { + const schema = loadSchema(`{ + "$defs": { "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } }, + "oneOf": [ + { "type": "object", "properties": { "operation": { "const": "a" } }, "required": ["operation"], "additionalProperties": false }, + { "type": "object", "properties": { "operation": { "const": "b" } }, "required": ["operation"], "additionalProperties": false } + ], + "properties": { "d": { "$ref": "#/$defs/digest" } } + }`); + assert.equal(validate(schema, { operation: "a" }).valid, true); + assert.equal(validate(schema, { operation: "b" }).valid, true); + assert.equal(validate(schema, {}).valid, false); + assert.equal(validate(schema, { operation: "a", d: "sha256:nope" }).valid, false); +}); + +test("unsupported keywords and remote refs fail closed at load time", () => { + assert.throws(() => loadSchema('{"contains":{"const":1}}'), /unsupported schema keyword/); + assert.throws(() => loadSchema('{"allOf":[{"type":"string"}]}'), /unsupported schema keyword/); + assert.throws( + () => loadSchema('{"$ref":"https://example.com/schema.json"}'), + /only local/, + ); + assert.throws( + () => loadSchema(`{"$schema":"http://json-schema.org/draft-07/schema#"}`), + /unsupported dialect/, + ); +}); + +test("validation depth is bounded", () => { + // Build an 80-level nested properties schema; validating an 80-level + // instance must trip the depth guard instead of recursing unboundedly. + let schema = { type: "object", properties: { leaf: { const: 1 } }, required: ["leaf"], additionalProperties: false }; + for (let i = 0; i < 80; i += 1) { + schema = { type: "object", properties: { next: schema }, required: ["next"], additionalProperties: false }; + } + let instance = { leaf: 1 }; + for (let i = 0; i < 80; i += 1) { + instance = { next: instance }; + } + const result = validate(loadSchema(JSON.stringify(schema)), instance); + assert.equal(result.valid, false); + assert.ok(result.errors.some((error) => error.keyword === "depth")); +}); diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 0000000..295b2df --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,45 @@ +# Psyche Protocol Artifacts + +This directory is the consumer-ready Psyche protocol release surface described +in [OpenCoven/psyche#11](https://github.com/OpenCoven/psyche/issues/11): the +versioned machine-readable schemas, canonical golden vectors, and the artifact +inventory downstream repositories pin. The human-readable design and +compatibility policy lives in [docs/PROTOCOL.md](../docs/PROTOCOL.md); the +conformance runner lives in +[`packages/psyche-protocol`](../packages/psyche-protocol). + +## Layout + +- `v1/schemas/` — JSON Schema (draft 2020-12) per record, self-contained. +- `v1/types/psyche-protocol.v1.d.ts` — generated TypeScript surface. +- `v1/golden/` — byte-exact canonical JSON vectors + (`positive/`, `negative/`, `vectors.json`). +- `v1/inventory.json` — machine-readable inventory and stability classes. +- `v1/MANIFEST.sha256` — sha256sum checksums of every artifact in this tree. + +Everything in this directory is **generated** by +`node scripts/protocol/generate.mjs` from the tables in +`scripts/protocol/definitions.mjs` and `scripts/protocol/vectors.mjs`. Do not +edit artifacts by hand; edit the sources and regenerate. CI re-runs the +generator and fails on any uncommitted drift. + +## Pinning (downstream) + +1. Pin an immutable release: a tag of this repository (tag archives include + the full artifact set) or the `@opencoven/psyche-protocol` npm package, + whose published digests are recorded in `MANIFEST.sha256`. +2. Verify integrity: + + ```sh + node packages/psyche-protocol/bin/psyche-conformance.js verify --root protocol/v1 + ``` + +3. Run conformance without checking out any Psyche source beyond the artifact + directory itself: + + ```sh + node packages/psyche-protocol/bin/psyche-conformance.js run --root protocol/v1 + ``` + +The runner emits a bounded machine-readable result, performs no network I/O, +and never requires production credentials. diff --git a/protocol/v1/MANIFEST.sha256 b/protocol/v1/MANIFEST.sha256 new file mode 100644 index 0000000..80b0741 --- /dev/null +++ b/protocol/v1/MANIFEST.sha256 @@ -0,0 +1,55 @@ +2742bf3abfb120ad1e4036556a36364bcb217c8d4ac53c9d23d6b5b0a1cd4ba7 golden/negative/crash-restart-broken-chain.json +315096bb0e927efe5f2074d7eb6cd2e2d6004f61f39deb9156922ce6ec87cea8 golden/negative/denial-unknown-code.json +d1a0435fc014fbd21b623e3dcd028bd70af1e447c70fd0791b9ddb6e54e36d67 golden/negative/denial-unknown-enum.json +ece530a2117c35a5c2fa292e0b7c2849b033451c93373f3eef2fc1405451d5ec golden/negative/denial-unknown-field.json +c3f985e0a8405a17fca39f10065afc3261338e950ba1904361f78a08e047ef97 golden/negative/denial-unknown-kind.json +8b5bdb32b2bb612ca19fed69b41a5781722ed3ebf674dbfcb4b5544de956ef6c golden/negative/downgrade-major-graph-v2.json +5cbd7e659d441115a33bd00cae01df84a2678fa294c236e6885f35fd4697132a golden/negative/stale-correlation-ack-outside-window.json +4d145d6ecacc21c037aed38b89c317d122457dbfe19dbf40babc1e9228d63ba9 golden/negative/stale-correlation-expired-request.json +aa0b827300a43295b084d88df670499ca380200f5d241f810a41de73413179fb golden/negative/stale-correlation-wrong-session.json +07d4f82b2c1a98ff5539905aff20cb6f18b9084342599e2ac50d7748121a330d golden/negative/unknown-version-intent-v2.json +0144fcd70794197778c17899ffbb028eda6510d647484cd81d9d7b7b801079cc golden/positive/addon.json +351bd63fe026180d186ab88f215da98cfc8663b0a0804a3b762b78ab32be2fcb golden/positive/approval.json +6c9d8de2a904f0821ab7cb2a420d0b7e2e065f55eaf911fb57e565d5c8cd1c7a golden/positive/budget.json +01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41 golden/positive/crash-restart/revision-1.json +aa7f6790f413d320238a7d1096047193c8f8b90aba6ce41accfa2f1b408a23a4 golden/positive/crash-restart/revision-2.json +b16b315a60e0288c71266dfd4dd8c646a1d26ef7667b63d81a3b00f4748916ec golden/positive/delegation.json +c4f7b567b2abbc9ec94f50a13ee42f5b8eba276033ace75c218ad1f472bbdbf4 golden/positive/delivery-ready.json +6d58339a99ecde35000d22d49b5d4f8085be83188b87d09efd5368b43b469377 golden/positive/error-storage-unavailable.json +1855f2c8cb9769fbe3f14726ff9b3fbad6ea50111cf318667051dd369c253b08 golden/positive/evidence.json +3007334009d8e99d9e95a1ab4ecd50078e414aff49ca720119ea405e49bac13b golden/positive/execution-binding-acknowledged.json +01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41 golden/positive/execution-binding-revision-1.json +c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61 golden/positive/execution-request-input.json +75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04 golden/positive/execution-request-launch.json +aab8c7902cfdeb07a55c4a0823e1cac2c8a94f71dce7dfd01ae3332ce4499036 golden/positive/graph-node.json +763bf92304a57f2f74a014636ef904edd13f609fab8e134b41d62b9e1efcdb27 golden/positive/graph.json +8d0724f86711ea4739f4feab08ecef6ed73ff691a78e9e13ae1639a0df969399 golden/positive/identity-snapshot.json +b6c6e3cdec0628b2a588abac4cb567a697031fd955fc542f3f8a1f974dcc5bfb golden/positive/intent.json +38ef1502fc6468592bf8c2aa7ec647add639af8a7d9b60ff61138061bb732afd golden/positive/recovery.json +11212e5933232ce5fc3b604416441f51f17a7069ad5c5dd0114346c60d1b1fac golden/positive/result-bundle.json +e98c8ee8f28a6d2c3757bd7f2f030c6cbf87cba1eaa1f7797052dc8579d4d507 golden/positive/surface-effect.json +bab220ec9dd03e9ddfc7187cfe028bcf2980164bb91fc2393d09917c905c0488 golden/positive/surface-event.json +df276a4d65635db9f31e8907dd5918058912b7b472155ee36317b451c2ef3b3d golden/positive/transition.json +bc9801058889aa0d1314f14d2106c00422d2f5b6252dd8252660a234acb14faf golden/positive/verdict.json +d9bdedff8374613fe4a8dc1f4a5176a6eb76b9de5806d6f470084d31660bdbd0 golden/vectors.json +5cfae98364d3cd8dae3b6a7e021033a19f49e259ce3a77936f66d55d2a9fb304 inventory.json +710a21d887ce08323b4f98dc952f7ca94c399042f49a93593b704946079052a7 schemas/psyche.addon.v1.schema.json +c991d66a492ee294a39c6157e6ea1f829bede3afb253ef3f653c5b15fad788b3 schemas/psyche.approval.v1.schema.json +ea426f97a730410b18129b050b86183fa46e93f1631708bca5b9f09a68d78dec schemas/psyche.budget.v1.schema.json +66aece08135a1182f3c7eee37ea7fc08c02ec43b310ecf6445b28f303d310591 schemas/psyche.delegation.v1.schema.json +96e8477172e377867a45e07647126cfa0489eeb1a433f10567ee1c341ad556c8 schemas/psyche.delivery.v1.schema.json +bf967f0c3e046afe9ccc11c8e29761e2c9018712d0587c728d5bd4aea54483f8 schemas/psyche.error.v1.schema.json +24f27226a8f402ae1121a467042ac3399ae0025a16c4416fe5a1ab9f8a031d34 schemas/psyche.evidence.v1.schema.json +53048860595920931b872d2bc83a07efbed19509224d305e290b2b3945b4dfe2 schemas/psyche.execution_binding.v1.schema.json +5e9b8f300da67ccf876e40ef4271085417548085827ff55ef9328d5a99d6dcd2 schemas/psyche.execution_request.v1.schema.json +640bc642d468ac4b308d345b44765c1ab8c83e789fb346f9f11134110d7ca618 schemas/psyche.graph_node.v1.schema.json +a4090f26433711a4e56438789a991ce8bce7589a3b0b4ee037b7ac39f1ea64f0 schemas/psyche.graph.v1.schema.json +9e98e786c8e459c9fd6df1aa3a168735e02ef6ceb6de6cfc1aa7ed5673f885fb schemas/psyche.identity_snapshot.v1.schema.json +5383314e0a5c6cb9d64c6e02a050357974dd1237e09d7e01944d75c7027e7f18 schemas/psyche.intent.v1.schema.json +0a512eaf572644fc0cc6b36e7ad2f4eeb72e3b52a1f6d9288afabe7f4571ebec schemas/psyche.recovery.v1.schema.json +b113daece4934538cef5ab085a849e1e65e0f2b3d941ff5be58950d88de18014 schemas/psyche.result_bundle.schema.json +a8c47540feb9bba8efdd3ffbe2bab05dfb64646f9311c3c261934059d61216eb schemas/psyche.surface_effect.v1.schema.json +5cdbcee556cc2e1798db129801c8c6e4ce3c47ab82c219574ae8efaec54490f1 schemas/psyche.surface_event.v1.schema.json +dc35bde93ba65535e5442c188ff545b5bb20d80a764d94eb41e6ff47ba6ab04a schemas/psyche.transition.schema.json +522189c9077b41a73a42cae50a5e29be6a617b54449d5ab709d2a678af318a8b schemas/psyche.verdict.v1.schema.json +6a614403f4d230d4f149886a7bdc66517cea30546b805571484a356995a52970 types/psyche-protocol.v1.d.ts diff --git a/protocol/v1/golden/negative/crash-restart-broken-chain.json b/protocol/v1/golden/negative/crash-restart-broken-chain.json new file mode 100644 index 0000000..49f8dd9 --- /dev/null +++ b/protocol/v1/golden/negative/crash-restart-broken-chain.json @@ -0,0 +1 @@ +{"adoption_state":"adopted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":null,"cancellation_state":"not_requested","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":"session-7","event_cursor":"cursor-0001","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":2,"revision_created_at":"2026-08-01T00:00:30Z","schema_version":"psyche.execution_binding.v1","terminal_state":null,"termination_reason_code":null,"termination_request":null} \ No newline at end of file diff --git a/protocol/v1/golden/negative/denial-unknown-code.json b/protocol/v1/golden/negative/denial-unknown-code.json new file mode 100644 index 0000000..bdd7d7a --- /dev/null +++ b/protocol/v1/golden/negative/denial-unknown-code.json @@ -0,0 +1 @@ +{"error":{"code":"quantum_flux","correlation_id":"corr-1","details":{},"message":"redacted public message","retryable":false},"schema_version":"psyche.error.v1"} \ No newline at end of file diff --git a/protocol/v1/golden/negative/denial-unknown-enum.json b/protocol/v1/golden/negative/denial-unknown-enum.json new file mode 100644 index 0000000..4c44784 --- /dev/null +++ b/protocol/v1/golden/negative/denial-unknown-enum.json @@ -0,0 +1 @@ +{"account_id":"main","action_class":"telegram.reply.send","attempt_count":0,"chat_id":"-1001234567890","delivery_id":"del_01ARZ3NDEKTSV4RRFFQ69G5FAV","effect":{"buttons":[],"format":"html","link_preview":{"enabled":true},"reply_to_message_id":"314","schema_version":"psyche.telegram_effect.v1","text":"Review complete.","type":"send_message"},"effect_digest":"sha256:26fae759f51eafdfa1277616327d942068948182fb0aaa6b34bbedb0a8ca7dc7","intent_id":"int_01BX5ZZKBKACTAV9WEVGEMMVRZ","logical_part":0,"logical_response_id":"response_01ARZ3NDEKTSV4RRFFQ69G5FAV","relationship":"reply_same_topic","schema_version":"psyche.delivery.v1","state":"archived","surface_decision":{"decision_id":"decision_01ARZ3NDEKTSV4RRFFQ69G5FAV","expires_at":"2026-08-01T00:05:00Z","policy_revision":"policy:sha256:0123456789abcdef","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","state":"reserved"},"telegram_message_id":null,"topic":{"id":"42","kind":"forum"}} \ No newline at end of file diff --git a/protocol/v1/golden/negative/denial-unknown-field.json b/protocol/v1/golden/negative/denial-unknown-field.json new file mode 100644 index 0000000..e482714 --- /dev/null +++ b/protocol/v1/golden/negative/denial-unknown-field.json @@ -0,0 +1 @@ +{"adapter_note":"extra field not in the envelope","constraints":{},"created_at":"2026-08-01T00:00:00Z","digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","intent_id":"int_01ARZ3NDEKTSV4RRFFQ69G5FAV","principal_id":"principal:val","project_id":"project:sha256:0123456789abcdef","requested_outcome":"Review and verify the scoped change.","required_evidence":["tests","diff_review"],"schema_version":"psyche.intent.v1","surface_event_id":null} \ No newline at end of file diff --git a/protocol/v1/golden/negative/denial-unknown-kind.json b/protocol/v1/golden/negative/denial-unknown-kind.json new file mode 100644 index 0000000..f978c4e --- /dev/null +++ b/protocol/v1/golden/negative/denial-unknown-kind.json @@ -0,0 +1 @@ +{"receipt_id":"not-a-record","schema_version":"psyche.delivery_receipt.v9"} \ No newline at end of file diff --git a/protocol/v1/golden/negative/downgrade-major-graph-v2.json b/protocol/v1/golden/negative/downgrade-major-graph-v2.json new file mode 100644 index 0000000..39014d2 --- /dev/null +++ b/protocol/v1/golden/negative/downgrade-major-graph-v2.json @@ -0,0 +1 @@ +{"graph_id":"grf_01BX5ZZKBKACTAV9WEVGEMMVRZ","owner_principal_id":"principal:one","policy_revision":"policy:one","root_intent_id":"int_01ARZ3NDEKTSV4RRFFQ69G5FAV","schema_version":"psyche.graph.v2","state":"draft","version":1} \ No newline at end of file diff --git a/protocol/v1/golden/negative/stale-correlation-ack-outside-window.json b/protocol/v1/golden/negative/stale-correlation-ack-outside-window.json new file mode 100644 index 0000000..aae50c7 --- /dev/null +++ b/protocol/v1/golden/negative/stale-correlation-ack-outside-window.json @@ -0,0 +1 @@ +{"adoption_state":"adopted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":{"acknowledged_at":"2026-08-09T00:00:00Z","acknowledgement_id":"ack-1","authority_evidence_digest":"sha256:9999999999999999999999999999999999999999999999999999999999999999","execution_request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","execution_request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","kind":"terminated","session_id":"session-9","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ"},"cancellation_state":"acknowledged_terminated","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":"session-9","event_cursor":"cursor-0009","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":null,"project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":1,"revision_created_at":"2026-08-01T00:00:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":"completed","termination_reason_code":"operator_requested","termination_request":{"created_at":"2026-08-01T00:02:00Z","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ","valid_until":"2026-08-01T00:20:00Z"}} \ No newline at end of file diff --git a/protocol/v1/golden/negative/stale-correlation-expired-request.json b/protocol/v1/golden/negative/stale-correlation-expired-request.json new file mode 100644 index 0000000..13c1681 --- /dev/null +++ b/protocol/v1/golden/negative/stale-correlation-expired-request.json @@ -0,0 +1 @@ +{"adoption_state":"not_submitted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":null,"cancellation_state":"not_requested","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":null,"event_cursor":null,"familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":null,"project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:00:00Z","revision":1,"revision_created_at":"2026-08-01T00:00:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":null,"termination_reason_code":null,"termination_request":null} \ No newline at end of file diff --git a/protocol/v1/golden/negative/stale-correlation-wrong-session.json b/protocol/v1/golden/negative/stale-correlation-wrong-session.json new file mode 100644 index 0000000..4871ccc --- /dev/null +++ b/protocol/v1/golden/negative/stale-correlation-wrong-session.json @@ -0,0 +1 @@ +{"adoption_state":"adopted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":{"acknowledged_at":"2026-08-01T00:05:00Z","acknowledgement_id":"ack-1","authority_evidence_digest":"sha256:9999999999999999999999999999999999999999999999999999999999999999","execution_request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","execution_request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","kind":"terminated","session_id":"session-9","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ"},"cancellation_state":"acknowledged_terminated","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":"session-7","event_cursor":"cursor-0009","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":"sha256:01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41","project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":2,"revision_created_at":"2026-08-01T00:06:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":"completed","termination_reason_code":"operator_requested","termination_request":{"created_at":"2026-08-01T00:02:00Z","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ","valid_until":"2026-08-01T00:20:00Z"}} \ No newline at end of file diff --git a/protocol/v1/golden/negative/unknown-version-intent-v2.json b/protocol/v1/golden/negative/unknown-version-intent-v2.json new file mode 100644 index 0000000..e5a066e --- /dev/null +++ b/protocol/v1/golden/negative/unknown-version-intent-v2.json @@ -0,0 +1 @@ +{"constraints":{},"created_at":"2026-08-01T00:00:00Z","digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","intent_id":"int_01ARZ3NDEKTSV4RRFFQ69G5FAV","principal_id":"principal:val","project_id":"project:one","requested_outcome":"Future-shaped payload must fail closed.","required_evidence":[],"schema_version":"psyche.intent.v2","surface_event_id":null} \ No newline at end of file diff --git a/protocol/v1/golden/positive/addon.json b/protocol/v1/golden/positive/addon.json new file mode 100644 index 0000000..0f566bd --- /dev/null +++ b/protocol/v1/golden/positive/addon.json @@ -0,0 +1 @@ +{"addon_id":"adn_01F6JA1T7V5Q1R9S8P4N2M3KWX","allowlist_digest":"sha256:777777777777777777777777777777777777777777777777777777777777777b","contributions_digest":"sha256:777777777777777777777777777777777777777777777777777777777777777b","package":"example-addon","package_digest":"sha256:7777777777777777777777777777777777777777777777777777777777777777","provenance_digest":"sha256:777777777777777777777777777777777777777777777777777777777777777a","revocation_state":"active","schema_version":"psyche.addon.v1","version":"1.0.0"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/approval.json b/protocol/v1/golden/positive/approval.json new file mode 100644 index 0000000..fb4f41c --- /dev/null +++ b/protocol/v1/golden/positive/approval.json @@ -0,0 +1 @@ +{"approval_id":"apr_01E5H90S6T4P0Q8R7N3M1K2JVW","decision":null,"expires_at":"2026-08-01T01:00:00Z","node_id":"nod_01ARZ3NDEKTSV4RRFFQ69G5FAV","requester_principal_id":"principal:val","schema_version":"psyche.approval.v1"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/budget.json b/protocol/v1/golden/positive/budget.json new file mode 100644 index 0000000..181bbe5 --- /dev/null +++ b/protocol/v1/golden/positive/budget.json @@ -0,0 +1 @@ +{"budget_id":"bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV","consumed":40,"graph_id":"grf_01BX5ZZKBKACTAV9WEVGEMMVRZ","limit":100000,"released":10,"reserved":100,"resource_class":"tokens","schema_version":"psyche.budget.v1"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/crash-restart/revision-1.json b/protocol/v1/golden/positive/crash-restart/revision-1.json new file mode 100644 index 0000000..05fa081 --- /dev/null +++ b/protocol/v1/golden/positive/crash-restart/revision-1.json @@ -0,0 +1 @@ +{"adoption_state":"not_submitted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":null,"cancellation_state":"not_requested","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":null,"event_cursor":null,"familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":null,"project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":1,"revision_created_at":"2026-08-01T00:00:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":null,"termination_reason_code":null,"termination_request":null} \ No newline at end of file diff --git a/protocol/v1/golden/positive/crash-restart/revision-2.json b/protocol/v1/golden/positive/crash-restart/revision-2.json new file mode 100644 index 0000000..0554f51 --- /dev/null +++ b/protocol/v1/golden/positive/crash-restart/revision-2.json @@ -0,0 +1 @@ +{"adoption_state":"adopted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":null,"cancellation_state":"not_requested","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":"session-7","event_cursor":"cursor-0001","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":"sha256:01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41","project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":2,"revision_created_at":"2026-08-01T00:00:30Z","schema_version":"psyche.execution_binding.v1","terminal_state":null,"termination_reason_code":null,"termination_request":null} \ No newline at end of file diff --git a/protocol/v1/golden/positive/delegation.json b/protocol/v1/golden/positive/delegation.json new file mode 100644 index 0000000..6a75307 --- /dev/null +++ b/protocol/v1/golden/positive/delegation.json @@ -0,0 +1 @@ +{"budget_id":"bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV","cancellation_policy":"terminate_with_acknowledgement","child_node_id":"nod_01BX5ZZKBKACTAV9WEVGEMMVRZ","delegation_id":"dlg_01C3F7YQ4R2M8N6P5K1J9H0GTS","evidence_scope_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","parent_node_id":"nod_01ARZ3NDEKTSV4RRFFQ69G5FAV","schema_version":"psyche.delegation.v1","scope_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/delivery-ready.json b/protocol/v1/golden/positive/delivery-ready.json new file mode 100644 index 0000000..3e7f76e --- /dev/null +++ b/protocol/v1/golden/positive/delivery-ready.json @@ -0,0 +1 @@ +{"account_id":"main","action_class":"telegram.reply.send","attempt_count":0,"chat_id":"-1001234567890","delivery_id":"del_01ARZ3NDEKTSV4RRFFQ69G5FAV","effect":{"buttons":[],"format":"html","link_preview":{"enabled":true},"reply_to_message_id":"314","schema_version":"psyche.telegram_effect.v1","text":"Review complete.","type":"send_message"},"effect_digest":"sha256:26fae759f51eafdfa1277616327d942068948182fb0aaa6b34bbedb0a8ca7dc7","intent_id":"int_01BX5ZZKBKACTAV9WEVGEMMVRZ","logical_part":0,"logical_response_id":"response_01ARZ3NDEKTSV4RRFFQ69G5FAV","relationship":"reply_same_topic","schema_version":"psyche.delivery.v1","state":"ready","surface_decision":{"decision_id":"decision_01ARZ3NDEKTSV4RRFFQ69G5FAV","expires_at":"2026-08-01T00:05:00Z","policy_revision":"policy:sha256:0123456789abcdef","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","state":"reserved"},"telegram_message_id":null,"topic":{"id":"42","kind":"forum"}} \ No newline at end of file diff --git a/protocol/v1/golden/positive/error-storage-unavailable.json b/protocol/v1/golden/positive/error-storage-unavailable.json new file mode 100644 index 0000000..8d0a6cd --- /dev/null +++ b/protocol/v1/golden/positive/error-storage-unavailable.json @@ -0,0 +1 @@ +{"error":{"code":"storage_unavailable","correlation_id":"corr-storage-1","details":{"component":"sqlite","operation":"write"},"message":"Storage is temporarily unavailable.","retryable":true},"schema_version":"psyche.error.v1"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/evidence.json b/protocol/v1/golden/positive/evidence.json new file mode 100644 index 0000000..4170e40 --- /dev/null +++ b/protocol/v1/golden/positive/evidence.json @@ -0,0 +1 @@ +{"attempt_id":"att_01E5H90S6T4P0Q8R7N3M1K2JVW","collection_method":"direct","content_digest":"sha256:5555555555555555555555555555555555555555555555555555555555555555","created_at":"2026-08-01T00:00:00Z","evidence_id":"evd_01C3F7YQ4R2M8N6P5K1J9H0GTS","media_type":"text/plain","node_id":"nod_01ARZ3NDEKTSV4RRFFQ69G5FAV","producer":"psyche-test","retention_policy":"default","schema_version":"psyche.evidence.v1","size":12} \ No newline at end of file diff --git a/protocol/v1/golden/positive/execution-binding-acknowledged.json b/protocol/v1/golden/positive/execution-binding-acknowledged.json new file mode 100644 index 0000000..1fa428f --- /dev/null +++ b/protocol/v1/golden/positive/execution-binding-acknowledged.json @@ -0,0 +1 @@ +{"adoption_state":"adopted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":{"acknowledged_at":"2026-08-01T00:05:00Z","acknowledgement_id":"ack-1","authority_evidence_digest":"sha256:9999999999999999999999999999999999999999999999999999999999999999","execution_request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","execution_request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","kind":"terminated","session_id":"session-9","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ"},"cancellation_state":"acknowledged_terminated","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":"session-9","event_cursor":"cursor-0009","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":"sha256:01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41","project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":2,"revision_created_at":"2026-08-01T00:06:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":"completed","termination_reason_code":"operator_requested","termination_request":{"created_at":"2026-08-01T00:02:00Z","termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ","valid_until":"2026-08-01T00:20:00Z"}} \ No newline at end of file diff --git a/protocol/v1/golden/positive/execution-binding-revision-1.json b/protocol/v1/golden/positive/execution-binding-revision-1.json new file mode 100644 index 0000000..05fa081 --- /dev/null +++ b/protocol/v1/golden/positive/execution-binding-revision-1.json @@ -0,0 +1 @@ +{"adoption_state":"not_submitted","attempt_id":"att_01ARZ3NDEKTSV4RRFFQ69G5FAV","cancellation_acknowledgement":null,"cancellation_state":"not_requested","cancellation_unresolved":null,"coven_contract_version":"coven.execution.v1","coven_session_id":null,"event_cursor":null,"familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","previous_revision_digest":null,"project_id":"project:one","request_created_at":"2026-08-01T00:00:00Z","request_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","request_id":"req_01ARZ3NDEKTSV4RRFFQ69G5FAV","request_valid_until":"2026-08-01T00:10:00Z","revision":1,"revision_created_at":"2026-08-01T00:00:00Z","schema_version":"psyche.execution_binding.v1","terminal_state":null,"termination_reason_code":null,"termination_request":null} \ No newline at end of file diff --git a/protocol/v1/golden/positive/execution-request-input.json b/protocol/v1/golden/positive/execution-request-input.json new file mode 100644 index 0000000..c252820 --- /dev/null +++ b/protocol/v1/golden/positive/execution-request-input.json @@ -0,0 +1 @@ +{"attempt_id":"att_01J00000000000000000000000","context_manifest_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","created_at":"2026-08-05T14:01:00Z","familiar_snapshot_id":"ids_01J00000000000000000000000","graph_id":"grf_01J00000000000000000000000","input_digest":"sha256:5555555555555555555555555555555555555555555555555555555555555555","node_id":"nod_01J00000000000000000000000","operation":"input","payload_digest":"sha256:6666666666666666666666666666666666666666666666666666666666666666","principal_id":"principal:val","project_id":"project:sha256:abc","request_id":"req_01J00000000000000000000000","required_artifact_bindings":[],"schema_version":"psyche.execution_request.v1","session_id":"session-1","valid_until":"2026-08-05T14:06:00Z"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/execution-request-launch.json b/protocol/v1/golden/positive/execution-request-launch.json new file mode 100644 index 0000000..63d8061 --- /dev/null +++ b/protocol/v1/golden/positive/execution-request-launch.json @@ -0,0 +1 @@ +{"attempt_id":"att_01J00000000000000000000000","budget_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","context_manifest_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","created_at":"2026-08-05T14:00:00Z","cwd":"/workspace/project","delegation_digest":null,"familiar_snapshot_id":"ids_01J00000000000000000000000","graph_id":"grf_01J00000000000000000000000","harness":"codex","node_id":"nod_01J00000000000000000000000","operation":"launch","payload_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444","principal_id":"principal:val","project_id":"project:sha256:abc","project_root":"/workspace/project","request_id":"req_01J00000000000000000000000","required_artifact_bindings":[{"artifact_id":"artifact-1","digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","media_type":"text/plain","size":12}],"schema_version":"psyche.execution_request.v1","valid_until":"2026-08-05T14:05:00Z"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/graph-node.json b/protocol/v1/golden/positive/graph-node.json new file mode 100644 index 0000000..5c03740 --- /dev/null +++ b/protocol/v1/golden/positive/graph-node.json @@ -0,0 +1 @@ +{"budget_id":"bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV","delegation_id":null,"dependencies":[],"familiar_snapshot_id":"ids_01C3F7YQ4R2M8N6P5K1J9H0GTS","graph_id":"grf_01BX5ZZKBKACTAV9WEVGEMMVRZ","node_id":"nod_01ARZ3NDEKTSV4RRFFQ69G5FAV","required_evidence":["tests","diff_review"],"schema_version":"psyche.graph_node.v1","state":"ready","version":1} \ No newline at end of file diff --git a/protocol/v1/golden/positive/graph.json b/protocol/v1/golden/positive/graph.json new file mode 100644 index 0000000..f2e0aa2 --- /dev/null +++ b/protocol/v1/golden/positive/graph.json @@ -0,0 +1 @@ +{"graph_id":"grf_01BX5ZZKBKACTAV9WEVGEMMVRZ","owner_principal_id":"principal:one","policy_revision":"policy:one","root_intent_id":"int_01ARZ3NDEKTSV4RRFFQ69G5FAV","schema_version":"psyche.graph.v1","state":"draft","version":1} \ No newline at end of file diff --git a/protocol/v1/golden/positive/identity-snapshot.json b/protocol/v1/golden/positive/identity-snapshot.json new file mode 100644 index 0000000..b0e586a --- /dev/null +++ b/protocol/v1/golden/positive/identity-snapshot.json @@ -0,0 +1 @@ +{"declaration_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","familiar_id":"familiar:one","identity_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","identity_file_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","principal_id":"principal:val","provenance":{"familiar_home_id":"home:main","resolver_version":"resolver.v1"},"resolved_at":"2026-08-01T00:00:00Z","revision":3,"role_skill_digest":"sha256:5555555555555555555555555555555555555555555555555555555555555555","schema_version":"psyche.identity_snapshot.v1","snapshot_id":"ids_01ARZ3NDEKTSV4RRFFQ69G5FAV","soul_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/intent.json b/protocol/v1/golden/positive/intent.json new file mode 100644 index 0000000..af667a2 --- /dev/null +++ b/protocol/v1/golden/positive/intent.json @@ -0,0 +1 @@ +{"constraints":{},"created_at":"2026-08-01T00:00:00Z","digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","familiar_snapshot_id":"ids_01BX5ZZKBKACTAV9WEVGEMMVRZ","intent_id":"int_01ARZ3NDEKTSV4RRFFQ69G5FAV","principal_id":"principal:val","project_id":"project:sha256:0123456789abcdef","requested_outcome":"Review and verify the scoped change.","required_evidence":["tests","diff_review"],"schema_version":"psyche.intent.v1","surface_event_id":null} \ No newline at end of file diff --git a/protocol/v1/golden/positive/recovery.json b/protocol/v1/golden/positive/recovery.json new file mode 100644 index 0000000..86ae43f --- /dev/null +++ b/protocol/v1/golden/positive/recovery.json @@ -0,0 +1 @@ +{"ambiguity":"session_identity_ambiguous","attempt_id":"att_01E5H90S6T4P0Q8R7N3M1K2JVW","fence_token":"fence-0001","lease_id":"lease-1","operator_disposition":null,"reconciliation_count":2,"recovery_id":"rcv_01F6JA1T7V5Q1R9S8P4N2M3KWX","schema_version":"psyche.recovery.v1"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/result-bundle.json b/protocol/v1/golden/positive/result-bundle.json new file mode 100644 index 0000000..d5823b1 --- /dev/null +++ b/protocol/v1/golden/positive/result-bundle.json @@ -0,0 +1 @@ +{"artifacts":[{"artifact_id":"artifact-1","content":{"digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","expires_at":"2026-08-05T14:03:00Z","media_type":"text/plain","size_bytes":5},"correlation":{"attempt_id":"att_01J00000000000000000000000","created_at":"2026-08-05T14:00:00Z","familiar_snapshot_id":"ids_01J00000000000000000000000","graph_id":"grf_01J00000000000000000000000","node_id":"nod_01J00000000000000000000000","project_id":"project:sha256:abc","request_digest":"sha256:75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04","request_id":"req_01J00000000000000000000000","valid_until":"2026-08-05T14:05:00Z"},"session_id":"session-1"}],"correlation":{"attempt_id":"att_01J00000000000000000000000","created_at":"2026-08-05T14:00:00Z","familiar_snapshot_id":"ids_01J00000000000000000000000","graph_id":"grf_01J00000000000000000000000","node_id":"nod_01J00000000000000000000000","project_id":"project:sha256:abc","request_digest":"sha256:75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04","request_id":"req_01J00000000000000000000000","valid_until":"2026-08-05T14:05:00Z"},"result":{"digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","expires_at":"2026-08-05T14:04:00Z","media_type":"application/json","size_bytes":2},"session_id":"session-1"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/surface-effect.json b/protocol/v1/golden/positive/surface-effect.json new file mode 100644 index 0000000..79fde13 --- /dev/null +++ b/protocol/v1/golden/positive/surface-effect.json @@ -0,0 +1 @@ +{"account_id":"main","action_class":"telegram.reply.send","attempt_id":"att_01E5H90S6T4P0Q8R7N3M1K2JVW","created_at":"2026-08-01T00:01:00Z","effect":{"text":"Review complete.","type":"message"},"effect_digest":"sha256:c16ac6fce1ddecbddc6bf0b51544975741901289ec2248ddeffe6951fc8be995","familiar_snapshot_id":"ids_01F6JA1T7V5Q1R9S8P4N2M3KWX","graph_id":"grf_01C3F7YQ4R2M8N6P5K1J9H0GTS","intent_id":"int_01BX5ZZKBKACTAV9WEVGEMMVRZ","locator":{"chat_id":"-100123","message_id":"42"},"node_id":"nod_01D4G8ZR5S3N9P7Q6M2K0J1HTV","project_id":"project:sha256:0123456789abcdef","schema_version":"psyche.surface_effect.v1","surface_effect_id":"sfx_01ARZ3NDEKTSV4RRFFQ69G5FAV"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/surface-event.json b/protocol/v1/golden/positive/surface-event.json new file mode 100644 index 0000000..66f5b95 --- /dev/null +++ b/protocol/v1/golden/positive/surface-event.json @@ -0,0 +1 @@ +{"account_id":"main","actor":{"id":"123","type":"user"},"adapter_event_digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","adapter_id":"telegram","content":{"text":"Please review this.","type":"text"},"locator":{"chat_id":"-100123","message_id":"42","type":"message"},"received_at":"2026-08-01T00:00:00Z","schema_version":"psyche.surface_event.v1","surface_event_id":"sev_01ARZ3NDEKTSV4RRFFQ69G5FAV"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/transition.json b/protocol/v1/golden/positive/transition.json new file mode 100644 index 0000000..743d046 --- /dev/null +++ b/protocol/v1/golden/positive/transition.json @@ -0,0 +1 @@ +{"created_at":"2026-08-01T00:00:00Z","from_state":null,"kind":"graph","record_id":"grf_01BX5ZZKBKACTAV9WEVGEMMVRZ","record_version":1,"to_state":"draft","transition_digest":"sha256:ecac4e647aa1efce77da6a2cdc86b082468d1153daf664e0d9b07509c5e599da"} \ No newline at end of file diff --git a/protocol/v1/golden/positive/verdict.json b/protocol/v1/golden/positive/verdict.json new file mode 100644 index 0000000..425bd74 --- /dev/null +++ b/protocol/v1/golden/positive/verdict.json @@ -0,0 +1 @@ +{"created_at":"2026-08-01T00:00:00Z","node_id":"nod_01ARZ3NDEKTSV4RRFFQ69G5FAV","outcome":"approved","policy_revision":"policy:one","reason_codes":["tests","diff_review"],"reviewer_id":"reviewer:one","schema_version":"psyche.verdict.v1","sealed_evidence_digest":"sha256:6666666666666666666666666666666666666666666666666666666666666666","verdict_id":"vrd_01F6JA1T7V5Q1R9S8P4N2M3KWX","verdict_type":"acceptance"} \ No newline at end of file diff --git a/protocol/v1/golden/vectors.json b/protocol/v1/golden/vectors.json new file mode 100644 index 0000000..ca0c9d2 --- /dev/null +++ b/protocol/v1/golden/vectors.json @@ -0,0 +1,470 @@ +{ + "artifact_set": "1.0.0", + "profile": "consumer-v1", + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme)", + "digest_rule": "sha256 over the complete canonical bytes of the vector file; no trailing newline", + "vectors": [ + { + "file": "golden/positive/identity-snapshot.json", + "sha256": "sha256:8d0724f86711ea4739f4feab08ecef6ed73ff691a78e9e13ae1639a0df969399", + "bytes": 750, + "record": "identity_snapshot", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "identity snapshot with producer-claimed content digests", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/intent.json", + "sha256": "sha256:b6c6e3cdec0628b2a588abac4cb567a697031fd955fc542f3f8a1f974dcc5bfb", + "bytes": 479, + "record": "intent", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "local intent with empty constraints", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/surface-event.json", + "sha256": "sha256:bab220ec9dd03e9ddfc7187cfe028bcf2980164bb91fc2393d09917c905c0488", + "bytes": 431, + "record": "surface_event", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "inbound surface observation", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/graph.json", + "sha256": "sha256:763bf92304a57f2f74a014636ef904edd13f609fab8e134b41d62b9e1efcdb27", + "bytes": 226, + "record": "graph", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "graph header in draft state", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/graph-node.json", + "sha256": "sha256:aab8c7902cfdeb07a55c4a0823e1cac2c8a94f71dce7dfd01ae3332ce4499036", + "bytes": 340, + "record": "graph_node", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "root graph node", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/delegation.json", + "sha256": "sha256:b16b315a60e0288c71266dfd4dd8c646a1d26ef7667b63d81a3b00f4748916ec", + "bytes": 476, + "record": "delegation", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "node-to-node authority delegation", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/budget.json", + "sha256": "sha256:6c9d8de2a904f0821ab7cb2a420d0b7e2e065f55eaf911fb57e565d5c8cd1c7a", + "bytes": 210, + "record": "budget", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "budget allocation counters", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/approval.json", + "sha256": "sha256:351bd63fe026180d186ab88f215da98cfc8663b0a0804a3b762b78ab32be2fcb", + "bytes": 222, + "record": "approval", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "pending approval without a decision", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/execution-binding-revision-1.json", + "sha256": "sha256:01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41", + "bytes": 800, + "record": "execution_binding", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "binding revision 1 without session or cancellation evidence", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/execution-binding-acknowledged.json", + "sha256": "sha256:3007334009d8e99d9e95a1ab4ecd50078e414aff49ca720119ea405e49bac13b", + "bytes": 1469, + "record": "execution_binding", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "acknowledged_terminated with matching core-owned evidence inside the termination window (C-S9 shape)", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/evidence.json", + "sha256": "sha256:1855f2c8cb9769fbe3f14726ff9b3fbad6ea50111cf318667051dd369c253b08", + "bytes": 421, + "record": "evidence", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "evidence metadata bound to node and attempt", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/verdict.json", + "sha256": "sha256:bc9801058889aa0d1314f14d2106c00422d2f5b6252dd8252660a234acb14faf", + "bytes": 410, + "record": "verdict", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "verdict over sealed evidence", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/recovery.json", + "sha256": "sha256:38ef1502fc6468592bf8c2aa7ec647add639af8a7d9b60ff61138061bb732afd", + "bytes": 274, + "record": "recovery", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "ambiguity recovery with fence token", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/addon.json", + "sha256": "sha256:0144fcd70794197778c17899ffbb028eda6510d647484cd81d9d7b7b801079cc", + "bytes": 527, + "record": "addon", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "digest-bound add-on registration", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/surface-effect.json", + "sha256": "sha256:e98c8ee8f28a6d2c3757bd7f2f030c6cbf87cba1eaa1f7797052dc8579d4d507", + "bytes": 666, + "record": "surface_effect", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "outbound effect with decoder-recomputed effect_digest", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/delivery-ready.json", + "sha256": "sha256:c4f7b567b2abbc9ec94f50a13ee42f5b8eba276033ace75c218ad1f472bbdbf4", + "bytes": 975, + "record": "delivery", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "delivery in ready state, message id absent", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/error-storage-unavailable.json", + "sha256": "sha256:6d58339a99ecde35000d22d49b5d4f8085be83188b87d09efd5368b43b469377", + "bytes": 227, + "record": "error", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "structured denial envelope for a retryable storage outage", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/execution-request-launch.json", + "sha256": "sha256:75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04", + "bytes": 1008, + "record": "execution_request:launch", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "launch adoption request (byte-copy of the Rust golden)", + "notes": null, + "copied_from": "crates/psyche-coven/tests/fixtures/execution-request-launch.json" + }, + { + "file": "golden/positive/execution-request-input.json", + "sha256": "sha256:c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61", + "bytes": 778, + "record": "execution_request:input", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "input adoption request for an adopted session", + "notes": null, + "copied_from": "crates/psyche-coven/tests/fixtures/execution-request-input.json" + }, + { + "file": "golden/positive/result-bundle.json", + "sha256": "sha256:11212e5933232ce5fc3b604416441f51f17a7069ad5c5dd0114346c60d1b1fac", + "bytes": 1341, + "record": "result_bundle", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "result/artifact bundle bound to the launch correlation", + "notes": null, + "copied_from": "crates/psyche-coven/tests/fixtures/result-bundle.json" + }, + { + "file": "golden/positive/transition.json", + "sha256": "sha256:df276a4d65635db9f31e8907dd5918058912b7b472155ee36317b451c2ef3b3d", + "bytes": 247, + "record": "transition", + "class": "positive", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "append-only graph transition", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/crash-restart/revision-1.json", + "sha256": "sha256:01177ac6a7fc8373f668c82d4c9ccf469710e14e962a73394589ea68e1b24e41", + "bytes": 800, + "record": "execution_binding", + "class": "crash-restart", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "revision 1 persisted before the crash", + "notes": null, + "copied_from": null + }, + { + "file": "golden/positive/crash-restart/revision-2.json", + "sha256": "sha256:aa7f6790f413d320238a7d1096047193c8f8b90aba6ce41accfa2f1b408a23a4", + "bytes": 879, + "record": "execution_binding", + "class": "crash-restart", + "expect": "accept", + "failure_surface": null, + "reason": null, + "quarantine_class": null, + "scenario": "post-restart revision 2 chained to revision 1's canonical digest with a strictly later revision_created_at", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/denial-unknown-kind.json", + "sha256": "sha256:c3f985e0a8405a17fca39f10065afc3261338e950ba1904361f78a08e047ef97", + "bytes": 75, + "record": null, + "class": "denial", + "expect": "reject", + "failure_surface": "decode", + "reason": "unknown_schema", + "quarantine_class": "unknown_schema", + "scenario": "schema kind outside the registry is quarantined, never dispatched", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/denial-unknown-enum.json", + "sha256": "sha256:d1a0435fc014fbd21b623e3dcd028bd70af1e447c70fd0791b9ddb6e54e36d67", + "bytes": 978, + "record": "delivery", + "class": "denial", + "expect": "reject", + "failure_surface": "decode", + "reason": "unknown_enum_value", + "quarantine_class": "unknown_enum_value", + "scenario": "delivery state outside the frozen vocabulary", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/denial-unknown-field.json", + "sha256": "sha256:ece530a2117c35a5c2fa292e0b7c2849b033451c93373f3eef2fc1405451d5ec", + "bytes": 528, + "record": "intent", + "class": "denial", + "expect": "reject", + "failure_surface": "decode", + "reason": "invalid_shape", + "quarantine_class": "invalid_shape", + "scenario": "unknown envelope field is denied (deny_unknown_fields)", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/denial-unknown-code.json", + "sha256": "sha256:315096bb0e927efe5f2074d7eb6cd2e2d6004f61f39deb9156922ce6ec87cea8", + "bytes": 161, + "record": "error", + "class": "denial", + "expect": "reject", + "failure_surface": "decode", + "reason": "unknown_enum_value", + "quarantine_class": "unknown_enum_value", + "scenario": "error envelope code outside ErrorCode::ALL", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/stale-correlation-ack-outside-window.json", + "sha256": "sha256:5cbd7e659d441115a33bd00cae01df84a2678fa294c236e6885f35fd4697132a", + "bytes": 1400, + "record": "execution_binding", + "class": "stale-correlation", + "expect": "reject", + "failure_surface": "decode", + "reason": "cancellation_evidence_mismatch", + "quarantine_class": "invalid_shape", + "scenario": "acknowledgement recorded after the termination window closed: schema-valid shape, decoder-denied by ExecutionBinding::validate_cancellation", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/stale-correlation-expired-request.json", + "sha256": "sha256:4d145d6ecacc21c037aed38b89c317d122457dbfe19dbf40babc1e9228d63ba9", + "bytes": 800, + "record": "execution_binding", + "class": "stale-correlation", + "expect": "reject", + "failure_surface": "decode", + "reason": "invalid_shape", + "quarantine_class": "invalid_shape", + "scenario": "request_valid_until not after request_created_at", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/stale-correlation-wrong-session.json", + "sha256": "sha256:aa0b827300a43295b084d88df670499ca380200f5d241f810a41de73413179fb", + "bytes": 1469, + "record": "execution_binding", + "class": "stale-correlation", + "expect": "reject", + "failure_surface": "decode", + "reason": "cancellation_evidence_mismatch", + "quarantine_class": "invalid_shape", + "scenario": "acknowledgement binds session-7 while the binding carries session-9 (validate_evidence_bindings)", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/unknown-version-intent-v2.json", + "sha256": "sha256:07d4f82b2c1a98ff5539905aff20cb6f18b9084342599e2ac50d7748121a330d", + "bytes": 441, + "record": null, + "class": "unknown-version", + "expect": "reject", + "failure_surface": "decode", + "reason": "unsupported_major", + "quarantine_class": "unsupported_major", + "scenario": "psyche.intent.v2 against the v1-only registry", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/downgrade-major-graph-v2.json", + "sha256": "sha256:8b5bdb32b2bb612ca19fed69b41a5781722ed3ebf674dbfcb4b5544de956ef6c", + "bytes": 226, + "record": null, + "class": "downgrade", + "expect": "reject", + "failure_surface": "decode", + "reason": "unsupported_major", + "quarantine_class": "unsupported_major", + "scenario": "downgrade direction: a v1-only consumer must fail closed on a newer major regardless of producer intent; the registry has no rollback path", + "notes": null, + "copied_from": null + }, + { + "file": "golden/negative/crash-restart-broken-chain.json", + "sha256": "sha256:2742bf3abfb120ad1e4036556a36364bcb217c8d4ac53c9d23d6b5b0a1cd4ba7", + "bytes": 879, + "record": "execution_binding", + "class": "crash-restart", + "expect": "reject", + "failure_surface": "profile", + "reason": "revision_chain_digest_mismatch", + "quarantine_class": "invalid_shape", + "scenario": "revision 2 whose previous_revision_digest does not bind revision 1's canonical bytes: schema-valid, profile-rejected; the store answers DatabaseCorruption (validate_revision_chain)", + "notes": null, + "copied_from": null + } + ] +} diff --git a/protocol/v1/inventory.json b/protocol/v1/inventory.json new file mode 100644 index 0000000..50f85a4 --- /dev/null +++ b/protocol/v1/inventory.json @@ -0,0 +1,522 @@ +{ + "artifact_set": "1.0.0", + "supported_major": 1, + "registry_size": 16, + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme)", + "digest": "sha256 over complete canonical JSON bytes, no trailing newline", + "generated_from": "scripts/protocol/definitions.mjs", + "records": [ + { + "key": "identity_snapshot", + "schema_id": "psyche.identity_snapshot.v1", + "file": "schemas/psyche.identity_snapshot.v1.schema.json", + "registry_kind": "identity_snapshot", + "record_kind": "IdentitySnapshot", + "id_prefix": "ids_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/identity.rs", + "profile_checks": [ + "schema", + "jsonDomain" + ] + }, + { + "key": "intent", + "schema_id": "psyche.intent.v1", + "file": "schemas/psyche.intent.v1.schema.json", + "registry_kind": "intent", + "record_kind": "Intent", + "id_prefix": "int_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/intent.rs", + "profile_checks": [ + "schema", + "jsonDomain" + ] + }, + { + "key": "surface_event", + "schema_id": "psyche.surface_event.v1", + "file": "schemas/psyche.surface_event.v1.schema.json", + "registry_kind": "surface_event", + "record_kind": "SurfaceEvent", + "id_prefix": "sev_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/surface.rs (SurfaceEvent)", + "profile_checks": [ + "schema", + "jsonDomain" + ] + }, + { + "key": "graph", + "schema_id": "psyche.graph.v1", + "file": "schemas/psyche.graph.v1.schema.json", + "registry_kind": "graph", + "record_kind": "Graph", + "id_prefix": "grf_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/graph.rs (Graph)", + "profile_checks": [ + "schema" + ] + }, + { + "key": "graph_node", + "schema_id": "psyche.graph_node.v1", + "file": "schemas/psyche.graph_node.v1.schema.json", + "registry_kind": "graph_node", + "record_kind": "GraphNode", + "id_prefix": "nod_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/graph.rs (GraphNode)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "delegation", + "schema_id": "psyche.delegation.v1", + "file": "schemas/psyche.delegation.v1.schema.json", + "registry_kind": "delegation", + "record_kind": "Delegation", + "id_prefix": "dlg_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Delegation)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "budget", + "schema_id": "psyche.budget.v1", + "file": "schemas/psyche.budget.v1.schema.json", + "registry_kind": "budget", + "record_kind": "Budget", + "id_prefix": "bud_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Budget)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "approval", + "schema_id": "psyche.approval.v1", + "file": "schemas/psyche.approval.v1.schema.json", + "registry_kind": "approval", + "record_kind": "Approval", + "id_prefix": "apr_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Approval)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "execution_binding", + "schema_id": "psyche.execution_binding.v1", + "file": "schemas/psyche.execution_binding.v1.schema.json", + "registry_kind": "execution_binding", + "record_kind": "Attempt", + "id_prefix": "att_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/execution.rs", + "profile_checks": [ + "schema", + "bindingRevision", + "bindingCancellation", + "jsonDomain" + ] + }, + { + "key": "evidence", + "schema_id": "psyche.evidence.v1", + "file": "schemas/psyche.evidence.v1.schema.json", + "registry_kind": "evidence", + "record_kind": "Evidence", + "id_prefix": "evd_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Evidence)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "verdict", + "schema_id": "psyche.verdict.v1", + "file": "schemas/psyche.verdict.v1.schema.json", + "registry_kind": "verdict", + "record_kind": "Verdict", + "id_prefix": "vrd_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Verdict)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "recovery", + "schema_id": "psyche.recovery.v1", + "file": "schemas/psyche.recovery.v1.schema.json", + "registry_kind": "recovery", + "record_kind": "Recovery", + "id_prefix": "rcv_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Recovery)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "addon", + "schema_id": "psyche.addon.v1", + "file": "schemas/psyche.addon.v1.schema.json", + "registry_kind": "addon", + "record_kind": "Addon", + "id_prefix": "adn_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/foundation.rs (Addon)", + "profile_checks": [ + "schema", + "idKinds" + ] + }, + { + "key": "surface_effect", + "schema_id": "psyche.surface_effect.v1", + "file": "schemas/psyche.surface_effect.v1.schema.json", + "registry_kind": "surface_effect", + "record_kind": "SurfaceEffect", + "id_prefix": "sfx_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/surface.rs (SurfaceEffect)", + "profile_checks": [ + "schema", + "idKinds", + "effectDigest", + "jsonDomain" + ] + }, + { + "key": "delivery", + "schema_id": "psyche.delivery.v1", + "file": "schemas/psyche.delivery.v1.schema.json", + "registry_kind": "delivery", + "record_kind": "Delivery", + "id_prefix": "del_", + "persistable": true, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/surface.rs (Delivery)", + "profile_checks": [ + "schema", + "idKinds", + "effectDigest", + "deliverySentBinding", + "jsonDomain" + ] + }, + { + "key": "error", + "schema_id": "psyche.error.v1", + "file": "schemas/psyche.error.v1.schema.json", + "registry_kind": "error", + "record_kind": null, + "id_prefix": null, + "persistable": false, + "stability": "stable-v1", + "source": "crates/psyche-core/src/contracts/error.rs", + "profile_checks": [ + "schema", + "errorEnvelope" + ] + }, + { + "key": "execution_request", + "schema_id": "psyche.execution_request.v1", + "file": "schemas/psyche.execution_request.v1.schema.json", + "registry_kind": null, + "record_kind": null, + "id_prefix": null, + "persistable": false, + "stability": "experimental", + "source": "crates/psyche-coven/src/port.rs (ExecutionRequestInput)", + "profile_checks": [ + "schema", + "idKinds", + "negotiation", + "jsonDomain" + ] + }, + { + "key": "result_bundle", + "schema_id": "psyche.result_bundle", + "file": "schemas/psyche.result_bundle.schema.json", + "registry_kind": null, + "record_kind": null, + "id_prefix": null, + "persistable": false, + "stability": "experimental", + "source": "crates/psyche-coven/src/port.rs (ResultBundle, ArtifactReference, ContentAddressedReference, ExecutionCorrelation)", + "profile_checks": [ + "schema", + "idKinds", + "resultBinding", + "jsonDomain" + ] + }, + { + "key": "transition", + "schema_id": "psyche.transition", + "file": "schemas/psyche.transition.schema.json", + "registry_kind": null, + "record_kind": null, + "id_prefix": null, + "persistable": false, + "stability": "stable-v1", + "source": "crates/psyche-store/src/transitions.rs (Transition)", + "profile_checks": [ + "schema", + "transitionDigest" + ] + } + ], + "enums": { + "graph_state": { + "owner": "crates/psyche-core/src/contracts/graph.rs (GraphState)", + "values": [ + "draft", + "admitted", + "rejected", + "running", + "waiting_approval", + "waiting_evidence", + "cancelling", + "completed", + "failed", + "cancelled", + "recovery_required" + ], + "stability": "stable-v1" + }, + "node_state": { + "owner": "crates/psyche-core/src/contracts/graph.rs (NodeState)", + "values": [ + "proposed", + "admitted", + "rejected", + "blocked", + "ready", + "skipped", + "reserved", + "dispatching", + "adopted", + "adoption_unknown", + "proven_not_adopted", + "failed", + "running", + "waiting_approval", + "candidate", + "awaiting_verification", + "verified", + "escalation_required", + "cancelling", + "cancelled", + "termination_unknown", + "recovery_required" + ], + "stability": "stable-v1" + }, + "adoption_state": { + "owner": "crates/psyche-core/src/contracts/execution.rs (AdoptionState)", + "values": [ + "not_submitted", + "submitting", + "adopted", + "proven_not_adopted", + "adoption_unknown", + "fenced" + ], + "stability": "stable-v1" + }, + "cancellation_state": { + "owner": "crates/psyche-core/src/contracts/execution.rs (CancellationState)", + "values": [ + "not_requested", + "termination_requested", + "acknowledged_terminated", + "acknowledged_already_terminal", + "termination_unknown" + ], + "stability": "stable-v1" + }, + "cancellation_acknowledgement_kind": { + "owner": "crates/psyche-core/src/contracts/execution.rs (CancellationAcknowledgementKind)", + "values": [ + "terminated", + "already_authoritatively_terminal" + ], + "stability": "stable-v1" + }, + "delivery_relationship": { + "owner": "crates/psyche-core/src/contracts/surface.rs (DeliveryRelationship)", + "values": [ + "reply_same_dm", + "reply_same_group", + "reply_same_topic", + "cross_chat", + "broadcast" + ], + "stability": "stable-v1" + }, + "delivery_decision_state": { + "owner": "crates/psyche-core/src/contracts/surface.rs (DeliveryDecisionState)", + "values": [ + "reserved", + "consumed" + ], + "stability": "stable-v1" + }, + "delivery_state": { + "owner": "crates/psyche-core/src/contracts/surface.rs (DeliveryState)", + "values": [ + "ready", + "sending", + "sent", + "retryable", + "delivery_unknown", + "failed", + "abandoned", + "dead_letter", + "resolving_unknown", + "compensated" + ], + "stability": "stable-v1" + }, + "error_code": { + "owner": "crates/psyche-core/src/contracts/error.rs (ErrorCode::ALL)", + "values": [ + "config_invalid", + "secret_unavailable", + "telegram_unauthorized", + "telegram_bot_identity_mismatch", + "telegram_conflict", + "telegram_rate_limited", + "telegram_unavailable", + "webhook_auth_failed", + "storage_unavailable", + "event_schema_unsupported", + "principal_mapping_invalid", + "graph_invalid", + "delegation_widened", + "budget_unenforceable", + "evidence_incomplete", + "verdict_invalid", + "route_not_found", + "route_ambiguous", + "sender_unauthorized", + "identity_invalid", + "identity_changed", + "coven_unavailable", + "coven_version_unsupported", + "coven_capability_missing", + "coven_policy_denied", + "coven_execution_binding_invalid", + "coven_binding_mismatch", + "coven_artifact_rejected", + "coven_intent_conflict", + "coven_adoption_unknown", + "coven_cancellation_unknown", + "coven_session_failed", + "delivery_unknown", + "preview_finalize_blocked", + "media_rejected", + "callback_invalid" + ], + "stability": "stable-v1" + }, + "capability": { + "owner": "crates/psyche-coven/src/port.rs (Capability)", + "values": [ + "stable_adoption", + "ambiguity_fence", + "ordered_events", + "authoritative_termination", + "content_addressed_results" + ], + "stability": "stable-v1" + }, + "rejection_reason": { + "owner": "crates/psyche-core/src/contracts/mod.rs (RejectionReason)", + "values": [ + "too_large", + "unknown_schema", + "unsupported_major", + "unknown_enum_value", + "invalid_shape" + ], + "stability": "stable-v1" + } + }, + "internal_surfaces": [ + { + "key": "config_schema", + "id": "psyche.config.v1", + "stability": "internal", + "source": "crates/psyche-core/src/schema.rs (CONFIG_SCHEMA_VERSION)", + "note": "Daemon configuration file gate. Denies unknown versions unconditionally (no compatibility range, no coercion); not a protocol record and never conformance-checked." + }, + { + "key": "store_schema", + "id": "psyche-store migration 001_foundation", + "stability": "internal", + "source": "crates/psyche-store/migrations/001_foundation.sql", + "note": "Durable SQL layout: canonical_records, execution_binding_revisions, transitions, quarantine, audit events. Storage-internal; the interoperable surface is the canonical_json column this artifact set pins." + }, + { + "key": "coven_ledger_statuses", + "id": "coven raw ledger statuses", + "stability": "internal", + "source": "docs/SCHEMAS.md (Cancellation and results); crates/psyche-coven/src/port.rs", + "note": "created/running/idle/completed/failed/killed/orphaned never appear on the Psyche wire and can never manufacture cancellation acknowledgement evidence." + } + ], + "counts": { + "records": 19, + "stable_v1": 17, + "experimental": 2, + "internal": 0, + "deprecated": 0, + "vectors_total": 33, + "vectors_by_class": { + "positive": 21, + "crash-restart": 3, + "denial": 4, + "stale-correlation": 3, + "unknown-version": 1, + "downgrade": 1 + } + } +} diff --git a/protocol/v1/schemas/psyche.addon.v1.schema.json b/protocol/v1/schemas/psyche.addon.v1.schema.json new file mode 100644 index 0000000..17f7471 --- /dev/null +++ b/protocol/v1/schemas/psyche.addon.v1.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.addon.v1.schema.json", + "title": "psyche.addon.v1", + "description": "Add-on registration with digest-bound package, provenance, contributions, and allowlist.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "addon", + "x-psyche-record-kind": "Addon", + "x-psyche-id-prefix": "adn_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Addon)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.addon.v1" + }, + "addon_id": { + "type": "string", + "pattern": "^adn_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "package": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "package_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "provenance_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "contributions_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "allowlist_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revocation_state": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "schema_version", + "addon_id", + "package", + "version", + "package_digest", + "provenance_digest", + "contributions_digest", + "allowlist_digest", + "revocation_state" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.approval.v1.schema.json b/protocol/v1/schemas/psyche.approval.v1.schema.json new file mode 100644 index 0000000..632fbf7 --- /dev/null +++ b/protocol/v1/schemas/psyche.approval.v1.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.approval.v1.schema.json", + "title": "psyche.approval.v1", + "description": "Approval decision record. decision is a bounded producer token; approval policy is a deferred owner.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "approval", + "x-psyche-record-kind": "Approval", + "x-psyche-id-prefix": "apr_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Approval)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.approval.v1" + }, + "approval_id": { + "type": "string", + "pattern": "^apr_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "requester_principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "decision": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "approval_id", + "node_id", + "requester_principal_id", + "expires_at" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.budget.v1.schema.json b/protocol/v1/schemas/psyche.budget.v1.schema.json new file mode 100644 index 0000000..a77b2ba --- /dev/null +++ b/protocol/v1/schemas/psyche.budget.v1.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.budget.v1.schema.json", + "title": "psyche.budget.v1", + "description": "Budget allocation counters. Budget policy is a deferred owner (docs/SCHEMAS.md, Deferred owners); only the record shape is stable.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "budget", + "x-psyche-record-kind": "Budget", + "x-psyche-id-prefix": "bud_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Budget)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.budget.v1" + }, + "budget_id": { + "type": "string", + "pattern": "^bud_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "resource_class": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reserved": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "released": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "schema_version", + "budget_id", + "graph_id", + "resource_class", + "limit", + "reserved", + "consumed", + "released" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.delegation.v1.schema.json b/protocol/v1/schemas/psyche.delegation.v1.schema.json new file mode 100644 index 0000000..693264b --- /dev/null +++ b/protocol/v1/schemas/psyche.delegation.v1.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.delegation.v1.schema.json", + "title": "psyche.delegation.v1", + "description": "Authority delegation between two graph nodes. scope_digest and evidence_scope_digest are producer claims; authority-widening rejection remains decoder/store authority (error code delegation_widened).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "delegation", + "x-psyche-record-kind": "Delegation", + "x-psyche-id-prefix": "dlg_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Delegation)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.delegation.v1" + }, + "delegation_id": { + "type": "string", + "pattern": "^dlg_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "parent_node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "child_node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "scope_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "budget_id": { + "type": "string", + "pattern": "^bud_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "evidence_scope_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "cancellation_policy": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "schema_version", + "delegation_id", + "parent_node_id", + "child_node_id", + "scope_digest", + "budget_id", + "evidence_scope_digest", + "cancellation_policy" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.delivery.v1.schema.json b/protocol/v1/schemas/psyche.delivery.v1.schema.json new file mode 100644 index 0000000..c467309 --- /dev/null +++ b/protocol/v1/schemas/psyche.delivery.v1.schema.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.delivery.v1.schema.json", + "title": "psyche.delivery.v1", + "description": "Authoritative outbound delivery record at the del_ prefix (dly_ is never accepted). state sent requires telegram_message_id and vice versa; effect must be a nonempty object whose canonical digest matches effect_digest.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "delivery", + "x-psyche-record-kind": "Delivery", + "x-psyche-id-prefix": "del_", + "x-psyche-source": "crates/psyche-core/src/contracts/surface.rs (Delivery)", + "x-psyche-profile-checks": [ + "schema", + "idKinds", + "effectDigest", + "deliverySentBinding", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.delivery.v1" + }, + "delivery_id": { + "type": "string", + "pattern": "^del_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "intent_id": { + "type": "string", + "pattern": "^int_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "action_class": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "chat_id": { + "type": "string", + "pattern": "^-\\d{1,31}$|^\\d{1,32}$" + }, + "topic": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + "relationship": { + "type": "string", + "enum": [ + "reply_same_dm", + "reply_same_group", + "reply_same_topic", + "cross_chat", + "broadcast" + ] + }, + "effect": { + "type": "object", + "minProperties": 1, + "additionalProperties": {} + }, + "effect_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "surface_decision": { + "type": "object", + "properties": { + "decision_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "policy_revision": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "state": { + "type": "string", + "enum": [ + "reserved", + "consumed" + ] + } + }, + "required": [ + "decision_id", + "request_digest", + "policy_revision", + "expires_at", + "state" + ], + "additionalProperties": false + }, + "logical_response_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "logical_part": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "type": "string", + "enum": [ + "ready", + "sending", + "sent", + "retryable", + "delivery_unknown", + "failed", + "abandoned", + "dead_letter", + "resolving_unknown", + "compensated" + ] + }, + "attempt_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "telegram_message_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{1,32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "schema_version", + "delivery_id", + "intent_id", + "action_class", + "account_id", + "chat_id", + "topic", + "relationship", + "effect", + "effect_digest", + "surface_decision", + "logical_response_id", + "logical_part", + "state", + "attempt_count" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.error.v1.schema.json b/protocol/v1/schemas/psyche.error.v1.schema.json new file mode 100644 index 0000000..13fb1a5 --- /dev/null +++ b/protocol/v1/schemas/psyche.error.v1.schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.error.v1.schema.json", + "title": "psyche.error.v1", + "description": "Typed public error envelope; exhaustively decodes every ErrorCode::ALL value but is never persistable (no RecordKind). Unknown codes are a strict decode failure and quarantinable.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "error", + "x-psyche-record-kind": null, + "x-psyche-id-prefix": null, + "x-psyche-source": "crates/psyche-core/src/contracts/error.rs", + "x-psyche-profile-checks": [ + "schema", + "errorEnvelope" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.error.v1" + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "config_invalid", + "secret_unavailable", + "telegram_unauthorized", + "telegram_bot_identity_mismatch", + "telegram_conflict", + "telegram_rate_limited", + "telegram_unavailable", + "webhook_auth_failed", + "storage_unavailable", + "event_schema_unsupported", + "principal_mapping_invalid", + "graph_invalid", + "delegation_widened", + "budget_unenforceable", + "evidence_incomplete", + "verdict_invalid", + "route_not_found", + "route_ambiguous", + "sender_unauthorized", + "identity_invalid", + "identity_changed", + "coven_unavailable", + "coven_version_unsupported", + "coven_capability_missing", + "coven_policy_denied", + "coven_execution_binding_invalid", + "coven_binding_mismatch", + "coven_artifact_rejected", + "coven_intent_conflict", + "coven_adoption_unknown", + "coven_cancellation_unknown", + "coven_session_failed", + "delivery_unknown", + "preview_finalize_blocked", + "media_rejected", + "callback_invalid" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "retryable": { + "type": "boolean" + }, + "correlation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "details": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + } + } + }, + "required": [ + "code", + "message", + "retryable", + "correlation_id", + "details" + ], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "error" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.evidence.v1.schema.json b/protocol/v1/schemas/psyche.evidence.v1.schema.json new file mode 100644 index 0000000..e08caba --- /dev/null +++ b/protocol/v1/schemas/psyche.evidence.v1.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.evidence.v1.schema.json", + "title": "psyche.evidence.v1", + "description": "Evidence metadata bound to a node and attempt. content_digest covers external content; evidence/verdict policy is a deferred owner.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "evidence", + "x-psyche-record-kind": "Evidence", + "x-psyche-id-prefix": "evd_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Evidence)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.evidence.v1" + }, + "evidence_id": { + "type": "string", + "pattern": "^evd_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "content_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "producer": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "collection_method": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "media_type": { + "type": "string", + "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", + "maxLength": 255 + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "retention_policy": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "schema_version", + "evidence_id", + "node_id", + "attempt_id", + "content_digest", + "producer", + "collection_method", + "media_type", + "size", + "created_at", + "retention_policy" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.execution_binding.v1.schema.json b/protocol/v1/schemas/psyche.execution_binding.v1.schema.json new file mode 100644 index 0000000..111a9dc --- /dev/null +++ b/protocol/v1/schemas/psyche.execution_binding.v1.schema.json @@ -0,0 +1,313 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.execution_binding.v1.schema.json", + "title": "psyche.execution_binding.v1", + "description": "Execution binding persisted as the one Attempt record kind: an execution binding *is* an attempt record (SchemaKind::ExecutionBinding maps onto RecordKind::Attempt; there is no duplicate binding-named kind, crates/psyche-core/src/contracts/mod.rs). The cancellation evidence matrix is enforced by ExecutionBinding::validate_cancellation.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "execution_binding", + "x-psyche-record-kind": "Attempt", + "x-psyche-id-prefix": "att_", + "x-psyche-source": "crates/psyche-core/src/contracts/execution.rs", + "x-psyche-profile-checks": [ + "schema", + "bindingRevision", + "bindingCancellation", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.execution_binding.v1" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "previous_revision_digest": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "revision_created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "request_created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "request_valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "coven_contract_version": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "coven_session_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "adoption_state": { + "type": "string", + "enum": [ + "not_submitted", + "submitting", + "adopted", + "proven_not_adopted", + "adoption_unknown", + "fenced" + ] + }, + "event_cursor": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "cancellation_state": { + "type": "string", + "enum": [ + "not_requested", + "termination_requested", + "acknowledged_terminated", + "acknowledged_already_terminal", + "termination_unknown" + ] + }, + "termination_request": { + "anyOf": [ + { + "type": "object", + "properties": { + "termination_request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "termination_request_id", + "created_at", + "valid_until" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "termination_reason_code": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(_[a-z0-9]+)*$", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "cancellation_acknowledgement": { + "anyOf": [ + { + "type": "object", + "properties": { + "acknowledgement_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "termination_request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "execution_request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "execution_request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "kind": { + "type": "string", + "enum": [ + "terminated", + "already_authoritatively_terminal" + ] + }, + "authority_evidence_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "acknowledged_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "acknowledgement_id", + "termination_request_id", + "session_id", + "execution_request_id", + "execution_request_digest", + "kind", + "authority_evidence_digest", + "acknowledged_at" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "cancellation_unresolved": { + "anyOf": [ + { + "type": "object", + "properties": { + "disposition_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "termination_request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "execution_request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "execution_request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "reason_code": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(_[a-z0-9]+)*$", + "maxLength": 128 + }, + "recorded_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "disposition_id", + "termination_request_id", + "session_id", + "execution_request_id", + "execution_request_digest", + "reason_code", + "recorded_at" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "terminal_state": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "schema_version", + "attempt_id", + "revision", + "revision_created_at", + "familiar_snapshot_id", + "project_id", + "request_id", + "request_digest", + "request_created_at", + "request_valid_until", + "coven_contract_version", + "adoption_state", + "cancellation_state" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.execution_request.v1.schema.json b/protocol/v1/schemas/psyche.execution_request.v1.schema.json new file mode 100644 index 0000000..cef8039 --- /dev/null +++ b/protocol/v1/schemas/psyche.execution_request.v1.schema.json @@ -0,0 +1,287 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.execution_request.v1.schema.json", + "title": "psyche.execution_request.v1", + "description": "Digest-bound Coven execution adoption request (launch or input operation). The request digest is SHA-256 over the complete canonical bytes and is pinned by crates/psyche-coven/tests/request_digest.rs. Cross-repository ownership is pending #12.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "experimental", + "x-psyche-registry-kind": null, + "x-psyche-record-kind": null, + "x-psyche-id-prefix": null, + "x-psyche-source": "crates/psyche-coven/src/port.rs (ExecutionRequestInput)", + "x-psyche-profile-checks": [ + "schema", + "idKinds", + "negotiation", + "jsonDomain" + ], + "oneOf": [ + { + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.execution_request.v1" + }, + "operation": { + "const": "launch" + }, + "request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "project_root": { + "type": "string", + "pattern": "^(/|/[^/]+(/[^/]+)*)$", + "maxLength": 4096 + }, + "cwd": { + "type": "string", + "pattern": "^(/|/[^/]+(/[^/]+)*)$", + "maxLength": 4096 + }, + "harness": { + "const": "codex" + }, + "context_manifest_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "delegation_digest": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "budget_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "required_artifact_bindings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "media_type": { + "type": "string", + "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", + "maxLength": 255 + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "artifact_id", + "digest", + "media_type", + "size" + ], + "additionalProperties": false + }, + "maxItems": 1024 + }, + "payload_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "operation", + "request_id", + "graph_id", + "node_id", + "attempt_id", + "principal_id", + "familiar_snapshot_id", + "project_id", + "project_root", + "cwd", + "harness", + "context_manifest_digest", + "budget_digest", + "required_artifact_bindings", + "payload_digest", + "created_at", + "valid_until" + ], + "additionalProperties": false, + "description": "psyche.execution_request.v1 undefined variant" + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.execution_request.v1" + }, + "operation": { + "const": "input" + }, + "request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "input_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "context_manifest_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "required_artifact_bindings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "media_type": { + "type": "string", + "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", + "maxLength": 255 + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "artifact_id", + "digest", + "media_type", + "size" + ], + "additionalProperties": false + }, + "maxItems": 1024 + }, + "payload_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "operation", + "request_id", + "graph_id", + "node_id", + "attempt_id", + "principal_id", + "familiar_snapshot_id", + "project_id", + "session_id", + "input_digest", + "context_manifest_digest", + "required_artifact_bindings", + "payload_digest", + "created_at", + "valid_until" + ], + "additionalProperties": false, + "description": "psyche.execution_request.v1 undefined variant" + } + ] +} diff --git a/protocol/v1/schemas/psyche.graph.v1.schema.json b/protocol/v1/schemas/psyche.graph.v1.schema.json new file mode 100644 index 0000000..b8712eb --- /dev/null +++ b/protocol/v1/schemas/psyche.graph.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.graph.v1.schema.json", + "title": "psyche.graph.v1", + "description": "Execution graph header with a strictly positive monotonic version.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "graph", + "x-psyche-record-kind": "Graph", + "x-psyche-id-prefix": "grf_", + "x-psyche-source": "crates/psyche-core/src/contracts/graph.rs (Graph)", + "x-psyche-profile-checks": [ + "schema" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.graph.v1" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "root_intent_id": { + "type": "string", + "pattern": "^int_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "owner_principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "policy_revision": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "state": { + "type": "string", + "enum": [ + "draft", + "admitted", + "rejected", + "running", + "waiting_approval", + "waiting_evidence", + "cancelling", + "completed", + "failed", + "cancelled", + "recovery_required" + ] + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "schema_version", + "graph_id", + "root_intent_id", + "owner_principal_id", + "policy_revision", + "state", + "version" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.graph_node.v1.schema.json b/protocol/v1/schemas/psyche.graph_node.v1.schema.json new file mode 100644 index 0000000..c0f05b4 --- /dev/null +++ b/protocol/v1/schemas/psyche.graph_node.v1.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.graph_node.v1.schema.json", + "title": "psyche.graph_node.v1", + "description": "Single node within a graph. dependencies is an ordered list of node ids (no decoder cap); required_evidence is capped at 1024 entries of 256 bytes each.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "graph_node", + "x-psyche-record-kind": "GraphNode", + "x-psyche-id-prefix": "nod_", + "x-psyche-source": "crates/psyche-core/src/contracts/graph.rs (GraphNode)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.graph_node.v1" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + } + }, + "delegation_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^dlg_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + { + "type": "null" + } + ] + }, + "budget_id": { + "type": "string", + "pattern": "^bud_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "required_evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 1024 + }, + "state": { + "type": "string", + "enum": [ + "proposed", + "admitted", + "rejected", + "blocked", + "ready", + "skipped", + "reserved", + "dispatching", + "adopted", + "adoption_unknown", + "proven_not_adopted", + "failed", + "running", + "waiting_approval", + "candidate", + "awaiting_verification", + "verified", + "escalation_required", + "cancelling", + "cancelled", + "termination_unknown", + "recovery_required" + ] + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "schema_version", + "node_id", + "graph_id", + "familiar_snapshot_id", + "dependencies", + "budget_id", + "required_evidence", + "state", + "version" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.identity_snapshot.v1.schema.json b/protocol/v1/schemas/psyche.identity_snapshot.v1.schema.json new file mode 100644 index 0000000..72c3abd --- /dev/null +++ b/protocol/v1/schemas/psyche.identity_snapshot.v1.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.identity_snapshot.v1.schema.json", + "title": "psyche.identity_snapshot.v1", + "description": "Point-in-time snapshot of a familiar identity. Provenance and content digests are producer claims over external artifacts; the store separately re-digests the complete canonical bytes at insertion (crates/psyche-store/src/records.rs insert_canonical_in_transaction).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "identity_snapshot", + "x-psyche-record-kind": "IdentitySnapshot", + "x-psyche-id-prefix": "ids_", + "x-psyche-source": "crates/psyche-core/src/contracts/identity.rs", + "x-psyche-profile-checks": [ + "schema", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.identity_snapshot.v1" + }, + "snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "familiar_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "declaration_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "identity_file_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "identity_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "soul_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "role_skill_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "provenance": { + "type": "object", + "properties": { + "familiar_home_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "resolver_version": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "familiar_home_id", + "resolver_version" + ], + "additionalProperties": false + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "snapshot_id", + "familiar_id", + "principal_id", + "revision", + "declaration_digest", + "identity_file_digest", + "identity_digest", + "soul_digest", + "role_skill_digest", + "provenance", + "resolved_at" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.intent.v1.schema.json b/protocol/v1/schemas/psyche.intent.v1.schema.json new file mode 100644 index 0000000..365e021 --- /dev/null +++ b/protocol/v1/schemas/psyche.intent.v1.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.intent.v1.schema.json", + "title": "psyche.intent.v1", + "description": "A user or system intent. `digest` is the producer's claimed content digest; the durable record digest is computed by the store over the complete canonical bytes and is independent of this field.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "intent", + "x-psyche-record-kind": "Intent", + "x-psyche-id-prefix": "int_", + "x-psyche-source": "crates/psyche-core/src/contracts/intent.rs", + "x-psyche-profile-checks": [ + "schema", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.intent.v1" + }, + "intent_id": { + "type": "string", + "pattern": "^int_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "requested_outcome": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "constraints": { + "type": "object", + "additionalProperties": {} + }, + "required_evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 1024 + }, + "surface_event_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^sev_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "required": [ + "schema_version", + "intent_id", + "principal_id", + "familiar_snapshot_id", + "project_id", + "requested_outcome", + "constraints", + "required_evidence", + "created_at", + "digest" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.recovery.v1.schema.json b/protocol/v1/schemas/psyche.recovery.v1.schema.json new file mode 100644 index 0000000..ce19380 --- /dev/null +++ b/protocol/v1/schemas/psyche.recovery.v1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.recovery.v1.schema.json", + "title": "psyche.recovery.v1", + "description": "Ambiguity recovery record: lease identity, optional fence token, bounded ambiguity token. Recovery policy is a deferred owner (docs/SCHEMAS.md, Deferred owners).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "recovery", + "x-psyche-record-kind": "Recovery", + "x-psyche-id-prefix": "rcv_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Recovery)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.recovery.v1" + }, + "recovery_id": { + "type": "string", + "pattern": "^rcv_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "lease_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "fence_token": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "ambiguity": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "reconciliation_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "operator_disposition": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "schema_version", + "recovery_id", + "attempt_id", + "lease_id", + "ambiguity", + "reconciliation_count" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.result_bundle.schema.json b/protocol/v1/schemas/psyche.result_bundle.schema.json new file mode 100644 index 0000000..7bc3b96 --- /dev/null +++ b/protocol/v1/schemas/psyche.result_bundle.schema.json @@ -0,0 +1,236 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.result_bundle.schema.json", + "title": "psyche.result_bundle", + "description": "Complete content-addressed result and artifact references bound to one adoption correlation. Cross-repository ownership pending #12; the schema id carries no .v1 suffix because this type is not in the versioned registry yet.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "experimental", + "x-psyche-registry-kind": null, + "x-psyche-record-kind": null, + "x-psyche-id-prefix": null, + "x-psyche-source": "crates/psyche-coven/src/port.rs (ResultBundle, ArtifactReference, ContentAddressedReference, ExecutionCorrelation)", + "x-psyche-profile-checks": [ + "schema", + "idKinds", + "resultBinding", + "jsonDomain" + ], + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "correlation": { + "type": "object", + "properties": { + "request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "request_id", + "request_digest", + "familiar_snapshot_id", + "project_id", + "graph_id", + "node_id", + "attempt_id", + "created_at", + "valid_until" + ], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "media_type": { + "type": "string", + "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", + "maxLength": 255 + }, + "size_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "digest", + "media_type", + "size_bytes", + "expires_at" + ], + "additionalProperties": false + }, + "artifacts": { + "type": "array", + "maxItems": 1024, + "items": { + "schema": { + "type": "object", + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "correlation": { + "type": "object", + "properties": { + "request_id": { + "type": "string", + "pattern": "^req_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "valid_until": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "request_id", + "request_digest", + "familiar_snapshot_id", + "project_id", + "graph_id", + "node_id", + "attempt_id", + "created_at", + "valid_until" + ], + "additionalProperties": false + }, + "content": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "media_type": { + "type": "string", + "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", + "maxLength": 255 + }, + "size_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "digest", + "media_type", + "size_bytes", + "expires_at" + ], + "additionalProperties": false + } + }, + "required": [ + "artifact_id", + "session_id", + "correlation", + "content" + ], + "additionalProperties": false + }, + "ts": "object" + } + } + }, + "required": [ + "session_id", + "correlation", + "result", + "artifacts" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.surface_effect.v1.schema.json b/protocol/v1/schemas/psyche.surface_effect.v1.schema.json new file mode 100644 index 0000000..64fa429 --- /dev/null +++ b/protocol/v1/schemas/psyche.surface_effect.v1.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.surface_effect.v1.schema.json", + "title": "psyche.surface_effect.v1", + "description": "Full execution correlation for one outbound surface action. effect_digest is recomputed by the decoder over the canonical bytes of effect and must match (SurfaceEffect::validate, surface.rs).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "surface_effect", + "x-psyche-record-kind": "SurfaceEffect", + "x-psyche-id-prefix": "sfx_", + "x-psyche-source": "crates/psyche-core/src/contracts/surface.rs (SurfaceEffect)", + "x-psyche-profile-checks": [ + "schema", + "idKinds", + "effectDigest", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.surface_effect.v1" + }, + "surface_effect_id": { + "type": "string", + "pattern": "^sfx_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "intent_id": { + "type": "string", + "pattern": "^int_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "graph_id": { + "type": "string", + "pattern": "^grf_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "attempt_id": { + "type": "string", + "pattern": "^att_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "familiar_snapshot_id": { + "type": "string", + "pattern": "^ids_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "action_class": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "locator": { + "type": "object", + "additionalProperties": {} + }, + "effect": { + "type": "object", + "additionalProperties": {} + }, + "effect_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "surface_effect_id", + "intent_id", + "graph_id", + "node_id", + "attempt_id", + "familiar_snapshot_id", + "project_id", + "action_class", + "account_id", + "locator", + "effect", + "effect_digest", + "created_at" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.surface_event.v1.schema.json b/protocol/v1/schemas/psyche.surface_event.v1.schema.json new file mode 100644 index 0000000..1efbe6f --- /dev/null +++ b/protocol/v1/schemas/psyche.surface_event.v1.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.surface_event.v1.schema.json", + "title": "psyche.surface_event.v1", + "description": "Adapter-neutral surface observation. actor/locator/content are core-owned, bounded, schema-versioned envelope payloads: adapters cannot add envelope fields or widen payloads (docs/SCHEMAS.md).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "surface_event", + "x-psyche-record-kind": "SurfaceEvent", + "x-psyche-id-prefix": "sev_", + "x-psyche-source": "crates/psyche-core/src/contracts/surface.rs (SurfaceEvent)", + "x-psyche-profile-checks": [ + "schema", + "jsonDomain" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.surface_event.v1" + }, + "surface_event_id": { + "type": "string", + "pattern": "^sev_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "adapter_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "actor": { + "type": "object", + "additionalProperties": {} + }, + "locator": { + "type": "object", + "additionalProperties": {} + }, + "adapter_event_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "received_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + }, + "content": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "schema_version", + "surface_event_id", + "adapter_id", + "account_id", + "actor", + "locator", + "adapter_event_digest", + "received_at", + "content" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.transition.schema.json b/protocol/v1/schemas/psyche.transition.schema.json new file mode 100644 index 0000000..eb2d540 --- /dev/null +++ b/protocol/v1/schemas/psyche.transition.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.transition.schema.json", + "title": "psyche.transition", + "description": "One immutable store-owned state transition. Not a registry document kind: it carries no schema_version of its own; kind names a registry kind (never error, whose record_kind() is None). transition_digest is SHA-256 over the canonical bytes of every other field (TransitionDigestInput omits the digest itself).", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": null, + "x-psyche-record-kind": null, + "x-psyche-id-prefix": null, + "x-psyche-source": "crates/psyche-store/src/transitions.rs (Transition)", + "x-psyche-profile-checks": [ + "schema", + "transitionDigest" + ], + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "identity_snapshot", + "intent", + "surface_event", + "graph", + "graph_node", + "delegation", + "budget", + "approval", + "execution_binding", + "evidence", + "verdict", + "recovery", + "addon", + "surface_effect", + "delivery" + ] + }, + "record_id": { + "type": "string", + "pattern": "^(ids|int|grf|nod|att|dlg|bud|apr|evd|vrd|rcv|adn|sev|sfx|del)_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "record_version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "from_state": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,63}$" + }, + { + "type": "null" + } + ] + }, + "to_state": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,63}$" + }, + "transition_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "kind", + "record_id", + "record_version", + "to_state", + "transition_digest", + "created_at" + ], + "additionalProperties": false +} diff --git a/protocol/v1/schemas/psyche.verdict.v1.schema.json b/protocol/v1/schemas/psyche.verdict.v1.schema.json new file mode 100644 index 0000000..125c548 --- /dev/null +++ b/protocol/v1/schemas/psyche.verdict.v1.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas/psyche.verdict.v1.schema.json", + "title": "psyche.verdict.v1", + "description": "Verdict reached from sealed evidence; verdict policy is a deferred owner.", + "x-psyche-artifact-set": "1.0.0", + "x-psyche-stability": "stable-v1", + "x-psyche-registry-kind": "verdict", + "x-psyche-record-kind": "Verdict", + "x-psyche-id-prefix": "vrd_", + "x-psyche-source": "crates/psyche-core/src/contracts/foundation.rs (Verdict)", + "x-psyche-profile-checks": [ + "schema", + "idKinds" + ], + "type": "object", + "properties": { + "schema_version": { + "const": "psyche.verdict.v1" + }, + "verdict_id": { + "type": "string", + "pattern": "^vrd_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "node_id": { + "type": "string", + "pattern": "^nod_[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "sealed_evidence_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "policy_revision": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "verdict_type": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "reviewer_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "outcome": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "reason_codes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 1024 + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$" + } + }, + "required": [ + "schema_version", + "verdict_id", + "node_id", + "sealed_evidence_digest", + "policy_revision", + "verdict_type", + "reviewer_id", + "outcome", + "reason_codes", + "created_at" + ], + "additionalProperties": false +} diff --git a/protocol/v1/types/psyche-protocol.v1.d.ts b/protocol/v1/types/psyche-protocol.v1.d.ts new file mode 100644 index 0000000..64b3b5b --- /dev/null +++ b/protocol/v1/types/psyche-protocol.v1.d.ts @@ -0,0 +1,458 @@ +// Generated TypeScript surface for the Psyche protocol v1 artifact set. +// Generated by scripts/protocol/generate.mjs from scripts/protocol/definitions.mjs. +// DO NOT EDIT BY HAND: changes belong in definitions.mjs, then regenerate. +// Artifact set: 1.0.0. Schema drift is a CI failure. + +/** Canonical ULID-shaped record identifier, e.g. `att_01ARZ3NDEKTSV4RRFFQ69G5FAV`. */ +export type PsycheRecordId = string; +/** `req_`-prefixed request identifier; never a stored record. */ +export type PsycheRequestId = string; +/** `sha256:<64 lowercase hex>` over canonical JSON bytes. */ +export type PsycheSha256Digest = `sha256:${string}`; +/** RFC 3339 timestamp; canonical producers emit the UTC `Z` form. */ +export type PsycheTimestamp = string; +/** Registry kind segment of a `psyche..v` schema version. */ +export type PsycheRegistryKind = + | "identity_snapshot" + | "intent" + | "surface_event" + | "graph" + | "graph_node" + | "delegation" + | "budget" + | "approval" + | "execution_binding" + | "evidence" + | "verdict" + | "recovery" + | "addon" + | "surface_effect" + | "delivery" + | "error"; + +export interface PsycheExecutionArtifactBinding { + readonly artifact_id: string; + readonly digest: PsycheSha256Digest; + readonly media_type: string; + readonly size: number; +} + +export type PsycheXGraphState = + | "draft" + | "admitted" + | "rejected" + | "running" + | "waiting_approval" + | "waiting_evidence" + | "cancelling" + | "completed" + | "failed" + | "cancelled" + | "recovery_required"; + +export type PsycheXNodeState = + | "proposed" + | "admitted" + | "rejected" + | "blocked" + | "ready" + | "skipped" + | "reserved" + | "dispatching" + | "adopted" + | "adoption_unknown" + | "proven_not_adopted" + | "failed" + | "running" + | "waiting_approval" + | "candidate" + | "awaiting_verification" + | "verified" + | "escalation_required" + | "cancelling" + | "cancelled" + | "termination_unknown" + | "recovery_required"; + +export type PsycheXAdoptionState = + | "not_submitted" + | "submitting" + | "adopted" + | "proven_not_adopted" + | "adoption_unknown" + | "fenced"; + +export type PsycheXCancellationState = + | "not_requested" + | "termination_requested" + | "acknowledged_terminated" + | "acknowledged_already_terminal" + | "termination_unknown"; + +export type PsycheXCancellationAcknowledgementKind = + | "terminated" + | "already_authoritatively_terminal"; + +export type PsycheXDeliveryRelationship = + | "reply_same_dm" + | "reply_same_group" + | "reply_same_topic" + | "cross_chat" + | "broadcast"; + +export type PsycheXDeliveryDecisionState = + | "reserved" + | "consumed"; + +export type PsycheXDeliveryState = + | "ready" + | "sending" + | "sent" + | "retryable" + | "delivery_unknown" + | "failed" + | "abandoned" + | "dead_letter" + | "resolving_unknown" + | "compensated"; + +export type PsycheXErrorCode = + | "config_invalid" + | "secret_unavailable" + | "telegram_unauthorized" + | "telegram_bot_identity_mismatch" + | "telegram_conflict" + | "telegram_rate_limited" + | "telegram_unavailable" + | "webhook_auth_failed" + | "storage_unavailable" + | "event_schema_unsupported" + | "principal_mapping_invalid" + | "graph_invalid" + | "delegation_widened" + | "budget_unenforceable" + | "evidence_incomplete" + | "verdict_invalid" + | "route_not_found" + | "route_ambiguous" + | "sender_unauthorized" + | "identity_invalid" + | "identity_changed" + | "coven_unavailable" + | "coven_version_unsupported" + | "coven_capability_missing" + | "coven_policy_denied" + | "coven_execution_binding_invalid" + | "coven_binding_mismatch" + | "coven_artifact_rejected" + | "coven_intent_conflict" + | "coven_adoption_unknown" + | "coven_cancellation_unknown" + | "coven_session_failed" + | "delivery_unknown" + | "preview_finalize_blocked" + | "media_rejected" + | "callback_invalid"; + +export type PsycheXCapability = + | "stable_adoption" + | "ambiguity_fence" + | "ordered_events" + | "authoritative_termination" + | "content_addressed_results"; + +export type PsycheXRejectionReason = + | "too_large" + | "unknown_schema" + | "unsupported_major" + | "unknown_enum_value" + | "invalid_shape"; + +/** Point-in-time snapshot of a familiar identity. Provenance and content digests are producer claims over external artifacts; the store separately re-digests the complete canonical bytes at insertion (crates/psyche-store/src/records.rs insert_canonical_in_transaction). */ +export interface IdentitySnapshotV1 { + readonly schema_version: "psyche.identity_snapshot.v1"; + readonly snapshot_id: `ids_string`; + readonly familiar_id: string; + readonly principal_id: string; + readonly revision: number; + readonly declaration_digest: `sha256:${string}`; + readonly identity_file_digest: `sha256:${string}`; + readonly identity_digest: `sha256:${string}`; + readonly soul_digest: `sha256:${string}`; + readonly role_skill_digest: `sha256:${string}`; + readonly provenance: object; + readonly resolved_at: string; +} + +/** A user or system intent. `digest` is the producer's claimed content digest; the durable record digest is computed by the store over the complete canonical bytes and is independent of this field. */ +export interface IntentV1 { + readonly schema_version: "psyche.intent.v1"; + readonly intent_id: `int_string`; + readonly principal_id: string; + readonly familiar_snapshot_id: `ids_string`; + readonly project_id: string; + readonly requested_outcome: string; + readonly constraints: Record; + readonly required_evidence: string[]; + readonly surface_event_id?: `sev_string` | null; + readonly created_at: string; + readonly digest: `sha256:${string}`; +} + +/** Adapter-neutral surface observation. actor/locator/content are core-owned, bounded, schema-versioned envelope payloads: adapters cannot add envelope fields or widen payloads (docs/SCHEMAS.md). */ +export interface SurfaceEventV1 { + readonly schema_version: "psyche.surface_event.v1"; + readonly surface_event_id: `sev_string`; + readonly adapter_id: string; + readonly account_id: string; + readonly actor: Record; + readonly locator: Record; + readonly adapter_event_digest: `sha256:${string}`; + readonly received_at: string; + readonly content: Record; +} + +/** Execution graph header with a strictly positive monotonic version. */ +export interface GraphV1 { + readonly schema_version: "psyche.graph.v1"; + readonly graph_id: `grf_string`; + readonly root_intent_id: `int_string`; + readonly owner_principal_id: string; + readonly policy_revision: string; + readonly state: "draft" | "admitted" | "rejected" | "running" | "waiting_approval" | "waiting_evidence" | "cancelling" | "completed" | "failed" | "cancelled" | "recovery_required"; + readonly version: number; +} + +/** Single node within a graph. dependencies is an ordered list of node ids (no decoder cap); required_evidence is capped at 1024 entries of 256 bytes each. */ +export interface GraphNodeV1 { + readonly schema_version: "psyche.graph_node.v1"; + readonly node_id: `nod_string`; + readonly graph_id: `grf_string`; + readonly familiar_snapshot_id: `ids_string`; + readonly dependencies: string[]; + readonly delegation_id?: `dlg_string` | null; + readonly budget_id: `bud_string`; + readonly required_evidence: string[]; + readonly state: "proposed" | "admitted" | "rejected" | "blocked" | "ready" | "skipped" | "reserved" | "dispatching" | "adopted" | "adoption_unknown" | "proven_not_adopted" | "failed" | "running" | "waiting_approval" | "candidate" | "awaiting_verification" | "verified" | "escalation_required" | "cancelling" | "cancelled" | "termination_unknown" | "recovery_required"; + readonly version: number; +} + +/** Authority delegation between two graph nodes. scope_digest and evidence_scope_digest are producer claims; authority-widening rejection remains decoder/store authority (error code delegation_widened). */ +export interface DelegationV1 { + readonly schema_version: "psyche.delegation.v1"; + readonly delegation_id: `dlg_string`; + readonly parent_node_id: `nod_string`; + readonly child_node_id: `nod_string`; + readonly scope_digest: `sha256:${string}`; + readonly budget_id: `bud_string`; + readonly evidence_scope_digest: `sha256:${string}`; + readonly cancellation_policy: string; +} + +/** Budget allocation counters. Budget policy is a deferred owner (docs/SCHEMAS.md, Deferred owners); only the record shape is stable. */ +export interface BudgetV1 { + readonly schema_version: "psyche.budget.v1"; + readonly budget_id: `bud_string`; + readonly graph_id: `grf_string`; + readonly resource_class: string; + readonly limit: number; + readonly reserved: number; + readonly consumed: number; + readonly released: number; +} + +/** Approval decision record. decision is a bounded producer token; approval policy is a deferred owner. */ +export interface ApprovalV1 { + readonly schema_version: "psyche.approval.v1"; + readonly approval_id: `apr_string`; + readonly node_id: `nod_string`; + readonly requester_principal_id: string; + readonly decision?: string | null; + readonly expires_at: string; +} + +/** Execution binding persisted as the one Attempt record kind: an execution binding *is* an attempt record (SchemaKind::ExecutionBinding maps onto RecordKind::Attempt; there is no duplicate binding-named kind, crates/psyche-core/src/contracts/mod.rs). The cancellation evidence matrix is enforced by ExecutionBinding::validate_cancellation. */ +export interface ExecutionBindingV1 { + readonly schema_version: "psyche.execution_binding.v1"; + readonly attempt_id: `att_string`; + readonly revision: number; + readonly previous_revision_digest?: `sha256:${string}` | null; + readonly revision_created_at: string; + readonly familiar_snapshot_id: `ids_string`; + readonly project_id: string; + readonly request_id: `req_${string}`; + readonly request_digest: `sha256:${string}`; + readonly request_created_at: string; + readonly request_valid_until: string; + readonly coven_contract_version: string; + readonly coven_session_id?: string | null; + readonly adoption_state: "not_submitted" | "submitting" | "adopted" | "proven_not_adopted" | "adoption_unknown" | "fenced"; + readonly event_cursor?: string | null; + readonly cancellation_state: "not_requested" | "termination_requested" | "acknowledged_terminated" | "acknowledged_already_terminal" | "termination_unknown"; + readonly termination_request?: object | null; + readonly termination_reason_code?: string | null; + readonly cancellation_acknowledgement?: object | null; + readonly cancellation_unresolved?: object | null; + readonly terminal_state?: string | null; +} + +/** Evidence metadata bound to a node and attempt. content_digest covers external content; evidence/verdict policy is a deferred owner. */ +export interface EvidenceV1 { + readonly schema_version: "psyche.evidence.v1"; + readonly evidence_id: `evd_string`; + readonly node_id: `nod_string`; + readonly attempt_id: `att_string`; + readonly content_digest: `sha256:${string}`; + readonly producer: string; + readonly collection_method: string; + readonly media_type: string; + readonly size: number; + readonly created_at: string; + readonly retention_policy: string; +} + +/** Verdict reached from sealed evidence; verdict policy is a deferred owner. */ +export interface VerdictV1 { + readonly schema_version: "psyche.verdict.v1"; + readonly verdict_id: `vrd_string`; + readonly node_id: `nod_string`; + readonly sealed_evidence_digest: `sha256:${string}`; + readonly policy_revision: string; + readonly verdict_type: string; + readonly reviewer_id: string; + readonly outcome: string; + readonly reason_codes: string[]; + readonly created_at: string; +} + +/** Ambiguity recovery record: lease identity, optional fence token, bounded ambiguity token. Recovery policy is a deferred owner (docs/SCHEMAS.md, Deferred owners). */ +export interface RecoveryV1 { + readonly schema_version: "psyche.recovery.v1"; + readonly recovery_id: `rcv_string`; + readonly attempt_id: `att_string`; + readonly lease_id: string; + readonly fence_token?: string | null; + readonly ambiguity: string; + readonly reconciliation_count: number; + readonly operator_disposition?: string | null; +} + +/** Add-on registration with digest-bound package, provenance, contributions, and allowlist. */ +export interface AddonV1 { + readonly schema_version: "psyche.addon.v1"; + readonly addon_id: `adn_string`; + readonly package: string; + readonly version: string; + readonly package_digest: `sha256:${string}`; + readonly provenance_digest: `sha256:${string}`; + readonly contributions_digest: `sha256:${string}`; + readonly allowlist_digest: `sha256:${string}`; + readonly revocation_state: string; +} + +/** Full execution correlation for one outbound surface action. effect_digest is recomputed by the decoder over the canonical bytes of effect and must match (SurfaceEffect::validate, surface.rs). */ +export interface SurfaceEffectV1 { + readonly schema_version: "psyche.surface_effect.v1"; + readonly surface_effect_id: `sfx_string`; + readonly intent_id: `int_string`; + readonly graph_id: `grf_string`; + readonly node_id: `nod_string`; + readonly attempt_id: `att_string`; + readonly familiar_snapshot_id: `ids_string`; + readonly project_id: string; + readonly action_class: string; + readonly account_id: string; + readonly locator: Record; + readonly effect: Record; + readonly effect_digest: `sha256:${string}`; + readonly created_at: string; +} + +/** Authoritative outbound delivery record at the del_ prefix (dly_ is never accepted). state sent requires telegram_message_id and vice versa; effect must be a nonempty object whose canonical digest matches effect_digest. */ +export interface DeliveryV1 { + readonly schema_version: "psyche.delivery.v1"; + readonly delivery_id: `del_string`; + readonly intent_id: `int_string`; + readonly action_class: string; + readonly account_id: string; + readonly chat_id: string; + readonly topic: object; + readonly relationship: "reply_same_dm" | "reply_same_group" | "reply_same_topic" | "cross_chat" | "broadcast"; + readonly effect: Record; + readonly effect_digest: `sha256:${string}`; + readonly surface_decision: object; + readonly logical_response_id: string; + readonly logical_part: number; + readonly state: "ready" | "sending" | "sent" | "retryable" | "delivery_unknown" | "failed" | "abandoned" | "dead_letter" | "resolving_unknown" | "compensated"; + readonly attempt_count: number; + readonly telegram_message_id?: string | null; +} + +/** Typed public error envelope; exhaustively decodes every ErrorCode::ALL value but is never persistable (no RecordKind). Unknown codes are a strict decode failure and quarantinable. */ +export interface ErrorV1 { + readonly schema_version: "psyche.error.v1"; + readonly error: object; +} + +export interface ExecutionRequestV1Launch { + readonly schema_version: "psyche.execution_request.v1"; + readonly operation: "launch"; + readonly request_id: `req_${string}`; + readonly graph_id: `grf_string`; + readonly node_id: `nod_string`; + readonly attempt_id: `att_string`; + readonly principal_id: string; + readonly familiar_snapshot_id: `ids_string`; + readonly project_id: string; + readonly project_root: string; + readonly cwd: string; + readonly harness: "codex"; + readonly context_manifest_digest: `sha256:${string}`; + readonly delegation_digest?: `sha256:${string}` | null; + readonly budget_digest: `sha256:${string}`; + readonly required_artifact_bindings: PsycheExecutionArtifactBinding[]; + readonly payload_digest: `sha256:${string}`; + readonly created_at: string; + readonly valid_until: string; +} + +export interface ExecutionRequestV1Input { + readonly schema_version: "psyche.execution_request.v1"; + readonly operation: "input"; + readonly request_id: `req_${string}`; + readonly graph_id: `grf_string`; + readonly node_id: `nod_string`; + readonly attempt_id: `att_string`; + readonly principal_id: string; + readonly familiar_snapshot_id: `ids_string`; + readonly project_id: string; + readonly session_id: string; + readonly input_digest: `sha256:${string}`; + readonly context_manifest_digest: `sha256:${string}`; + readonly required_artifact_bindings: PsycheExecutionArtifactBinding[]; + readonly payload_digest: `sha256:${string}`; + readonly created_at: string; + readonly valid_until: string; +} + +/** Digest-bound Coven execution adoption request (launch or input operation). The request digest is SHA-256 over the complete canonical bytes and is pinned by crates/psyche-coven/tests/request_digest.rs. Cross-repository ownership is pending #12. */ +export type ExecutionRequestV1 = ExecutionRequestV1Launch | ExecutionRequestV1Input; + +/** Complete content-addressed result and artifact references bound to one adoption correlation. Cross-repository ownership pending #12; the schema id carries no .v1 suffix because this type is not in the versioned registry yet. */ +export interface ResultBundle { + readonly session_id: string; + readonly correlation: object; + readonly result: object; + readonly artifacts: PsycheArtifactReference[]; +} + +/** One immutable store-owned state transition. Not a registry document kind: it carries no schema_version of its own; kind names a registry kind (never error, whose record_kind() is None). transition_digest is SHA-256 over the canonical bytes of every other field (TransitionDigestInput omits the digest itself). */ +export interface Transition { + readonly kind: "identity_snapshot" | "intent" | "surface_event" | "graph" | "graph_node" | "delegation" | "budget" | "approval" | "execution_binding" | "evidence" | "verdict" | "recovery" | "addon" | "surface_effect" | "delivery"; + readonly record_id: PsycheRecordId; + readonly record_version: number; + readonly from_state?: string | null; + readonly to_state: string; + readonly transition_digest: `sha256:${string}`; + readonly created_at: string; +} diff --git a/scripts/protocol/definitions.mjs b/scripts/protocol/definitions.mjs new file mode 100644 index 0000000..1623ebd --- /dev/null +++ b/scripts/protocol/definitions.mjs @@ -0,0 +1,949 @@ +// Single source of truth for the published Psyche protocol v1 artifact set. +// +// Everything under protocol/v1/ that is machine-generated (JSON Schemas, the +// TypeScript surface, golden vector digests, inventory.json, MANIFEST.sha256) +// is emitted by generate.mjs from the tables in this file. The tables are +// transcriptions of the enforcing Rust code; every entry cites the file that +// enforces it, and the CI drift gate fails if the published artifacts and this +// source ever disagree. +// +// Stability classes mirror issue OpenCoven/psyche#11: +// stable-v1 — frozen wire/storage shape, decoder-enforced by psyche-core +// today; schema publication is a compatibility promise. +// experimental — published for interoperability but owned at a boundary +// whose authority is still settling (Coven boundary, #12); +// may change without a major protocol bump. +// internal — not a cross-repository protocol surface; documented for +// completeness, never conformance-checked. +// deprecated — published but scheduled for removal; none today. + +export const STABILITY = { + STABLE_V1: "stable-v1", + EXPERIMENTAL: "experimental", + INTERNAL: "internal", + DEPRECATED: "deprecated", +}; + +// The JSON Schema dialect published by this artifact set. +export const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"; + +// $id base for published schemas. Pinned to the repository's main branch raw +// URL so schemas resolve without a custom domain. +export const SCHEMA_ID_BASE = + "https://raw.githubusercontent.com/OpenCoven/psyche/main/protocol/v1/schemas"; + +// Canonical ULID shape from psyche-core/src/id.rs: 26 Crockford Base32 +// characters, uppercase, first character restricted to 0..=7 because a ULID's +// 128 bits only fill the top 25.5 of the 26 encoded characters. +export const ULID_PATTERN = "[0-7][0-9A-HJKMNP-TV-Z]{25}"; + +export const RECORD_PREFIXES = { + identity_snapshot: "ids_", + intent: "int_", + graph: "grf_", + graph_node: "nod_", + attempt: "att_", + delegation: "dlg_", + budget: "bud_", + approval: "apr_", + evidence: "evd_", + verdict: "vrd_", + recovery: "rcv_", + addon: "adn_", + surface_event: "sev_", + surface_effect: "sfx_", + delivery: "del_", +}; + +// --------------------------------------------------------------------------- +// JSON Schema fragment builders. Each returns `{ schema, ts }`: the JSON +// Schema fragment and the generated TypeScript type for the same field. +// --------------------------------------------------------------------------- + +const field = (schema, ts) => ({ schema, ts }); + +const str = (max) => field({ type: "string", minLength: 1, maxLength: max }, "string"); + +// Bounded string that may be empty (no decoder `bounded()` call on this field). +const looseStr = (max) => field({ type: "string", minLength: 0, maxLength: max }, "string"); + +const digest = () => + field({ type: "string", pattern: "^sha256:[0-9a-f]{64}$" }, "`sha256:${string}`"); + +// RFC 3339 timestamp. Producers canonicalize to the UTC `Z` form; decoders +// accept the full RFC 3339 syntax the `time` crate accepts +// (time::serde::rfc3339). +const timestamp = () => + field( + { + type: "string", + format: "date-time", + pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$", + }, + "string", + ); + +const integer = (min = -9_007_199_254_740_991) => + field({ type: "integer", minimum: min, maximum: 9_007_199_254_740_991 }, "number"); + +const recordId = (prefix) => + field({ type: "string", pattern: `^${prefix}_${ULID_PATTERN}$` }, `\`${prefix}_${"string"}\``); + +const requestId = () => field({ type: "string", pattern: `^req_${ULID_PATTERN}$` }, "`req_${string}`"); + +const enumeration = (values) => + field( + { type: "string", enum: values }, + values.map((v) => JSON.stringify(v)).join(" | "), + ); + +const boolean = () => field({ type: "boolean" }, "boolean"); + +const nullable = (spec) => ({ + schema: { anyOf: [spec.schema, { type: "null" }] }, + ts: `${spec.ts} | null`, +}); + +function objectSchema(fields, { open = false, minProperties } = {}) { + const properties = {}; + const required = []; + for (const [name, spec] of Object.entries(fields)) { + properties[name] = spec.schema; + if (!spec.optional && !spec.optionalNullable) required.push(name); + } + const schema = { type: "object", properties, required, additionalProperties: open ? {} : false }; + if (minProperties !== undefined) schema.minProperties = minProperties; + return { schema, ts: "object" }; +} + +const object = objectSchema; + +// Open JSON object whose values are any in-domain JSON value. Key/value bounds +// and the global JSON domain (safe integers, depth <= 64) are enforced by the +// conformance profile, not by JSON Schema. +const jsonObject = () => field({ type: "object", additionalProperties: {} }, "Record"); + +const stringList = (maxItems = 1024) => + field( + { type: "array", items: { type: "string", minLength: 1, maxLength: 256 }, maxItems }, + "string[]", + ); + +const idList = (prefix) => + field( + { type: "array", items: { type: "string", pattern: `^${prefix}_${ULID_PATTERN}$` } }, + "string[]", + ); + +const reasonCode = () => + field( + { type: "string", pattern: "^[a-z][a-z0-9]*(_[a-z0-9]+)*$", maxLength: 128 }, + "string", + ); + +const mediaType = () => + field( + { type: "string", pattern: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", maxLength: 255 }, + "string", + ); + +const stableToken = (max = 255) => + field({ type: "string", pattern: "^[a-z0-9_]+$", maxLength: max }, "string"); + +const absolutePath = () => + field({ type: "string", pattern: "^(/|/[^/]+(/[^/]+)*)$", maxLength: 4096 }, "string"); + +// --------------------------------------------------------------------------- +// Enum vocabularies. Each cites the Rust definition that owns the spellings. +// --------------------------------------------------------------------------- + +export const enums = { + graph_state: { + owner: "crates/psyche-core/src/contracts/graph.rs (GraphState)", + values: [ + "draft", "admitted", "rejected", "running", "waiting_approval", + "waiting_evidence", "cancelling", "completed", "failed", "cancelled", + "recovery_required", + ], + stability: STABILITY.STABLE_V1, + }, + node_state: { + owner: "crates/psyche-core/src/contracts/graph.rs (NodeState)", + values: [ + "proposed", "admitted", "rejected", "blocked", "ready", "skipped", + "reserved", "dispatching", "adopted", "adoption_unknown", + "proven_not_adopted", "failed", "running", "waiting_approval", + "candidate", "awaiting_verification", "verified", "escalation_required", + "cancelling", "cancelled", "termination_unknown", "recovery_required", + ], + stability: STABILITY.STABLE_V1, + }, + adoption_state: { + owner: "crates/psyche-core/src/contracts/execution.rs (AdoptionState)", + values: [ + "not_submitted", "submitting", "adopted", "proven_not_adopted", + "adoption_unknown", "fenced", + ], + stability: STABILITY.STABLE_V1, + }, + cancellation_state: { + owner: "crates/psyche-core/src/contracts/execution.rs (CancellationState)", + values: [ + "not_requested", "termination_requested", "acknowledged_terminated", + "acknowledged_already_terminal", "termination_unknown", + ], + stability: STABILITY.STABLE_V1, + }, + cancellation_acknowledgement_kind: { + owner: + "crates/psyche-core/src/contracts/execution.rs (CancellationAcknowledgementKind)", + values: ["terminated", "already_authoritatively_terminal"], + stability: STABILITY.STABLE_V1, + }, + delivery_relationship: { + owner: "crates/psyche-core/src/contracts/surface.rs (DeliveryRelationship)", + values: [ + "reply_same_dm", "reply_same_group", "reply_same_topic", "cross_chat", + "broadcast", + ], + stability: STABILITY.STABLE_V1, + }, + delivery_decision_state: { + owner: "crates/psyche-core/src/contracts/surface.rs (DeliveryDecisionState)", + values: ["reserved", "consumed"], + stability: STABILITY.STABLE_V1, + }, + delivery_state: { + owner: "crates/psyche-core/src/contracts/surface.rs (DeliveryState)", + values: [ + "ready", "sending", "sent", "retryable", "delivery_unknown", "failed", + "abandoned", "dead_letter", "resolving_unknown", "compensated", + ], + stability: STABILITY.STABLE_V1, + }, + error_code: { + owner: "crates/psyche-core/src/contracts/error.rs (ErrorCode::ALL)", + values: [ + "config_invalid", "secret_unavailable", "telegram_unauthorized", + "telegram_bot_identity_mismatch", "telegram_conflict", + "telegram_rate_limited", "telegram_unavailable", "webhook_auth_failed", + "storage_unavailable", "event_schema_unsupported", + "principal_mapping_invalid", "graph_invalid", "delegation_widened", + "budget_unenforceable", "evidence_incomplete", "verdict_invalid", + "route_not_found", "route_ambiguous", "sender_unauthorized", + "identity_invalid", "identity_changed", "coven_unavailable", + "coven_version_unsupported", "coven_capability_missing", + "coven_policy_denied", "coven_execution_binding_invalid", + "coven_binding_mismatch", "coven_artifact_rejected", + "coven_intent_conflict", "coven_adoption_unknown", + "coven_cancellation_unknown", "coven_session_failed", "delivery_unknown", + "preview_finalize_blocked", "media_rejected", "callback_invalid", + ], + stability: STABILITY.STABLE_V1, + }, + capability: { + owner: "crates/psyche-coven/src/port.rs (Capability)", + values: [ + "stable_adoption", "ambiguity_fence", "ordered_events", + "authoritative_termination", "content_addressed_results", + ], + stability: STABILITY.STABLE_V1, + }, + rejection_reason: { + owner: "crates/psyche-core/src/contracts/mod.rs (RejectionReason)", + values: [ + "too_large", "unknown_schema", "unsupported_major", "unknown_enum_value", + "invalid_shape", + ], + stability: STABILITY.STABLE_V1, + }, +}; + +// --------------------------------------------------------------------------- +// Record inventory. Entries appear in SchemaKind::ALL order for the sixteen +// registry kinds, followed by the Coven boundary types and the store-owned +// transition. `registryKind` is the segment in the core document +// registry (null for records outside it); `prefix` is the durable identifier +// prefix (null when the record has no durable identity). +// --------------------------------------------------------------------------- + +function artifactBinding() { + return object({ + artifact_id: str(255), + digest: digest(), + media_type: mediaType(), + size: integer(1), + }).schema; +} + +function contentReference() { + return object({ + digest: digest(), + media_type: mediaType(), + size_bytes: integer(1), + expires_at: timestamp(), + }).schema; +} + +function correlation() { + return object({ + request_id: requestId(), + request_digest: digest(), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + graph_id: recordId("grf"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + created_at: timestamp(), + valid_until: timestamp(), + }).schema; +} + +const transitionState = () => + field({ type: "string", pattern: "^[a-z][a-z0-9_]{0,63}$" }, "string"); + +export const records = [ + { + key: "identity_snapshot", + schemaId: "psyche.identity_snapshot.v1", + registryKind: "identity_snapshot", + recordKind: "IdentitySnapshot", + prefix: "ids_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/identity.rs", + description: + "Point-in-time snapshot of a familiar identity. Provenance and content digests are producer claims over external artifacts; the store separately re-digests the complete canonical bytes at insertion (crates/psyche-store/src/records.rs insert_canonical_in_transaction).", + profileChecks: ["schema", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.identity_snapshot.v1" }, '"psyche.identity_snapshot.v1"'), + snapshot_id: recordId("ids"), + familiar_id: str(255), + principal_id: str(255), + revision: integer(1), + declaration_digest: digest(), + identity_file_digest: digest(), + identity_digest: digest(), + soul_digest: digest(), + role_skill_digest: digest(), + provenance: object({ + familiar_home_id: str(255), + resolver_version: str(255), + }), + resolved_at: timestamp(), + }, + }, + { + key: "intent", + schemaId: "psyche.intent.v1", + registryKind: "intent", + recordKind: "Intent", + prefix: "int_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/intent.rs", + description: + "A user or system intent. `digest` is the producer's claimed content digest; the durable record digest is computed by the store over the complete canonical bytes and is independent of this field.", + profileChecks: ["schema", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.intent.v1" }, '"psyche.intent.v1"'), + intent_id: recordId("int"), + principal_id: str(255), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + requested_outcome: str(16_384), + constraints: jsonObject(), + required_evidence: stringList(), + surface_event_id: nullable(recordId("sev")), + created_at: timestamp(), + digest: digest(), + }, + }, + { + key: "surface_event", + schemaId: "psyche.surface_event.v1", + registryKind: "surface_event", + recordKind: "SurfaceEvent", + prefix: "sev_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/surface.rs (SurfaceEvent)", + description: + "Adapter-neutral surface observation. actor/locator/content are core-owned, bounded, schema-versioned envelope payloads: adapters cannot add envelope fields or widen payloads (docs/SCHEMAS.md).", + profileChecks: ["schema", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.surface_event.v1" }, '"psyche.surface_event.v1"'), + surface_event_id: recordId("sev"), + adapter_id: str(256), + account_id: str(256), + actor: jsonObject(), + locator: jsonObject(), + adapter_event_digest: digest(), + received_at: timestamp(), + content: jsonObject(), + }, + }, + { + key: "graph", + schemaId: "psyche.graph.v1", + registryKind: "graph", + recordKind: "Graph", + prefix: "grf_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/graph.rs (Graph)", + description: "Execution graph header with a strictly positive monotonic version.", + profileChecks: ["schema"], + fields: { + schema_version: field({ const: "psyche.graph.v1" }, '"psyche.graph.v1"'), + graph_id: recordId("grf"), + root_intent_id: recordId("int"), + owner_principal_id: str(255), + policy_revision: str(255), + state: enumeration(enums.graph_state.values), + version: integer(1), + }, + }, + { + key: "graph_node", + schemaId: "psyche.graph_node.v1", + registryKind: "graph_node", + recordKind: "GraphNode", + prefix: "nod_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/graph.rs (GraphNode)", + description: + "Single node within a graph. dependencies is an ordered list of node ids (no decoder cap); required_evidence is capped at 1024 entries of 256 bytes each.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.graph_node.v1" }, '"psyche.graph_node.v1"'), + node_id: recordId("nod"), + graph_id: recordId("grf"), + familiar_snapshot_id: recordId("ids"), + dependencies: field( + { type: "array", items: { type: "string", pattern: `^nod_${ULID_PATTERN}$` } }, + "string[]", + ), + delegation_id: nullable(recordId("dlg")), + budget_id: recordId("bud"), + required_evidence: stringList(), + state: enumeration(enums.node_state.values), + version: integer(1), + }, + }, + { + key: "delegation", + schemaId: "psyche.delegation.v1", + registryKind: "delegation", + recordKind: "Delegation", + prefix: "dlg_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Delegation)", + description: + "Authority delegation between two graph nodes. scope_digest and evidence_scope_digest are producer claims; authority-widening rejection remains decoder/store authority (error code delegation_widened).", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.delegation.v1" }, '"psyche.delegation.v1"'), + delegation_id: recordId("dlg"), + parent_node_id: recordId("nod"), + child_node_id: recordId("nod"), + scope_digest: digest(), + budget_id: recordId("bud"), + evidence_scope_digest: digest(), + cancellation_policy: str(256), + }, + }, + { + key: "budget", + schemaId: "psyche.budget.v1", + registryKind: "budget", + recordKind: "Budget", + prefix: "bud_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Budget)", + description: + "Budget allocation counters. Budget policy is a deferred owner (docs/SCHEMAS.md, Deferred owners); only the record shape is stable.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.budget.v1" }, '"psyche.budget.v1"'), + budget_id: recordId("bud"), + graph_id: recordId("grf"), + resource_class: str(256), + limit: integer(0), + reserved: integer(0), + consumed: integer(0), + released: integer(0), + }, + }, + { + key: "approval", + schemaId: "psyche.approval.v1", + registryKind: "approval", + recordKind: "Approval", + prefix: "apr_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Approval)", + description: + "Approval decision record. decision is a bounded producer token; approval policy is a deferred owner.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.approval.v1" }, '"psyche.approval.v1"'), + approval_id: recordId("apr"), + node_id: recordId("nod"), + requester_principal_id: str(255), + decision: nullable(str(256)), + expires_at: timestamp(), + }, + }, + { + key: "execution_binding", + schemaId: "psyche.execution_binding.v1", + registryKind: "execution_binding", + recordKind: "Attempt", + prefix: "att_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/execution.rs", + description: + "Execution binding persisted as the one Attempt record kind: an execution binding *is* an attempt record (SchemaKind::ExecutionBinding maps onto RecordKind::Attempt; there is no duplicate binding-named kind, crates/psyche-core/src/contracts/mod.rs). The cancellation evidence matrix is enforced by ExecutionBinding::validate_cancellation.", + profileChecks: ["schema", "bindingRevision", "bindingCancellation", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.execution_binding.v1" }, '"psyche.execution_binding.v1"'), + attempt_id: recordId("att"), + revision: integer(1), + previous_revision_digest: nullable(digest()), + revision_created_at: timestamp(), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + request_id: requestId(), + request_digest: digest(), + request_created_at: timestamp(), + request_valid_until: timestamp(), + coven_contract_version: str(255), + coven_session_id: nullable(str(255)), + adoption_state: enumeration(enums.adoption_state.values), + event_cursor: nullable(str(255)), + cancellation_state: enumeration(enums.cancellation_state.values), + termination_request: nullable( + object({ + termination_request_id: requestId(), + created_at: timestamp(), + valid_until: timestamp(), + }), + ), + termination_reason_code: nullable(reasonCode()), + cancellation_acknowledgement: nullable( + object({ + acknowledgement_id: str(255), + termination_request_id: requestId(), + session_id: str(255), + execution_request_id: requestId(), + execution_request_digest: digest(), + kind: enumeration(enums.cancellation_acknowledgement_kind.values), + authority_evidence_digest: digest(), + acknowledged_at: timestamp(), + }), + ), + cancellation_unresolved: nullable( + object({ + disposition_id: str(255), + termination_request_id: requestId(), + session_id: str(255), + execution_request_id: requestId(), + execution_request_digest: digest(), + reason_code: reasonCode(), + recorded_at: timestamp(), + }), + ), + terminal_state: nullable(str(255)), + }, + }, + { + key: "evidence", + schemaId: "psyche.evidence.v1", + registryKind: "evidence", + recordKind: "Evidence", + prefix: "evd_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Evidence)", + description: + "Evidence metadata bound to a node and attempt. content_digest covers external content; evidence/verdict policy is a deferred owner.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.evidence.v1" }, '"psyche.evidence.v1"'), + evidence_id: recordId("evd"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + content_digest: digest(), + producer: str(256), + collection_method: str(256), + media_type: mediaType(), + size: integer(0), + created_at: timestamp(), + retention_policy: str(256), + }, + }, + { + key: "verdict", + schemaId: "psyche.verdict.v1", + registryKind: "verdict", + recordKind: "Verdict", + prefix: "vrd_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Verdict)", + description: "Verdict reached from sealed evidence; verdict policy is a deferred owner.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.verdict.v1" }, '"psyche.verdict.v1"'), + verdict_id: recordId("vrd"), + node_id: recordId("nod"), + sealed_evidence_digest: digest(), + policy_revision: str(256), + verdict_type: str(256), + reviewer_id: str(256), + outcome: str(256), + reason_codes: stringList(), + created_at: timestamp(), + }, + }, + { + key: "recovery", + schemaId: "psyche.recovery.v1", + registryKind: "recovery", + recordKind: "Recovery", + prefix: "rcv_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Recovery)", + description: + "Ambiguity recovery record: lease identity, optional fence token, bounded ambiguity token. Recovery policy is a deferred owner (docs/SCHEMAS.md, Deferred owners).", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.recovery.v1" }, '"psyche.recovery.v1"'), + recovery_id: recordId("rcv"), + attempt_id: recordId("att"), + lease_id: str(255), + fence_token: nullable(str(255)), + ambiguity: str(256), + reconciliation_count: integer(0), + operator_disposition: nullable(str(256)), + }, + }, + { + key: "addon", + schemaId: "psyche.addon.v1", + registryKind: "addon", + recordKind: "Addon", + prefix: "adn_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/foundation.rs (Addon)", + description: + "Add-on registration with digest-bound package, provenance, contributions, and allowlist.", + profileChecks: ["schema", "idKinds"], + fields: { + schema_version: field({ const: "psyche.addon.v1" }, '"psyche.addon.v1"'), + addon_id: recordId("adn"), + package: str(256), + version: str(256), + package_digest: digest(), + provenance_digest: digest(), + contributions_digest: digest(), + allowlist_digest: digest(), + revocation_state: str(256), + }, + }, + { + key: "surface_effect", + schemaId: "psyche.surface_effect.v1", + registryKind: "surface_effect", + recordKind: "SurfaceEffect", + prefix: "sfx_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/surface.rs (SurfaceEffect)", + description: + "Full execution correlation for one outbound surface action. effect_digest is recomputed by the decoder over the canonical bytes of effect and must match (SurfaceEffect::validate, surface.rs).", + profileChecks: ["schema", "idKinds", "effectDigest", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.surface_effect.v1" }, '"psyche.surface_effect.v1"'), + surface_effect_id: recordId("sfx"), + intent_id: recordId("int"), + graph_id: recordId("grf"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + familiar_snapshot_id: recordId("ids"), + project_id: str(256), + action_class: str(256), + account_id: str(256), + locator: jsonObject(), + effect: jsonObject(), + effect_digest: digest(), + created_at: timestamp(), + }, + }, + { + key: "delivery", + schemaId: "psyche.delivery.v1", + registryKind: "delivery", + recordKind: "Delivery", + prefix: "del_", + persistable: true, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/surface.rs (Delivery)", + description: + "Authoritative outbound delivery record at the del_ prefix (dly_ is never accepted). state sent requires telegram_message_id and vice versa; effect must be a nonempty object whose canonical digest matches effect_digest.", + profileChecks: ["schema", "idKinds", "effectDigest", "deliverySentBinding", "jsonDomain"], + fields: { + schema_version: field({ const: "psyche.delivery.v1" }, '"psyche.delivery.v1"'), + delivery_id: recordId("del"), + intent_id: recordId("int"), + action_class: str(256), + account_id: str(256), + chat_id: field( + { type: "string", pattern: "^-\\d{1,31}$|^\\d{1,32}$" }, + "string", + ), + topic: object({ kind: str(256), id: str(256) }), + relationship: enumeration(enums.delivery_relationship.values), + effect: field( + { type: "object", minProperties: 1, additionalProperties: {} }, + "Record", + ), + effect_digest: digest(), + surface_decision: object({ + decision_id: str(256), + request_digest: digest(), + policy_revision: str(256), + expires_at: timestamp(), + state: enumeration(enums.delivery_decision_state.values), + }), + logical_response_id: str(256), + logical_part: integer(0), + state: enumeration(enums.delivery_state.values), + attempt_count: integer(0), + telegram_message_id: nullable(field({ type: "string", pattern: "^\\d{1,32}$" }, "string")), + }, + }, + { + key: "error", + schemaId: "psyche.error.v1", + registryKind: "error", + recordKind: null, + prefix: null, + persistable: false, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-core/src/contracts/error.rs", + description: + "Typed public error envelope; exhaustively decodes every ErrorCode::ALL value but is never persistable (no RecordKind). Unknown codes are a strict decode failure and quarantinable.", + profileChecks: ["schema", "errorEnvelope"], + fields: { + schema_version: field({ const: "psyche.error.v1" }, '"psyche.error.v1"'), + error: object({ + code: enumeration(enums.error_code.values), + message: str(4096), + retryable: boolean(), + correlation_id: str(255), + details: field( + { + type: "object", + maxProperties: 128, + additionalProperties: { type: "string", maxLength: 4096 }, + }, + "Record", + ), + }), + }, + }, + // Coven boundary types — not in the core document registry (decode_document + // answers UnknownSchema for them); owned jointly with the Coven repository + // pending OpenCoven/psyche#12. + { + key: "execution_request", + schemaId: "psyche.execution_request.v1", + registryKind: null, + recordKind: null, + prefix: null, + persistable: false, + stability: STABILITY.EXPERIMENTAL, + source: "crates/psyche-coven/src/port.rs (ExecutionRequestInput)", + description: + "Digest-bound Coven execution adoption request (launch or input operation). The request digest is SHA-256 over the complete canonical bytes and is pinned by crates/psyche-coven/tests/request_digest.rs. Cross-repository ownership is pending #12.", + profileChecks: ["schema", "idKinds", "negotiation", "jsonDomain"], + tagUnion: "operation", + variants: { + launch: { + schema_version: field({ const: "psyche.execution_request.v1" }, '"psyche.execution_request.v1"'), + operation: field({ const: "launch" }, '"launch"'), + request_id: requestId(), + graph_id: recordId("grf"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + principal_id: str(255), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + project_root: absolutePath(), + cwd: absolutePath(), + harness: field({ const: "codex" }, '"codex"'), + context_manifest_digest: digest(), + delegation_digest: nullable(digest()), + budget_digest: digest(), + required_artifact_bindings: field( + { type: "array", items: artifactBinding(), maxItems: 1024 }, + "PsycheExecutionArtifactBinding[]", + ), + payload_digest: digest(), + created_at: timestamp(), + valid_until: timestamp(), + }, + input: { + schema_version: field({ const: "psyche.execution_request.v1" }, '"psyche.execution_request.v1"'), + operation: field({ const: "input" }, '"input"'), + request_id: requestId(), + graph_id: recordId("grf"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + principal_id: str(255), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + session_id: str(255), + input_digest: digest(), + context_manifest_digest: digest(), + required_artifact_bindings: field( + { type: "array", items: artifactBinding(), maxItems: 1024 }, + "PsycheExecutionArtifactBinding[]", + ), + payload_digest: digest(), + created_at: timestamp(), + valid_until: timestamp(), + }, + }, + }, + { + key: "result_bundle", + schemaId: "psyche.result_bundle", + registryKind: null, + recordKind: null, + prefix: null, + persistable: false, + stability: STABILITY.EXPERIMENTAL, + source: + "crates/psyche-coven/src/port.rs (ResultBundle, ArtifactReference, ContentAddressedReference, ExecutionCorrelation)", + description: + "Complete content-addressed result and artifact references bound to one adoption correlation. Cross-repository ownership pending #12; the schema id carries no .v1 suffix because this type is not in the versioned registry yet.", + profileChecks: ["schema", "idKinds", "resultBinding", "jsonDomain"], + fields: { + session_id: str(255), + correlation: object(correlationFieldsObject()), + result: object(contentReferenceFields()), + artifacts: field( + { + type: "array", + maxItems: 1024, + items: object({ + artifact_id: str(255), + session_id: str(255), + correlation: object(correlationFieldsObject()), + content: object(contentReferenceFields()), + }), + }, + "PsycheArtifactReference[]", + ), + }, + }, + { + key: "transition", + schemaId: "psyche.transition", + registryKind: null, + recordKind: null, + prefix: null, + persistable: false, + stability: STABILITY.STABLE_V1, + source: "crates/psyche-store/src/transitions.rs (Transition)", + description: + "One immutable store-owned state transition. Not a registry document kind: it carries no schema_version of its own; kind names a registry kind (never error, whose record_kind() is None). transition_digest is SHA-256 over the canonical bytes of every other field (TransitionDigestInput omits the digest itself).", + profileChecks: ["schema", "transitionDigest"], + fields: { + kind: enumeration([ + "identity_snapshot", "intent", "surface_event", "graph", "graph_node", + "delegation", "budget", "approval", "execution_binding", "evidence", + "verdict", "recovery", "addon", "surface_effect", "delivery", + ]), + record_id: field( + { + type: "string", + pattern: `^(ids|int|grf|nod|att|dlg|bud|apr|evd|vrd|rcv|adn|sev|sfx|del)_${ULID_PATTERN}$`, + }, + "PsycheRecordId", + ), + record_version: integer(1), + from_state: nullable( + field({ type: "string", pattern: "^[a-z][a-z0-9_]{0,63}$" }, "string"), + ), + to_state: field({ type: "string", pattern: "^[a-z][a-z0-9_]{0,63}$" }, "string"), + transition_digest: digest(), + created_at: timestamp(), + }, + }, +]; + +function correlationFieldsObject() { + return { + request_id: requestId(), + request_digest: digest(), + familiar_snapshot_id: recordId("ids"), + project_id: str(255), + graph_id: recordId("grf"), + node_id: recordId("nod"), + attempt_id: recordId("att"), + created_at: timestamp(), + valid_until: timestamp(), + }; +} + +function contentReferenceFields() { + return { + digest: digest(), + media_type: mediaType(), + size_bytes: integer(1), + expires_at: timestamp(), + }; +} + +// Internal surfaces (documented, not published as conformance schemas). +export const internalSurfaces = [ + { + key: "config_schema", + id: "psyche.config.v1", + stability: STABILITY.INTERNAL, + source: "crates/psyche-core/src/schema.rs (CONFIG_SCHEMA_VERSION)", + note: "Daemon configuration file gate. Denies unknown versions unconditionally (no compatibility range, no coercion); not a protocol record and never conformance-checked.", + }, + { + key: "store_schema", + id: "psyche-store migration 001_foundation", + stability: STABILITY.INTERNAL, + source: "crates/psyche-store/migrations/001_foundation.sql", + note: "Durable SQL layout: canonical_records, execution_binding_revisions, transitions, quarantine, audit events. Storage-internal; the interoperable surface is the canonical_json column this artifact set pins.", + }, + { + key: "coven_ledger_statuses", + id: "coven raw ledger statuses", + stability: STABILITY.INTERNAL, + source: "docs/SCHEMAS.md (Cancellation and results); crates/psyche-coven/src/port.rs", + note: "created/running/idle/completed/failed/killed/orphaned never appear on the Psyche wire and can never manufacture cancellation acknowledgement evidence.", + }, +]; + +export const protocolVersion = { + artifactSet: "1.0.0", + supportedMajor: 1, + registrySize: 16, + schemaDialect: "https://json-schema.org/draft/2020-12/schema", +}; diff --git a/scripts/protocol/generate.mjs b/scripts/protocol/generate.mjs new file mode 100644 index 0000000..1c585f7 --- /dev/null +++ b/scripts/protocol/generate.mjs @@ -0,0 +1,455 @@ +// Emits the published protocol/v1 artifact set from the tables in +// definitions.mjs and vectors.mjs. +// +// Outputs (all deterministic; CI re-runs this and fails on `git diff`): +// protocol/v1/schemas/.schema.json JSON Schema draft 2020-12 +// protocol/v1/types/psyche-protocol.v1.d.ts generated TypeScript surface +// protocol/v1/golden//.json byte-exact canonical vectors +// protocol/v1/golden/vectors.json vector manifest with digests +// protocol/v1/inventory.json machine-readable inventory +// protocol/v1/MANIFEST.sha256 sha256sum artifact checksums +// +// Usage: node scripts/protocol/generate.mjs [--check] +// --check regenerates into a temp directory and exits 1 on any drift +// instead of writing (used by CI and local verification). + +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + enums, + internalSurfaces, + JSON_SCHEMA_DIALECT, + protocolVersion, + records, + SCHEMA_ID_BASE, + STABILITY, +} from "./definitions.mjs"; +import { vectors } from "./vectors.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const outputRoot = path.join(repoRoot, "protocol", "v1"); + +const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +function canonicalBytes(value) { + // RFC 8785: JSON.stringify over a deep key-sorted copy. The Psyche JSON + // domain (integers, strings, bools, null, objects, arrays) round-trips + // exactly; parity with serde_json_canonicalizer is pinned by the golden + // digests copied from Rust fixtures. + const sorted = (value) => { + if (Array.isArray(value)) return value.map(sorted); + if (value !== null && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = sorted(value[key]); + return out; + } + return value; + }; + return Buffer.from(JSON.stringify(sorted(value)), "utf8"); +} + +const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`; + +// --------------------------------------------------------------------------- +// Computed digest tokens inside vector documents. +// --------------------------------------------------------------------------- + +function findVector(file) { + const vector = vectors.find((entry) => entry.file === file); + if (!vector) throw new Error(`missing vector ${file}`); + return vector; +} + +function resolveTokens(document, computed) { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk); + if (value !== null && typeof value === "object") { + if (Object.keys(value).length === 1 && typeof value.__computed__ === "string") { + const resolve = computed[value.__computed__]; + if (!resolve) throw new Error(`unknown computed token ${value.__computed__}`); + return resolve(); + } + const out = {}; + for (const [key, entry] of Object.entries(value)) out[key] = walk(entry); + return out; + } + return value; + }; + return walk(document); +} + +function buildComputed() { + const revision1 = findVector("positive/crash-restart/revision-1.json"); + const revision1Bytes = () => canonicalBytes(revision1.document); + const deliveryEffect = () => + structuredClone(findVector("positive/delivery-ready.json").document.effect); + const surfaceEffectValue = () => + structuredClone(findVector("positive/surface-effect.json").document.effect); + const transitionVector = () => findVector("positive/transition.json").document; + return { + revision1Digest: () => `sha256:${sha256Hex(revision1Bytes())}`, + deliveryEffectDigest: () => `sha256:${sha256Hex(canonicalBytes(deliveryEffect()))}`, + surfaceEffectDigest: () => `sha256:${sha256Hex(canonicalBytes(surfaceEffectValue()))}`, + // TransitionDigestInput (crates/psyche-store/src/transitions.rs): every + // transition field except transition_digest itself, canonically digested. + transitionDigest: () => { + const { transition_digest: _omit, ...input } = transitionVector(); + return `sha256:${sha256Hex(canonicalBytes(input))}`; + }, + }; +} + +// --------------------------------------------------------------------------- +// JSON Schema assembly. +// --------------------------------------------------------------------------- + +const isNullable = (spec) => + Array.isArray(spec.schema.anyOf) && spec.schema.anyOf.some((part) => part.type === "null"); + +function schemaHeader(record, file) { + return { + $schema: JSON_SCHEMA_DIALECT, + $id: `${SCHEMA_ID_BASE}/${file}`, + title: record.schemaId, + description: record.description, + "x-psyche-artifact-set": protocolVersion.artifactSet, + "x-psyche-stability": record.stability, + "x-psyche-registry-kind": record.registryKind, + "x-psyche-record-kind": record.recordKind, + "x-psyche-id-prefix": record.prefix, + "x-psyche-source": record.source, + "x-psyche-profile-checks": record.profileChecks, + }; +} + +function objectFromFields(fields) { + const properties = {}; + const required = []; + for (const [name, spec] of Object.entries(fields)) { + properties[name] = spec.schema; + if (!isNullable(spec) && !spec.schema.const) required.push(name); + } + return { type: "object", properties, required, additionalProperties: false }; +} + +// `const` schema fragments imply required (schema_version, operation, ...). +function requiredNames(fields) { + const required = []; + for (const [name, spec] of Object.entries(fields)) { + if (isNullable(spec)) continue; + required.push(name); + } + return required; +} + +function objectSchemaFrom(fields) { + const properties = {}; + for (const [name, spec] of Object.entries(fields)) properties[name] = spec.schema; + return { + type: "object", + properties, + required: requiredNames(fields), + additionalProperties: false, + }; +} + +function buildSchemas() { + const schemas = []; + for (const record of records) { + const file = `${record.schemaId}.schema.json`; + if (record.variants) { + const oneOf = Object.entries(record.variants).map(([, fields]) => ({ + ...objectSchemaFrom(fields), + description: `${record.schemaId} ${fields.operation.const} variant`, + })); + schemas.push({ + ...schemaHeader(record, file), + oneOf, + }); + continue; + } + schemas.push({ + ...schemaHeader(record, file), + ...objectSchemaFrom(record.fields), + }); + } + return schemas; +} + +// --------------------------------------------------------------------------- +// TypeScript surface. +// --------------------------------------------------------------------------- + +const pascal = (schemaId) => + schemaId + .replace(/^psyche\./, "") + .split(/[._]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + +function tsFields(fields) { + const lines = []; + for (const [name, spec] of Object.entries(fields)) { + const optional = isNullable(spec) ? "?" : ""; + lines.push(` readonly ${name}${optional}: ${spec.ts};`); + } + return lines.join("\n"); +} + +function buildTypeScript() { + const header = `// Generated TypeScript surface for the Psyche protocol v1 artifact set. +// Generated by scripts/protocol/generate.mjs from scripts/protocol/definitions.mjs. +// DO NOT EDIT BY HAND: changes belong in definitions.mjs, then regenerate. +// Artifact set: ${protocolVersion.artifactSet}. Schema drift is a CI failure. + +/** Canonical ULID-shaped record identifier, e.g. \`att_01ARZ3NDEKTSV4RRFFQ69G5FAV\`. */ +export type PsycheRecordId = string; +/** \`req_\`-prefixed request identifier; never a stored record. */ +export type PsycheRequestId = string; +/** \`sha256:<64 lowercase hex>\` over canonical JSON bytes. */ +export type PsycheSha256Digest = \`sha256:\${string}\`; +/** RFC 3339 timestamp; canonical producers emit the UTC \`Z\` form. */ +export type PsycheTimestamp = string; +/** Registry kind segment of a \`psyche..v\` schema version. */ +export type PsycheRegistryKind = +${records + .filter((record) => record.registryKind) + .map((record) => ` | "${record.registryKind}"`) + .join("\n")}; +`; + + const bodies = []; + for (const record of records) { + if (record.variants) { + const name = pascal(record.schemaId); + const variantNames = Object.keys(record.variants).map( + (variant) => `${name}${variant.charAt(0).toUpperCase()}${variant.slice(1)}`, + ); + Object.entries(record.variants).forEach(([variant, fields], index) => { + bodies.push( + `export interface ${variantNames[index]} {\n${tsFields(fields)}\n}`, + ); + }); + bodies.push( + `/** ${record.description} */\nexport type ${name} = ${variantNames.join(" | ")};`, + ); + continue; + } + const name = pascal(record.schemaId); + bodies.push( + `/** ${record.description} */\nexport interface ${name} {\n${tsFields(record.fields)}\n}`, + ); + } + + const enumTypes = Object.entries(enums) + .map(([name, def]) => `export type Psyche${pascal(`x.${name}`)} =\n${def.values.map((v) => ` | ${JSON.stringify(v)}`).join("\n")};`) + .join("\n\n"); + + const artifactBinding = `export interface PsycheExecutionArtifactBinding { + readonly artifact_id: string; + readonly digest: PsycheSha256Digest; + readonly media_type: string; + readonly size: number; +}`; + + return `${header}\n${artifactBinding}\n\n${enumTypes}\n\n${bodies.join("\n\n")}\n`; +} + +// --------------------------------------------------------------------------- +// Vector emission. +// --------------------------------------------------------------------------- + +async function emitVectors(goldenDir, manifestEntries) { + const computed = buildComputed(); + const vectorManifest = []; + for (const vector of vectors) { + const target = path.join(goldenDir, vector.file); + await mkdir(path.dirname(target), { recursive: true }); + let bytes; + let record = vector.record; + if (vector.copyBytesFrom) { + const source = path.join(repoRoot, vector.copyBytesFrom); + bytes = await readFile(source); + // Byte copies must already be canonical: re-canonicalizing the parsed + // content must reproduce the source bytes exactly. + const parsed = JSON.parse(bytes.toString("utf8")); + const recanonical = canonicalBytes(parsed); + if (!recanonical.equals(bytes)) { + throw new Error(`${vector.copyBytesFrom} is not canonical; cannot publish as a golden byte copy`); + } + if (record === "execution_request") { + record = `execution_request:${parsed.operation}`; + } + } else { + const resolved = resolveTokens(vector.document, computed); + bytes = canonicalBytes(resolved); + } + await writeFile(target, bytes); + vectorManifest.push({ + file: `golden/${vector.file}`, + sha256: `sha256:${sha256Hex(bytes)}`, + bytes: bytes.length, + record, + class: vector.class, + expect: vector.expect, + failure_surface: vector.failureSurface ?? null, + reason: vector.reason ?? null, + quarantine_class: vector.quarantineClass ?? null, + scenario: vector.scenario, + notes: vector.notes ?? null, + copied_from: vector.copyBytesFrom ?? null, + }); + } + return vectorManifest; +} + +// --------------------------------------------------------------------------- +// Inventory. +// --------------------------------------------------------------------------- + +function buildInventory() { + const counts = { stable_v1: 0, experimental: 0, internal: 0, deprecated: 0 }; + const recordEntries = records.map((record) => { + counts[record.stability.replaceAll("-", "_")] += 1; + return { + key: record.key, + schema_id: record.schemaId, + file: `schemas/${record.schemaId}.schema.json`, + registry_kind: record.registryKind, + record_kind: record.recordKind, + id_prefix: record.prefix, + persistable: record.persistable, + stability: record.stability, + source: record.source, + profile_checks: record.profileChecks, + }; + }); + return { + artifact_set: protocolVersion.artifactSet, + supported_major: protocolVersion.supportedMajor, + registry_size: protocolVersion.registrySize, + schema_dialect: JSON_SCHEMA_DIALECT, + canonicalization: "RFC 8785 (JSON Canonicalization Scheme)", + digest: "sha256 over complete canonical JSON bytes, no trailing newline", + generated_from: "scripts/protocol/definitions.mjs", + records: recordEntries, + enums: Object.fromEntries( + Object.entries(enums).map(([name, def]) => [ + name, + { owner: def.owner, values: def.values, stability: def.stability }, + ]), + ), + internal_surfaces: internalSurfaces, + counts: { + records: recordEntries.length, + ...counts, + vectors_total: vectors.length, + vectors_by_class: vectors.reduce((acc, vector) => { + acc[vector.class] = (acc[vector.class] ?? 0) + 1; + return acc; + }, {}), + }, + }; +} + +// --------------------------------------------------------------------------- +// Main. +// --------------------------------------------------------------------------- + +async function collectFiles(root) { + const out = []; + const walk = async (dir) => { + for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else out.push(path.relative(root, full).split(path.sep).join("/")); + } + }; + await walk(root); + return out; +} + +async function generateInto(root) { + const schemasDir = path.join(root, "schemas"); + const typesDir = path.join(root, "types"); + const goldenDir = path.join(root, "golden"); + await rm(root, { recursive: true, force: true }); + await mkdir(schemasDir, { recursive: true }); + await mkdir(typesDir, { recursive: true }); + await mkdir(goldenDir, { recursive: true }); + + for (const schema of buildSchemas()) { + const file = `${schema.title}.schema.json`; + await writeFile(path.join(schemasDir, file), stableJson(schema)); + } + + await writeFile(path.join(typesDir, "psyche-protocol.v1.d.ts"), buildTypeScript()); + + const vectorManifest = await emitVectors(goldenDir, null); + const manifest = { + artifact_set: protocolVersion.artifactSet, + profile: "consumer-v1", + canonicalization: "RFC 8785 (JSON Canonicalization Scheme)", + digest_rule: "sha256 over the complete canonical bytes of the vector file; no trailing newline", + vectors: vectorManifest, + }; + await writeFile(path.join(goldenDir, "vectors.json"), stableJson(manifest)); + + await writeFile(path.join(root, "inventory.json"), stableJson(buildInventory())); + + const files = await collectFiles(root); + const checksums = []; + for (const file of files) { + const bytes = await readFile(path.join(root, file)); + checksums.push(`${sha256Hex(bytes)} ${file}`); + } + await writeFile(path.join(root, "MANIFEST.sha256"), `${checksums.join("\n")}\n`); +} + +async function main() { + const check = process.argv.includes("--check"); + if (check) { + const temp = path.join(repoRoot, ".protocol-generate-check"); + try { + await generateInto(temp); + const published = existsSync(outputRoot) ? await collectFiles(outputRoot) : []; + const generated = await collectFiles(temp); + const drift = []; + const generatedSet = new Set(generated); + for (const file of published) { + if (!generatedSet.has(file)) { + drift.push(`- published file not generated: ${file}`); + continue; + } + const a = await readFile(path.join(outputRoot, file)); + const b = await readFile(path.join(temp, file)); + if (!a.equals(b)) drift.push(`- content drift: ${file}`); + } + for (const file of generated) { + if (!published.includes(file)) drift.push(`- generated file not published: ${file}`); + } + if (drift.length > 0) { + console.error(`protocol artifact drift detected:\n${drift.join("\n")}`); + console.error("run: node scripts/protocol/generate.mjs"); + process.exit(1); + } + console.log(`protocol artifacts up to date (${generated.length} files)`); + } finally { + await rm(temp, { recursive: true, force: true }); + } + return; + } + await generateInto(outputRoot); + const files = await collectFiles(outputRoot); + console.log(`generated ${files.length} protocol artifact files under protocol/v1`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/protocol/vectors.mjs b/scripts/protocol/vectors.mjs new file mode 100644 index 0000000..a43e733 --- /dev/null +++ b/scripts/protocol/vectors.mjs @@ -0,0 +1,671 @@ +// Golden vector source documents for the published Psyche protocol v1 set. +// +// Every vector is emitted as byte-exact RFC 8785 canonical JSON by +// generate.mjs; the SHA-256 over those bytes is published in +// protocol/v1/golden/vectors.json. Values reuse the exact material Psyche's +// Rust suites already pin (crates/psyche-core/tests/fixtures, +// crates/psyche-coven/tests/fixtures, crates/psyche-core/tests/decode.rs), so +// a consumer can diff this artifact set against the enforcing implementation. +// +// Vector classes (issue OpenCoven/psyche#11 required fixtures): +// positive — decode-accept and pass every profile check +// denial — decode-reject with a quarantinable classification +// stale-correlation — schema-valid, semantically stale, decoder-denied +// unknown-version — a registry kind at an unsupported major +// downgrade — fail-closed behavior on a major the consumer does not +// support (no rollback path exists in v1) +// crash-restart — append-only revision chain binding across restart + +const ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const ULID_B = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; +const ULID_C = "01C3F7YQ4R2M8N6P5K1J9H0GTS"; +const ULID_D = "01D4G8ZR5S3N9P7Q6M2K0J1HTV"; +const ULID_E = "01E5H90S6T4P0Q8R7N3M1K2JVW"; +const ULID_F = "01F6JA1T7V5Q1R9S8P4N2M3KWX"; +const HEX_16 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const T0 = "2026-08-01T00:00:00Z"; +const sha = (hex) => `sha256:${hex}`; + +// --------------------------------------------------------------------------- +// Base documents. +// --------------------------------------------------------------------------- + +const intentDocument = { + schema_version: "psyche.intent.v1", + intent_id: `int_${ULID_A}`, + principal_id: "principal:val", + familiar_snapshot_id: `ids_${ULID_B}`, + 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: T0, + digest: `sha256:${HEX_16}`, +}; + +const graph = { + schema_version: "psyche.graph.v1", + graph_id: `grf_${ULID_B}`, + root_intent_id: `int_${ULID_A}`, + owner_principal_id: "principal:one", + policy_revision: "policy:one", + state: "draft", + version: 1, +}; + +// The crash/restart chain baseline: binding revision 1. +const bindingRevision1 = { + schema_version: "psyche.execution_binding.v1", + attempt_id: `att_${ULID_A}`, + revision: 1, + previous_revision_digest: null, + revision_created_at: T0, + familiar_snapshot_id: `ids_${ULID_B}`, + project_id: "project:one", + request_id: `req_${ULID_A}`, + request_digest: `sha256:${HEX_16}`, + request_created_at: T0, + request_valid_until: "2026-08-01T00:10:00Z", + coven_contract_version: "coven.execution.v1", + coven_session_id: null, + adoption_state: "not_submitted", + event_cursor: null, + cancellation_state: "not_requested", + termination_request: null, + termination_reason_code: null, + cancellation_acknowledgement: null, + cancellation_unresolved: null, + terminal_state: null, +}; + +// Revision 2 binds previous_revision_digest to revision 1's canonical digest; +// the generator resolves the __computed__ token before emitting bytes. +const bindingRevision2 = { + ...bindingRevision1, + revision: 2, + previous_revision_digest: { __computed__: "revision1Digest" }, + revision_created_at: "2026-08-01T00:00:30Z", + coven_session_id: "session-7", + adoption_state: "adopted", + event_cursor: "cursor-0001", +}; + +const acknowledgedBinding = { + ...bindingRevision1, + revision: 2, + previous_revision_digest: { __computed__: "revision1Digest" }, + revision_created_at: "2026-08-01T00:06:00Z", + coven_session_id: "session-9", + adoption_state: "adopted", + event_cursor: "cursor-0009", + cancellation_state: "acknowledged_terminated", + termination_request: { + termination_request_id: `req_${ULID_B}`, + created_at: "2026-08-01T00:02:00Z", + valid_until: "2026-08-01T00:20:00Z", + }, + termination_reason_code: "operator_requested", + cancellation_acknowledgement: { + acknowledgement_id: "ack-1", + termination_request_id: `req_${ULID_B}`, + session_id: "session-9", + execution_request_id: `req_${ULID_A}`, + execution_request_digest: `sha256:${HEX_16}`, + kind: "terminated", + authority_evidence_digest: sha("9999999999999999999999999999999999999999999999999999999999999999"), + acknowledged_at: "2026-08-01T00:05:00Z", + }, + cancellation_unresolved: null, + terminal_state: "completed", +}; + +const deliveryReady = { + schema_version: "psyche.delivery.v1", + delivery_id: `del_${ULID_A}`, + intent_id: `int_${ULID_B}`, + 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: { __computed__: "deliveryEffectDigest" }, + surface_decision: { + decision_id: "decision_01ARZ3NDEKTSV4RRFFQ69G5FAV", + request_digest: `sha256:${HEX_16}`, + 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, +}; + +const surfaceEffect = { + schema_version: "psyche.surface_effect.v1", + surface_effect_id: `sfx_${ULID_A}`, + intent_id: `int_${ULID_B}`, + graph_id: `grf_${ULID_C}`, + node_id: `nod_${ULID_D}`, + attempt_id: `att_${ULID_E}`, + familiar_snapshot_id: `ids_${ULID_F}`, + 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: { __computed__: "surfaceEffectDigest" }, + created_at: "2026-08-01T00:01:00Z", +}; + +// --------------------------------------------------------------------------- +// Vector table. Order is the publication order under protocol/v1/golden/. +// --------------------------------------------------------------------------- + +export const vectors = [ + // ---------------------------------------------------------------- positives + { + file: "positive/identity-snapshot.json", + record: "identity_snapshot", + class: "positive", + expect: "accept", + scenario: "identity snapshot with producer-claimed content digests", + document: { + schema_version: "psyche.identity_snapshot.v1", + snapshot_id: `ids_${ULID_A}`, + familiar_id: "familiar:one", + principal_id: "principal:val", + revision: 3, + declaration_digest: sha("1111111111111111111111111111111111111111111111111111111111111111"), + identity_file_digest: sha("2222222222222222222222222222222222222222222222222222222222222222"), + identity_digest: sha("3333333333333333333333333333333333333333333333333333333333333333"), + soul_digest: sha("4444444444444444444444444444444444444444444444444444444444444444"), + role_skill_digest: sha("5555555555555555555555555555555555555555555555555555555555555555"), + provenance: { familiar_home_id: "home:main", resolver_version: "resolver.v1" }, + resolved_at: T0, + }, + }, + { + file: "positive/intent.json", + record: "intent", + class: "positive", + expect: "accept", + scenario: "local intent with empty constraints", + document: structuredClone(intentDocument), + }, + { + file: "positive/surface-event.json", + record: "surface_event", + class: "positive", + expect: "accept", + scenario: "inbound surface observation", + document: { + schema_version: "psyche.surface_event.v1", + surface_event_id: `sev_${ULID_A}`, + adapter_id: "telegram", + account_id: "main", + actor: { type: "user", id: "123" }, + locator: { type: "message", chat_id: "-100123", message_id: "42" }, + adapter_event_digest: `sha256:${HEX_16}`, + received_at: T0, + content: { type: "text", text: "Please review this." }, + }, + }, + { + file: "positive/graph.json", + record: "graph", + class: "positive", + expect: "accept", + scenario: "graph header in draft state", + document: graph, + }, + { + file: "positive/graph-node.json", + record: "graph_node", + class: "positive", + expect: "accept", + scenario: "root graph node", + document: { + schema_version: "psyche.graph_node.v1", + node_id: `nod_${ULID_A}`, + graph_id: `grf_${ULID_B}`, + familiar_snapshot_id: `ids_${ULID_C}`, + dependencies: [], + delegation_id: null, + budget_id: `bud_${ULID_D}`, + required_evidence: ["tests", "diff_review"], + state: "ready", + version: 1, + }, + }, + { + file: "positive/delegation.json", + record: "delegation", + class: "positive", + expect: "accept", + scenario: "node-to-node authority delegation", + document: { + schema_version: "psyche.delegation.v1", + delegation_id: `dlg_${ULID_C}`, + parent_node_id: `nod_${ULID_A}`, + child_node_id: `nod_${ULID_B}`, + scope_digest: sha("1111111111111111111111111111111111111111111111111111111111111111"), + budget_id: `bud_${ULID_D}`, + evidence_scope_digest: sha("2222222222222222222222222222222222222222222222222222222222222222"), + cancellation_policy: "terminate_with_acknowledgement", + }, + }, + { + file: "positive/budget.json", + record: "budget", + class: "positive", + expect: "accept", + scenario: "budget allocation counters", + document: { + schema_version: "psyche.budget.v1", + budget_id: `bud_${ULID_D}`, + graph_id: `grf_${ULID_B}`, + resource_class: "tokens", + limit: 100000, + reserved: 100, + consumed: 40, + released: 10, + }, + }, + { + file: "positive/approval.json", + record: "approval", + class: "positive", + expect: "accept", + scenario: "pending approval without a decision", + document: { + schema_version: "psyche.approval.v1", + approval_id: `apr_${ULID_E}`, + node_id: `nod_${ULID_A}`, + requester_principal_id: "principal:val", + decision: null, + expires_at: "2026-08-01T01:00:00Z", + }, + }, + { + file: "positive/execution-binding-revision-1.json", + record: "execution_binding", + class: "positive", + expect: "accept", + scenario: "binding revision 1 without session or cancellation evidence", + document: structuredClone(bindingRevision1), + }, + { + file: "positive/execution-binding-acknowledged.json", + record: "execution_binding", + class: "positive", + expect: "accept", + scenario: + "acknowledged_terminated with matching core-owned evidence inside the termination window (C-S9 shape)", + document: structuredClone(acknowledgedBinding), + }, + { + file: "positive/evidence.json", + record: "evidence", + class: "positive", + expect: "accept", + scenario: "evidence metadata bound to node and attempt", + document: { + schema_version: "psyche.evidence.v1", + evidence_id: `evd_${ULID_C}`, + node_id: `nod_${ULID_A}`, + attempt_id: `att_${ULID_E}`, + content_digest: sha("5555555555555555555555555555555555555555555555555555555555555555"), + producer: "psyche-test", + collection_method: "direct", + media_type: "text/plain", + size: 12, + created_at: T0, + retention_policy: "default", + }, + }, + { + file: "positive/verdict.json", + record: "verdict", + class: "positive", + expect: "accept", + scenario: "verdict over sealed evidence", + document: { + schema_version: "psyche.verdict.v1", + verdict_id: `vrd_${ULID_F}`, + node_id: `nod_${ULID_A}`, + sealed_evidence_digest: sha("6666666666666666666666666666666666666666666666666666666666666666"), + policy_revision: "policy:one", + verdict_type: "acceptance", + reviewer_id: "reviewer:one", + outcome: "approved", + reason_codes: ["tests", "diff_review"], + created_at: T0, + }, + }, + { + file: "positive/recovery.json", + record: "recovery", + class: "positive", + expect: "accept", + scenario: "ambiguity recovery with fence token", + document: { + schema_version: "psyche.recovery.v1", + recovery_id: `rcv_${ULID_F}`, + attempt_id: `att_${ULID_E}`, + lease_id: "lease-1", + fence_token: "fence-0001", + ambiguity: "session_identity_ambiguous", + reconciliation_count: 2, + operator_disposition: null, + }, + }, + { + file: "positive/addon.json", + record: "addon", + class: "positive", + expect: "accept", + scenario: "digest-bound add-on registration", + document: { + schema_version: "psyche.addon.v1", + addon_id: `adn_${ULID_F}`, + package: "example-addon", + version: "1.0.0", + package_digest: sha("7777777777777777777777777777777777777777777777777777777777777777"), + provenance_digest: sha("777777777777777777777777777777777777777777777777777777777777777a"), + contributions_digest: sha("777777777777777777777777777777777777777777777777777777777777777b"), + allowlist_digest: sha("777777777777777777777777777777777777777777777777777777777777777b"), + revocation_state: "active", + }, + }, + { + file: "positive/surface-effect.json", + record: "surface_effect", + class: "positive", + expect: "accept", + scenario: "outbound effect with decoder-recomputed effect_digest", + document: surfaceEffect, + }, + { + file: "positive/delivery-ready.json", + record: "delivery", + class: "positive", + expect: "accept", + scenario: "delivery in ready state, message id absent", + document: deliveryReady, + }, + { + file: "positive/error-storage-unavailable.json", + record: "error", + class: "positive", + expect: "accept", + scenario: "structured denial envelope for a retryable storage outage", + document: { + 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" }, + }, + }, + }, + { + file: "positive/execution-request-launch.json", + record: "execution_request", + class: "positive", + expect: "accept", + scenario: "launch adoption request (byte-copy of the Rust golden)", + copyBytesFrom: "crates/psyche-coven/tests/fixtures/execution-request-launch.json", + }, + { + file: "positive/execution-request-input.json", + record: "execution_request", + class: "positive", + expect: "accept", + scenario: "input adoption request for an adopted session", + copyBytesFrom: "crates/psyche-coven/tests/fixtures/execution-request-input.json", + }, + { + file: "positive/result-bundle.json", + record: "result_bundle", + class: "positive", + expect: "accept", + scenario: "result/artifact bundle bound to the launch correlation", + copyBytesFrom: "crates/psyche-coven/tests/fixtures/result-bundle.json", + }, + { + file: "positive/transition.json", + record: "transition", + class: "positive", + expect: "accept", + scenario: "append-only graph transition", + document: { + kind: "graph", + record_id: `grf_${ULID_B}`, + record_version: 1, + from_state: null, + to_state: "draft", + transition_digest: { __computed__: "transitionDigest" }, + created_at: T0, + }, + }, + + // crash/restart revision chain (positive members of the crash-restart class) + { + file: "positive/crash-restart/revision-1.json", + record: "execution_binding", + class: "crash-restart", + expect: "accept", + scenario: "revision 1 persisted before the crash", + document: structuredClone(bindingRevision1), + }, + { + file: "positive/crash-restart/revision-2.json", + record: "execution_binding", + class: "crash-restart", + expect: "accept", + scenario: + "post-restart revision 2 chained to revision 1's canonical digest with a strictly later revision_created_at", + document: structuredClone(bindingRevision2), + }, + + // ------------------------------------------------------------------ denial + { + file: "negative/denial-unknown-kind.json", + record: null, + class: "denial", + expect: "reject", + failureSurface: "decode", + reason: "unknown_schema", + quarantineClass: "unknown_schema", + scenario: "schema kind outside the registry is quarantined, never dispatched", + document: { + schema_version: "psyche.delivery_receipt.v9", + receipt_id: "not-a-record", + }, + }, + { + file: "negative/denial-unknown-enum.json", + record: "delivery", + class: "denial", + expect: "reject", + failureSurface: "decode", + reason: "unknown_enum_value", + quarantineClass: "unknown_enum_value", + scenario: "delivery state outside the frozen vocabulary", + document: { ...structuredClone(deliveryReady), state: "archived" }, + }, + { + file: "negative/denial-unknown-field.json", + record: "intent", + class: "denial", + expect: "reject", + failureSurface: "decode", + reason: "invalid_shape", + quarantineClass: "invalid_shape", + scenario: "unknown envelope field is denied (deny_unknown_fields)", + document: { + ...structuredClone(intentDocument), + adapter_note: "extra field not in the envelope", + }, + }, + { + file: "negative/denial-unknown-code.json", + record: "error", + class: "denial", + expect: "reject", + failureSurface: "decode", + reason: "unknown_enum_value", + quarantineClass: "unknown_enum_value", + scenario: "error envelope code outside ErrorCode::ALL", + document: { + schema_version: "psyche.error.v1", + error: { + code: "quantum_flux", + message: "redacted public message", + retryable: false, + correlation_id: "corr-1", + details: {}, + }, + }, + }, + + // ---------------------------------------------------------- stale correlation + { + file: "negative/stale-correlation-ack-outside-window.json", + record: "execution_binding", + class: "stale-correlation", + expect: "reject", + failureSurface: "decode", + reason: "cancellation_evidence_mismatch", + quarantineClass: "invalid_shape", + scenario: + "acknowledgement recorded after the termination window closed: schema-valid shape, decoder-denied by ExecutionBinding::validate_cancellation", + document: { + ...structuredClone(acknowledgedBinding), + previous_revision_digest: null, + revision: 1, + revision_created_at: T0, + coven_session_id: "session-9", + cancellation_acknowledgement: { + acknowledgement_id: "ack-1", + termination_request_id: `req_${ULID_B}`, + session_id: "session-9", + execution_request_id: `req_${ULID_A}`, + execution_request_digest: `sha256:${HEX_16}`, + kind: "terminated", + authority_evidence_digest: sha("9999999999999999999999999999999999999999999999999999999999999999"), + acknowledged_at: "2026-08-09T00:00:00Z", + }, + }, + }, + { + file: "negative/stale-correlation-expired-request.json", + record: "execution_binding", + class: "stale-correlation", + expect: "reject", + failureSurface: "decode", + reason: "invalid_shape", + quarantineClass: "invalid_shape", + scenario: "request_valid_until not after request_created_at", + document: { + ...structuredClone(bindingRevision1), + request_valid_until: T0, + }, + }, + { + file: "negative/stale-correlation-wrong-session.json", + record: "execution_binding", + class: "stale-correlation", + expect: "reject", + failureSurface: "decode", + reason: "cancellation_evidence_mismatch", + quarantineClass: "invalid_shape", + scenario: + "acknowledgement binds session-7 while the binding carries session-9 (validate_evidence_bindings)", + document: { + ...structuredClone(acknowledgedBinding), + coven_session_id: "session-7", + cancellation_acknowledgement: { + acknowledgement_id: "ack-1", + termination_request_id: `req_${ULID_B}`, + session_id: "session-9", + execution_request_id: `req_${ULID_A}`, + execution_request_digest: `sha256:${HEX_16}`, + kind: "terminated", + authority_evidence_digest: sha("9999999999999999999999999999999999999999999999999999999999999999"), + acknowledged_at: "2026-08-01T00:05:00Z", + }, + }, + }, + + // ------------------------------------------------------------ unknown version + { + file: "negative/unknown-version-intent-v2.json", + record: null, + class: "unknown-version", + expect: "reject", + failureSurface: "decode", + reason: "unsupported_major", + quarantineClass: "unsupported_major", + scenario: "psyche.intent.v2 against the v1-only registry", + document: { + schema_version: "psyche.intent.v2", + intent_id: `int_${ULID_A}`, + principal_id: "principal:val", + familiar_snapshot_id: `ids_${ULID_B}`, + project_id: "project:one", + requested_outcome: "Future-shaped payload must fail closed.", + constraints: {}, + required_evidence: [], + surface_event_id: null, + created_at: T0, + digest: `sha256:${HEX_16}`, + }, + }, + + // ----------------------------------------------------------------- downgrade + { + file: "negative/downgrade-major-graph-v2.json", + record: null, + class: "downgrade", + expect: "reject", + failureSurface: "decode", + reason: "unsupported_major", + quarantineClass: "unsupported_major", + scenario: + "downgrade direction: a v1-only consumer must fail closed on a newer major regardless of producer intent; the registry has no rollback path", + document: { ...graph, schema_version: "psyche.graph.v2" }, + }, + + // ------------------------------------------------------------- crash/restart + { + file: "negative/crash-restart-broken-chain.json", + record: "execution_binding", + class: "crash-restart", + expect: "reject", + failureSurface: "profile", + reason: "revision_chain_digest_mismatch", + quarantineClass: "invalid_shape", + scenario: + "revision 2 whose previous_revision_digest does not bind revision 1's canonical bytes: schema-valid, profile-rejected; the store answers DatabaseCorruption (validate_revision_chain)", + document: { + ...structuredClone(bindingRevision2), + previous_revision_digest: sha("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), + }, + }, +]; From d33fd79b37af78872471435a9c6a62d58df77a63 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:05:03 +0000 Subject: [PATCH 2/4] fix(protocol): gate the transition vector off the registry decoder The store-owned transition carries no schema_version of its own, so decode_document must deny it; the positive-vector cross-check ran the decode before reaching the None branch. Restructure the loop so store-owned vectors assert denial and registry vectors decode and re-canonicalize, matching docs/PROTOCOL.md section 2. Refs OpenCoven/psyche#11 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- crates/psyche-core/tests/protocol_golden.rs | 50 ++++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/psyche-core/tests/protocol_golden.rs b/crates/psyche-core/tests/protocol_golden.rs index ee06506..de89825 100644 --- a/crates/psyche-core/tests/protocol_golden.rs +++ b/crates/psyche-core/tests/protocol_golden.rs @@ -96,29 +96,47 @@ fn vector_bytes(relative: &str) -> Vec { fn every_published_positive_vector_decodes_and_recanonicalizes_to_the_published_bytes() { for (file, expected_prefix) in POSITIVE_VECTORS { let bytes = vector_bytes(file); - let document = decode_document(&bytes) - .unwrap_or_else(|error| panic!("{file} must decode as a positive vector: {error}")); - if let Some(expected_prefix) = expected_prefix { - // `psyche.error.v1` never persists, so only some vectors carry a - // durable record id. - if let Some(id) = document.persistable_record_id() { + // `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 expected_prefix { + Some(_) => Some(decode_document(&bytes).unwrap_or_else(|error| { + panic!("{file} must decode as a positive vector: {error}") + })), + None => { assert!( - id.as_str().starts_with(expected_prefix), - "{file}: record id {} lacks the {expected_prefix} prefix", - id.as_str(), + decode_document(&bytes).is_err(), + "{file}: store-owned records are not registry documents" ); + None + } + }; + if let Some(document) = document.as_ref() { + if let Some(expected_prefix) = expected_prefix { + // `psyche.error.v1` never persists, so only some vectors + // carry a durable record id. + if let Some(id) = document.persistable_record_id() { + assert!( + id.as_str().starts_with(expected_prefix), + "{file}: record id {} lacks the {expected_prefix} prefix", + id.as_str(), + ); + } } } // 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" - ); + if let Some(document) = document.as_ref() { + 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" + ); + } } } From bba78cb0c93d8b3e8d69942c11746f65eac06bfb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:10:32 +0000 Subject: [PATCH 3/4] fix(protocol): separate not-persistable vectors from non-document vectors psyche.error.v1 decodes exhaustively and only then refuses persistence, so the error-envelope golden vector belongs in the decode-and-recanonicalize path without an id-prefix assertion, while psyche.transition alone is a non-document. Encode the three cases as an explicit IdExpectation so each is asserted on its own terms. Refs OpenCoven/psyche#11 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- crates/psyche-core/tests/protocol_golden.rs | 140 +++++++++++++------- 1 file changed, 90 insertions(+), 50 deletions(-) diff --git a/crates/psyche-core/tests/protocol_golden.rs b/crates/psyche-core/tests/protocol_golden.rs index de89825..594fdbc 100644 --- a/crates/psyche-core/tests/protocol_golden.rs +++ b/crates/psyche-core/tests/protocol_golden.rs @@ -10,30 +10,65 @@ use psyche_core::digest::canonical_bytes; const PROTOCOL_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../protocol/v1/golden"); -/// (vector file, expected id prefix). The registry-kind positive vectors; -/// `psyche.transition` is store-owned (not a registry document) and the Coven -/// boundary types are pinned by crates/psyche-coven, so neither appears here. -const POSITIVE_VECTORS: &[(&str, Option<&str>)] = &[ - ("positive/identity-snapshot.json", Some("ids_")), - ("positive/intent.json", Some("int_")), - ("positive/surface-event.json", Some("sev_")), - ("positive/graph.json", Some("grf_")), - ("positive/graph-node.json", Some("nod_")), - ("positive/delegation.json", Some("dlg_")), - ("positive/budget.json", Some("bud_")), - ("positive/approval.json", Some("apr_")), - ("positive/execution-binding-revision-1.json", Some("att_")), - ("positive/execution-binding-acknowledged.json", Some("att_")), - ("positive/evidence.json", Some("evd_")), - ("positive/verdict.json", Some("vrd_")), - ("positive/recovery.json", Some("rcv_")), - ("positive/addon.json", Some("adn_")), - ("positive/surface-effect.json", Some("sfx_")), - ("positive/delivery-ready.json", Some("del_")), - ("positive/error-storage-unavailable.json", None), - ("positive/transition.json", None), - ("positive/crash-restart/revision-1.json", Some("att_")), - ("positive/crash-restart/revision-2.json", Some("att_")), +/// 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 @@ -94,49 +129,54 @@ fn vector_bytes(relative: &str) -> Vec { #[test] fn every_published_positive_vector_decodes_and_recanonicalizes_to_the_published_bytes() { - for (file, expected_prefix) in POSITIVE_VECTORS { + 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 expected_prefix { - Some(_) => Some(decode_document(&bytes).unwrap_or_else(|error| { - panic!("{file} must decode as a positive vector: {error}") - })), - None => { + 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 Some(document) = document.as_ref() { - if let Some(expected_prefix) = expected_prefix { - // `psyche.error.v1` never persists, so only some vectors - // carry a durable record id. - if let Some(id) = document.persistable_record_id() { - assert!( - id.as_str().starts_with(expected_prefix), - "{file}: record id {} lacks the {expected_prefix} prefix", - id.as_str(), - ); - } + 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. - if let Some(document) = document.as_ref() { - 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" - ); - } + 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" + ); } } From 66e86f369e33019a34eb049b06ca3e13d262dd65 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:27:45 +0000 Subject: [PATCH 4/4] chore(ci): re-pin the reviewed workflow hash for the protocol job Adding the protocol job to .github/workflows/ci.yml (issue #11 drift gate) changes the reviewed workflow content, so the evidence checker's hash pin must be updated in the same reviewed change. Root triggers, global env, the rust matrix, required job shapes, and every pinned G2 command are unchanged and still verified structurally. Refs OpenCoven/psyche#11 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- scripts/check-g2-evidence.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check-g2-evidence.py b/scripts/check-g2-evidence.py index f5d8ce9..5365131 100644 --- a/scripts/check-g2-evidence.py +++ b/scripts/check-g2-evidence.py @@ -104,7 +104,12 @@ class EvidenceError(RuntimeError): # Pins the complete reviewed workflow, including setup/actions and every writer # that could poison GITHUB_ENV or GITHUB_PATH. Newlines are normalized first so # the same reviewed content verifies on Windows checkouts. -REVIEWED_WORKFLOW_SHA256 = "1f908303c1a8940ce5ec8c81182ddaf5d82e5c6bddd5ece7fb33e1baf9a087f1" +# +# Updated with the reviewed addition of the `protocol` job (issue #11): the +# job re-runs the deterministic protocol-artifact generator and fails on +# uncommitted drift, then runs the standalone conformance runner against the +# published artifact set. No G2 command, trigger, env, or job shape changed. +REVIEWED_WORKFLOW_SHA256 = "e9c4201165922259ed5c8406bb1e7b7f2fe1836b74f26f26908257b22f137675" CI_WORKFLOW_ID = 326408880