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..594fdbc --- /dev/null +++ b/crates/psyche-core/tests/protocol_golden.rs @@ -0,0 +1,239 @@ +//! Cross-checks the published protocol v1 golden vectors against the real +//! canonical decoder: every positive vector must decode, re-canonicalize to +//! exactly the published bytes, and every negative vector must be denied with +//! the class the artifact set promises. This is the Rust-side half of the +//! drift gate described in docs/PROTOCOL.md section 6. +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use psyche_core::contracts::{CanonicalDocument, ContractError, decode_document}; +use psyche_core::digest::canonical_bytes; + +const PROTOCOL_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../protocol/v1/golden"); + +/// What the registry decoder should do with a positive vector's identity. +#[derive(Debug, PartialEq, Eq)] +enum IdExpectation { + /// A persistable registry record whose id carries this prefix. + Prefix(&'static str), + /// Decodes as a registry document but never persists (`psyche.error.v1`). + NotPersistable, + /// Store-owned and not a registry document at all (`psyche.transition`): + /// the decoder must deny it for want of a schema_version. + NotADocument, +} + +/// (vector file, identity expectation). The Coven boundary types are pinned +/// by crates/psyche-coven and do not appear here. +const POSITIVE_VECTORS: &[(&str, IdExpectation)] = &[ + ( + "positive/identity-snapshot.json", + IdExpectation::Prefix("ids_"), + ), + ("positive/intent.json", IdExpectation::Prefix("int_")), + ("positive/surface-event.json", IdExpectation::Prefix("sev_")), + ("positive/graph.json", IdExpectation::Prefix("grf_")), + ("positive/graph-node.json", IdExpectation::Prefix("nod_")), + ("positive/delegation.json", IdExpectation::Prefix("dlg_")), + ("positive/budget.json", IdExpectation::Prefix("bud_")), + ("positive/approval.json", IdExpectation::Prefix("apr_")), + ( + "positive/execution-binding-revision-1.json", + IdExpectation::Prefix("att_"), + ), + ( + "positive/execution-binding-acknowledged.json", + IdExpectation::Prefix("att_"), + ), + ("positive/evidence.json", IdExpectation::Prefix("evd_")), + ("positive/verdict.json", IdExpectation::Prefix("vrd_")), + ("positive/recovery.json", IdExpectation::Prefix("rcv_")), + ("positive/addon.json", IdExpectation::Prefix("adn_")), + ( + "positive/surface-effect.json", + IdExpectation::Prefix("sfx_"), + ), + ( + "positive/delivery-ready.json", + IdExpectation::Prefix("del_"), + ), + ( + "positive/error-storage-unavailable.json", + IdExpectation::NotPersistable, + ), + ("positive/transition.json", IdExpectation::NotADocument), + ( + "positive/crash-restart/revision-1.json", + IdExpectation::Prefix("att_"), + ), + ( + "positive/crash-restart/revision-2.json", + IdExpectation::Prefix("att_"), + ), +]; + +/// (vector file, expected classification). Classifications follow +/// `ContractError` exactly as `RejectedDocument::from_decode_error` maps them +/// onto `RejectionReason`; `psyche.error.v1` decodes exhaustively, so an +/// unknown envelope field is an `InvalidShape`, not an unknown-enum one. +const NEGATIVE_VECTORS: &[(&str, RejectionClass)] = &[ + ( + "negative/denial-unknown-kind.json", + RejectionClass::UnknownSchema, + ), + ( + "negative/denial-unknown-enum.json", + RejectionClass::UnknownEnumValue, + ), + ( + "negative/denial-unknown-field.json", + RejectionClass::InvalidShape, + ), + ( + "negative/denial-unknown-code.json", + RejectionClass::UnknownEnumValue, + ), + ( + "negative/stale-correlation-ack-outside-window.json", + RejectionClass::InvalidShape, + ), + ( + "negative/stale-correlation-expired-request.json", + RejectionClass::InvalidShape, + ), + ( + "negative/stale-correlation-wrong-session.json", + RejectionClass::InvalidShape, + ), + ( + "negative/unknown-version-intent-v2.json", + RejectionClass::UnsupportedMajor, + ), + ( + "negative/downgrade-major-graph-v2.json", + RejectionClass::UnsupportedMajor, + ), +]; + +#[derive(Debug, PartialEq, Eq)] +enum RejectionClass { + UnknownSchema, + UnsupportedMajor, + UnknownEnumValue, + InvalidShape, +} + +fn vector_bytes(relative: &str) -> Vec { + std::fs::read(format!("{PROTOCOL_ROOT}/{relative}")) + .unwrap_or_else(|error| panic!("cannot read golden vector {relative}: {error}")) +} + +#[test] +fn every_published_positive_vector_decodes_and_recanonicalizes_to_the_published_bytes() { + for (file, expectation) in POSITIVE_VECTORS { + let bytes = vector_bytes(file); + // `psyche.transition` is store-owned and not a registry document: it + // has no schema_version of its own, so the registry decoder must deny + // it even though its canonical bytes are published. Everything else + // must decode through the real registry decoder. + let document = match expectation { + IdExpectation::NotADocument => { + assert!( + decode_document(&bytes).is_err(), + "{file}: store-owned records are not registry documents" + ); + None + } + _ => Some(decode_document(&bytes).unwrap_or_else(|error| { + panic!("{file} must decode as a positive vector: {error}") + })), + }; + let Some(document) = document.as_ref() else { + continue; + }; + if let IdExpectation::Prefix(expected_prefix) = expectation { + match document.persistable_record_id() { + Some(id) => assert!( + id.as_str().starts_with(expected_prefix), + "{file}: record id {} lacks the {expected_prefix} prefix", + id.as_str(), + ), + None => panic!("{file}: decoded without a durable record id"), + } + } else { + // IdExpectation::NotPersistable: psyche.error.v1 decodes + // exhaustively but never persists, so it carries no record id. + assert!( + document.persistable_record_id().is_none(), + "{file}: non-persistable document carries a record id" + ); + } + // Byte-exactness: the published file must be the canonical rendering + // of the decoded document, proving byte parity between the JavaScript + // canonicalizer that produced the artifact set and the Rust one. + let recanonical = canonical_bytes(document).unwrap_or_else(|error| { + panic!("{file}: decoded document failed canonicalization: {error}") + }); + assert_eq!( + recanonical, bytes, + "{file}: canonical bytes drifted from the published artifact" + ); + } +} + +#[test] +fn every_published_negative_vector_is_denied_with_the_promised_classification() { + for (file, expected) in NEGATIVE_VECTORS { + let bytes = vector_bytes(file); + let error = match decode_document(&bytes) { + Ok(_) => panic!("{file} is a negative vector and must be denied"), + Err(error) => error, + }; + let classified = match error { + ContractError::UnknownSchema => RejectionClass::UnknownSchema, + ContractError::UnsupportedMajor { .. } => RejectionClass::UnsupportedMajor, + ContractError::UnknownEnumValue { .. } => RejectionClass::UnknownEnumValue, + ContractError::InvalidShape { .. } + | ContractError::CancellationEvidenceMismatch + | ContractError::WrongRecordPrefix { .. } + | ContractError::MalformedIdentifier + | ContractError::InvalidUlid + | ContractError::UnsupportedDigestPrefix + | ContractError::MalformedDigest + | ContractError::CanonicalizationFailed + | ContractError::NonInteroperableNumber + | ContractError::SchemaMismatch { .. } + | ContractError::WrongRecordKind { .. } + | ContractError::DigestMismatch { .. } + | ContractError::DocumentTooLarge => RejectionClass::InvalidShape, + }; + assert_eq!(&classified, expected, "{file}: unexpected classification"); + } +} + +#[test] +fn the_crash_restart_chain_binds_revision_2_to_revision_1_canonical_bytes() { + let revision1 = vector_bytes("positive/crash-restart/revision-1.json"); + let revision2 = vector_bytes("positive/crash-restart/revision-2.json"); + let first = decode_document(&revision1).unwrap(); + let second = decode_document(&revision2).unwrap(); + + let claimed_previous = match &second { + CanonicalDocument::ExecutionBinding(binding) => binding + .previous_revision_digest + .as_ref() + .expect("revision 2 binds a previous digest"), + other => panic!("expected an execution binding, got {other:?}"), + }; + // The store binds previous_revision_digest to the previous revision's + // canonical digest (crates/psyche-store/src/execution_bindings.rs), which + // equals the digest over revision 1's published canonical bytes. + let expected_previous = psyche_core::digest::digest(&first) + .unwrap_or_else(|error| panic!("revision 1 digest failed: {error}")); + assert_eq!( + claimed_previous.as_str(), + expected_previous.as_str(), + "revision 2 must bind revision 1's canonical bytes" + ); + assert_eq!(canonical_bytes(&first).unwrap(), revision1); + assert_eq!(canonical_bytes(&second).unwrap(), revision2); +} 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/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 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"), + }, + }, +];