From 9820a7fc6284df438fbb7e9af3acd6f041973894 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:20:57 -0500 Subject: [PATCH 01/66] chore: add Psyche G2 foundation crates - Add workspace members: psyche-store, psyche-coven, psyche-surfaces, psyche-test-support with minimal src/lib.rs and inherited manifest fields. - psyche-test-support sets publish = false directly; the other three use publish.workspace = true. - Add workspace dependencies for new crates plus async-trait, rusqlite, serde_json_canonicalizer, sha2, time, ulid, proptest. - Add scripts/check-g2-workspace.sh to verify all four crates are present in cargo metadata and that psyche-test-support.publish == [] and no manifest mixes publish.workspace with a bare publish key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 16 ++++++++++++++ Cargo.toml | 15 +++++++++++++ crates/psyche-coven/Cargo.toml | 11 +++++++++ crates/psyche-coven/src/lib.rs | 1 + crates/psyche-store/Cargo.toml | 11 +++++++++ crates/psyche-store/src/lib.rs | 1 + crates/psyche-surfaces/Cargo.toml | 11 +++++++++ crates/psyche-surfaces/src/lib.rs | 1 + crates/psyche-test-support/Cargo.toml | 11 +++++++++ crates/psyche-test-support/src/lib.rs | 1 + scripts/check-g2-workspace.sh | 32 +++++++++++++++++++++++++++ 11 files changed, 111 insertions(+) create mode 100644 crates/psyche-coven/Cargo.toml create mode 100644 crates/psyche-coven/src/lib.rs create mode 100644 crates/psyche-store/Cargo.toml create mode 100644 crates/psyche-store/src/lib.rs create mode 100644 crates/psyche-surfaces/Cargo.toml create mode 100644 crates/psyche-surfaces/src/lib.rs create mode 100644 crates/psyche-test-support/Cargo.toml create mode 100644 crates/psyche-test-support/src/lib.rs create mode 100755 scripts/check-g2-workspace.sh diff --git a/Cargo.lock b/Cargo.lock index c2e8e66..0291764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,10 @@ dependencies = [ "thiserror", ] +[[package]] +name = "psyche-coven" +version = "0.0.0" + [[package]] name = "psyche-runtime" version = "0.0.0" @@ -410,6 +414,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "psyche-store" +version = "0.0.0" + +[[package]] +name = "psyche-surfaces" +version = "0.0.0" + +[[package]] +name = "psyche-test-support" +version = "0.0.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 0567ee9..331a9f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,10 @@ members = [ "crates/psyche-config", "crates/psyche-runtime", "crates/psyche-cli", + "crates/psyche-store", + "crates/psyche-coven", + "crates/psyche-surfaces", + "crates/psyche-test-support", ] [workspace.package] @@ -32,6 +36,17 @@ publish = false # distributed via npm, never crates.io psyche-core = { path = "crates/psyche-core" } psyche-config = { path = "crates/psyche-config" } psyche-runtime = { path = "crates/psyche-runtime" } +psyche-store = { path = "crates/psyche-store" } +psyche-coven = { path = "crates/psyche-coven" } +psyche-surfaces = { path = "crates/psyche-surfaces" } +psyche-test-support = { path = "crates/psyche-test-support" } +async-trait = "0.1" +rusqlite = { version = "0.32", features = ["bundled"] } +serde_json_canonicalizer = "0.3" +sha2 = "0.10" +time = { version = "0.3", features = ["formatting", "parsing", "serde"] } +ulid = { version = "1", features = ["serde"] } +proptest = "1" serde = { version = "1", features = ["derive"] } toml = "1" thiserror = "2" diff --git a/crates/psyche-coven/Cargo.toml b/crates/psyche-coven/Cargo.toml new file mode 100644 index 0000000..0ce63e0 --- /dev/null +++ b/crates/psyche-coven/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "psyche-coven" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[lints] +workspace = true diff --git a/crates/psyche-coven/src/lib.rs b/crates/psyche-coven/src/lib.rs new file mode 100644 index 0000000..2b0fbf1 --- /dev/null +++ b/crates/psyche-coven/src/lib.rs @@ -0,0 +1 @@ +//! Behavior-level Coven execution boundary. diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml new file mode 100644 index 0000000..d2d3966 --- /dev/null +++ b/crates/psyche-store/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "psyche-store" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[lints] +workspace = true diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs new file mode 100644 index 0000000..413dd83 --- /dev/null +++ b/crates/psyche-store/src/lib.rs @@ -0,0 +1 @@ +//! Durable SQLite substrate for Psyche contracts. diff --git a/crates/psyche-surfaces/Cargo.toml b/crates/psyche-surfaces/Cargo.toml new file mode 100644 index 0000000..89b727f --- /dev/null +++ b/crates/psyche-surfaces/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "psyche-surfaces" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[lints] +workspace = true diff --git a/crates/psyche-surfaces/src/lib.rs b/crates/psyche-surfaces/src/lib.rs new file mode 100644 index 0000000..f2c660d --- /dev/null +++ b/crates/psyche-surfaces/src/lib.rs @@ -0,0 +1 @@ +//! Behavior-level surface acceptance and delivery boundary. diff --git a/crates/psyche-test-support/Cargo.toml b/crates/psyche-test-support/Cargo.toml new file mode 100644 index 0000000..1701563 --- /dev/null +++ b/crates/psyche-test-support/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "psyche-test-support" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs new file mode 100644 index 0000000..def9f95 --- /dev/null +++ b/crates/psyche-test-support/src/lib.rs @@ -0,0 +1 @@ +//! Deterministic fakes and reusable Psyche conformance fixtures. diff --git a/scripts/check-g2-workspace.sh b/scripts/check-g2-workspace.sh new file mode 100755 index 0000000..6030d45 --- /dev/null +++ b/scripts/check-g2-workspace.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +metadata=$(cargo metadata --no-deps --format-version 1) + +for crate in psyche-store psyche-coven psyche-surfaces psyche-test-support; do + jq -e --arg crate "$crate" '.packages[] | select(.name == $crate)' \ + <<<"$metadata" >/dev/null +done + +# Assert psyche-test-support has publish == [] (i.e. not publishable). +jq -e ' + .packages[] + | select(.name == "psyche-test-support") + | .publish == [] +' <<<"$metadata" >/dev/null || { + echo "FAIL: psyche-test-support must have publish = false (publish == [] in metadata)" >&2 + exit 1 +} + +# Verify no manifest combines publish.workspace and a bare publish = in the +# same [package] table — that is a TOML conflict and signals a copy-paste error. +for crate in psyche-store psyche-coven psyche-surfaces psyche-test-support; do + manifest="crates/$crate/Cargo.toml" + if grep -qE '^\s*publish\.workspace' "$manifest" && \ + grep -qE '^\s*publish\s*=' "$manifest"; then + echo "FAIL: $manifest declares both publish.workspace and publish = in [package]" >&2 + exit 1 + fi +done + +echo "OK: all G2 workspace crates present and publication metadata is correct." From 3926cea720865d6156d022cee9a0d34be954adae Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:39:12 -0500 Subject: [PATCH 02/66] feat(core): add canonical contract primitives - Add contracts::RecordKind (15 kinds, stable 4-char prefixes: ids_, int_, grf_, nod_, att_, dlg_, bud_, apr_, evd_, vrd_, rcv_, adn_, sev_, sfx_, del_) and contracts::SchemaKind (16 kinds incl. Error), with SchemaKind::record_kind() as the sole SchemaKind -> RecordKind mapping. ExecutionBinding maps to Attempt; Error maps to None. No RecordKind variant exists for ExecutionBinding, keeping RecordKind::ALL at 15. - Add contracts::SchemaVersion { kind, major }, parsed from and displayed as psyche..v, serde try_from/into String. Accepts exactly the 16 canonical major-1 strings the registry defines; an unrecognised kind is ContractError::UnknownSchema, a recognised kind at any other (or malformed) major is ContractError::UnsupportedMajor. - Add id::RecordId: opaque newtype validated as <26 char canonical uppercase ULID>, with RecordId::parse (exact requested kind) and RecordId::parse_any/TryFrom (kind derived from the matched prefix) for serde. Rejects wrong-kind prefixes (e.g. dly_ where Delivery requires del_, or del_ where Delegation requires dlg_), lowercase or non-canonical ULID characters, and trailing data. - Add id::RequestId: separate req_-prefixed newtype, not a RecordKind, with the same canonical-ULID validation. - Add digest::canonical_bytes (delegates to serde_json_canonicalizer, so object key order does not affect the output) and digest::digest, returning a validated digest::Sha256Digest (sha256: + 64 lowercase hex chars, serde try_from/into String, rejects wrong prefix, wrong length, uppercase hex, and trailing content). - Add contracts::ContractError covering schema, record/request id, digest, and canonicalization validation failures. - Document schema.rs's existing CONFIG_SCHEMA_VERSION as a deliberately separate authority from contracts::SchemaVersion (config-file loading vs. the domain contract registry) rather than integrating it, since psyche.config.v1 is not one of the registry's 16 defined kinds. - Records, CanonicalDocument, decode_document, store validation, and QuarantineId are explicitly out of scope for this task. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 259 ++++++++++++- crates/psyche-core/Cargo.toml | 9 + crates/psyche-core/src/contracts/mod.rs | 473 ++++++++++++++++++++++++ crates/psyche-core/src/digest.rs | 178 +++++++++ crates/psyche-core/src/id.rs | 337 +++++++++++++++++ crates/psyche-core/src/lib.rs | 3 + crates/psyche-core/src/schema.rs | 17 + 7 files changed, 1274 insertions(+), 2 deletions(-) create mode 100644 crates/psyche-core/src/contracts/mod.rs create mode 100644 crates/psyche-core/src/digest.rs create mode 100644 crates/psyche-core/src/id.rs diff --git a/Cargo.lock b/Cargo.lock index 0291764..b5166ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,12 +82,36 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.13.0" @@ -151,12 +175,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -188,6 +241,34 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -196,7 +277,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -325,6 +406,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -364,6 +454,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "psyche-cli" version = "0.0.0" @@ -395,8 +504,11 @@ dependencies = [ name = "psyche-core" version = "0.0.0" dependencies = [ + "proptest", "serde", "serde_json", + "serde_json_canonicalizer", + "sha2", "thiserror", ] @@ -426,6 +538,12 @@ version = "0.0.0" name = "psyche-test-support" version = "0.0.0" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -435,12 +553,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "regex" version = "1.13.1" @@ -483,6 +645,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "serde" version = "1.0.229" @@ -526,6 +706,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -535,6 +726,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -595,7 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -774,6 +976,18 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -792,6 +1006,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -807,6 +1027,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -828,6 +1057,32 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/psyche-core/Cargo.toml b/crates/psyche-core/Cargo.toml index 257634e..7bad630 100644 --- a/crates/psyche-core/Cargo.toml +++ b/crates/psyche-core/Cargo.toml @@ -15,11 +15,20 @@ publish.workspace = true # `TryFrom` impl rather than straight into the newtype. serde = { workspace = true } thiserror = { workspace = true } +# `digest::canonical_bytes` delegates to this for RFC 8785 canonical JSON — +# the single authority for "ignores key order" rather than a hand-rolled +# re-serialization that could drift from the spec. +serde_json_canonicalizer = { workspace = true } +# `digest::digest` hashes the canonical bytes; this crate is the workspace's +# one SHA-256 implementation so every consumer hashes the same way. +sha2 = { workspace = true } [dev-dependencies] # Exercises the `#[serde(try_from = "String")]` path — the way a reference # actually enters the type in production — rather than only `TryFrom` directly. serde_json = { workspace = true } +# Property test backing "any value change changes the digest" in digest.rs. +proptest = { workspace = true } [lints] workspace = true diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs new file mode 100644 index 0000000..2a596b8 --- /dev/null +++ b/crates/psyche-core/src/contracts/mod.rs @@ -0,0 +1,473 @@ +//! Canonical contract primitives: record kinds and schema versions. +//! +//! This module is the sole authority mapping a [`SchemaKind`] to the +//! [`RecordKind`] it produces (if any), and the sole authority for which +//! `psyche..v` strings this build accepts as a [`SchemaVersion`]. +//! Task 2 stops at these primitives: no `records`, no `CanonicalDocument`, no +//! store validation, and no `QuarantineId` — those are store-owned and land +//! in later tasks (`QuarantineId` explicitly in Task 7). +use std::fmt; + +/// Reasons a contract primitive failed to validate. +/// +/// Every variant is deliberately payload-light: an invalid `RecordId`, +/// `RequestId`, `Sha256Digest`, or `SchemaVersion` is untrusted input, and the +/// error carries only what a caller needs to react to — not enough to +/// reconstruct the rejected value. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ContractError { + /// A `psyche..v` string named a kind this build does not define. + #[error("unknown schema kind in {found:?}")] + UnknownSchema { + /// The rejected string, verbatim. + found: String, + }, + /// The kind is known but this build does not accept the declared major. + #[error("unsupported schema major in {found:?}")] + UnsupportedMajor { + /// The rejected string, verbatim. + found: String, + }, + /// A record identifier did not carry the exact prefix its requested + /// `RecordKind` requires. The required prefix is [`RecordKind::prefix`], + /// not stored redundantly on this error. + #[error("record id does not have the {kind:?} prefix {:?}", kind.prefix())] + WrongRecordPrefix { + /// The kind whose prefix was required. + kind: RecordKind, + }, + /// An identifier was not shaped as `<26-character ULID>`, either + /// because of a wrong-length suffix or trailing data after it. + #[error("identifier is not shaped as followed by a 26-character ULID")] + MalformedIdentifier, + /// The ULID suffix was not a canonical uppercase Crockford Base32 ULID. + #[error("identifier suffix is not a canonical uppercase ULID")] + InvalidUlid, + /// A digest did not begin with the required `sha256:` prefix. + #[error("digest does not start with \"sha256:\"")] + UnsupportedDigestPrefix, + /// A digest was not exactly 64 lowercase hex characters after the prefix. + #[error("digest is not exactly 64 lowercase hex characters")] + MalformedDigest, + /// The value could not be serialized into canonical JSON — e.g. a + /// non-string map key, or a number requiring more than double precision. + /// The nested reason describes the *shape* problem `serde_json` found, + /// never the value's own field content. + #[error("value could not be canonicalized: {reason}")] + CanonicalizationFailed { + /// `serde_json`'s description of the shape problem. + reason: String, + }, +} + +/// The fifteen kinds of record this build persists, each identified by a +/// stable four-character prefix baked into every [`crate::id::RecordId`] of +/// that kind. +/// +/// `ExecutionBinding` is deliberately absent: [`SchemaKind::ExecutionBinding`] +/// maps onto `RecordKind::Attempt` (an execution binding *is* an attempt +/// record), so adding a separate variant here would give one record shape two +/// competing identities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RecordKind { + /// `ids_` — a snapshot of an identity at a point in time. + IdentitySnapshot, + /// `int_` — a user or system intent. + Intent, + /// `grf_` — a graph of nodes. + Graph, + /// `nod_` — a single node within a graph. + GraphNode, + /// `att_` — an execution attempt (including execution bindings). + Attempt, + /// `dlg_` — a delegation of authority. + Delegation, + /// `bud_` — a budget allocation. + Budget, + /// `apr_` — an approval decision. + Approval, + /// `evd_` — evidence gathered in support of a decision. + Evidence, + /// `vrd_` — a verdict reached from evidence. + Verdict, + /// `rcv_` — a recovery action. + Recovery, + /// `adn_` — an addon registration. + Addon, + /// `sev_` — a surface event. + SurfaceEvent, + /// `sfx_` — a surface effect. + SurfaceEffect, + /// `del_` — a delivery record. Not to be confused with `dly_`, which this + /// build never accepts. + Delivery, +} + +impl RecordKind { + /// Every [`RecordKind`] this build defines, in declaration order. + pub const ALL: [RecordKind; 15] = [ + RecordKind::IdentitySnapshot, + RecordKind::Intent, + RecordKind::Graph, + RecordKind::GraphNode, + RecordKind::Attempt, + RecordKind::Delegation, + RecordKind::Budget, + RecordKind::Approval, + RecordKind::Evidence, + RecordKind::Verdict, + RecordKind::Recovery, + RecordKind::Addon, + RecordKind::SurfaceEvent, + RecordKind::SurfaceEffect, + RecordKind::Delivery, + ]; + + /// The stable four-character prefix every `RecordId` of this kind begins + /// with. + pub const fn prefix(self) -> &'static str { + match self { + RecordKind::IdentitySnapshot => "ids_", + RecordKind::Intent => "int_", + RecordKind::Graph => "grf_", + RecordKind::GraphNode => "nod_", + RecordKind::Attempt => "att_", + RecordKind::Delegation => "dlg_", + RecordKind::Budget => "bud_", + RecordKind::Approval => "apr_", + RecordKind::Evidence => "evd_", + RecordKind::Verdict => "vrd_", + RecordKind::Recovery => "rcv_", + RecordKind::Addon => "adn_", + RecordKind::SurfaceEvent => "sev_", + RecordKind::SurfaceEffect => "sfx_", + RecordKind::Delivery => "del_", + } + } +} + +/// The sixteen record/document shapes this build's schema registry knows +/// about, one of which (`Error`) never round-trips through a `RecordKind`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SchemaKind { + /// `psyche.identity_snapshot.vN` + IdentitySnapshot, + /// `psyche.intent.vN` + Intent, + /// `psyche.surface_event.vN` + SurfaceEvent, + /// `psyche.graph.vN` + Graph, + /// `psyche.graph_node.vN` + GraphNode, + /// `psyche.delegation.vN` + Delegation, + /// `psyche.budget.vN` + Budget, + /// `psyche.approval.vN` + Approval, + /// `psyche.execution_binding.vN` — maps to [`RecordKind::Attempt`]. + ExecutionBinding, + /// `psyche.evidence.vN` + Evidence, + /// `psyche.verdict.vN` + Verdict, + /// `psyche.recovery.vN` + Recovery, + /// `psyche.addon.vN` + Addon, + /// `psyche.surface_effect.vN` + SurfaceEffect, + /// `psyche.delivery.vN` + Delivery, + /// `psyche.error.vN` — an error document. Never a stored record, so it + /// has no corresponding `RecordKind`. + Error, +} + +impl SchemaKind { + /// All sixteen kinds, in the order their canonical strings are listed in + /// the registry. + const ALL: [SchemaKind; 16] = [ + SchemaKind::IdentitySnapshot, + SchemaKind::Intent, + SchemaKind::SurfaceEvent, + SchemaKind::Graph, + SchemaKind::GraphNode, + SchemaKind::Delegation, + SchemaKind::Budget, + SchemaKind::Approval, + SchemaKind::ExecutionBinding, + SchemaKind::Evidence, + SchemaKind::Verdict, + SchemaKind::Recovery, + SchemaKind::Addon, + SchemaKind::SurfaceEffect, + SchemaKind::Delivery, + SchemaKind::Error, + ]; + + /// The `` segment of this kind's canonical `psyche..vN` + /// string. + const fn name(self) -> &'static str { + match self { + SchemaKind::IdentitySnapshot => "identity_snapshot", + SchemaKind::Intent => "intent", + SchemaKind::SurfaceEvent => "surface_event", + SchemaKind::Graph => "graph", + SchemaKind::GraphNode => "graph_node", + SchemaKind::Delegation => "delegation", + SchemaKind::Budget => "budget", + SchemaKind::Approval => "approval", + SchemaKind::ExecutionBinding => "execution_binding", + SchemaKind::Evidence => "evidence", + SchemaKind::Verdict => "verdict", + SchemaKind::Recovery => "recovery", + SchemaKind::Addon => "addon", + SchemaKind::SurfaceEffect => "surface_effect", + SchemaKind::Delivery => "delivery", + SchemaKind::Error => "error", + } + } + + /// The kind named by a `psyche..vN` string's `` segment, if + /// this build recognises it. + fn from_name(name: &str) -> Option { + SchemaKind::ALL.into_iter().find(|k| k.name() == name) + } + + /// The [`RecordKind`] this schema kind is stored as, or `None` for + /// [`SchemaKind::Error`], which is never a stored record. + /// + /// This is the *sole* mapping from schema kind to record kind: nothing + /// else in this crate re-derives it, so there is exactly one place that + /// can disagree with itself. + pub const fn record_kind(self) -> Option { + match self { + SchemaKind::IdentitySnapshot => Some(RecordKind::IdentitySnapshot), + SchemaKind::Intent => Some(RecordKind::Intent), + SchemaKind::SurfaceEvent => Some(RecordKind::SurfaceEvent), + SchemaKind::Graph => Some(RecordKind::Graph), + SchemaKind::GraphNode => Some(RecordKind::GraphNode), + SchemaKind::Delegation => Some(RecordKind::Delegation), + SchemaKind::Budget => Some(RecordKind::Budget), + SchemaKind::Approval => Some(RecordKind::Approval), + SchemaKind::ExecutionBinding => Some(RecordKind::Attempt), + SchemaKind::Evidence => Some(RecordKind::Evidence), + SchemaKind::Verdict => Some(RecordKind::Verdict), + SchemaKind::Recovery => Some(RecordKind::Recovery), + SchemaKind::Addon => Some(RecordKind::Addon), + SchemaKind::SurfaceEffect => Some(RecordKind::SurfaceEffect), + SchemaKind::Delivery => Some(RecordKind::Delivery), + SchemaKind::Error => None, + } + } +} + +/// The major version this build accepts for every [`SchemaKind`] today. +/// +/// A single constant, not a per-kind table: every kind in the registry is +/// presently at major 1, and a kind reaching major 2 is a deliberate, +/// reviewed change to this file rather than a silent range extension. +const SUPPORTED_MAJOR: u16 = 1; + +/// A validated `psyche..v` contract schema version. +/// +/// Fields are public and directly constructible: unlike [`crate::id::RecordId`] +/// there is no encoded invariant beyond "this kind and this major are both +/// individually meaningful values", so a caller building a schema version to +/// serialize (e.g. `SchemaVersion { kind: SchemaKind::Graph, major: 1 }`) does +/// not need to round-trip through string parsing to do it. [`SchemaVersion::parse`] +/// and the `serde` implementation are what enforce that the *string form* is +/// one this build's registry actually accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SchemaVersion { + /// Which of the sixteen registry kinds this version names. + pub kind: SchemaKind, + /// The major version declared. This build only accepts `1`. + pub major: u16, +} + +impl SchemaVersion { + /// Parses a `psyche..v` string against the registry. + /// + /// An unrecognised `` segment is [`ContractError::UnknownSchema`]. + /// A recognised kind whose major is not the one this build supports — + /// including a malformed major segment, such as a leading zero or + /// non-digit content — is [`ContractError::UnsupportedMajor`]: the + /// registry has exactly two failure modes, and a garbled major on an + /// otherwise-known kind is a version problem, not an unknown-kind one. + pub fn parse(value: &str) -> Result { + let unknown = || ContractError::UnknownSchema { + found: value.to_string(), + }; + let segments: Vec<&str> = value.split('.').collect(); + let [namespace, kind_segment, major_segment] = segments.as_slice() else { + return Err(unknown()); + }; + if *namespace != "psyche" { + return Err(unknown()); + } + let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; + + let unsupported_major = || ContractError::UnsupportedMajor { + found: value.to_string(), + }; + let digits = major_segment + .strip_prefix('v') + .ok_or_else(unsupported_major)?; + // Reject a leading zero on a multi-digit major ("v01"): it parses to + // the same integer as "v1" but is not the canonical string, and the + // registry only accepts the canonical form. + if digits.is_empty() + || (digits.len() > 1 && digits.starts_with('0')) + || !digits.bytes().all(|b| b.is_ascii_digit()) + { + return Err(unsupported_major()); + } + let major: u16 = digits.parse().map_err(|_| unsupported_major())?; + if major != SUPPORTED_MAJOR { + return Err(unsupported_major()); + } + Ok(SchemaVersion { kind, major }) + } +} + +impl fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "psyche.{}.v{}", self.kind.name(), self.major) + } +} + +impl TryFrom for SchemaVersion { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: SchemaVersion) -> Self { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_kind_all_has_exactly_fifteen_entries() { + assert_eq!(RecordKind::ALL.len(), 15); + } + + #[test] + fn execution_binding_maps_to_attempt_only() { + assert_eq!( + SchemaKind::ExecutionBinding.record_kind(), + Some(RecordKind::Attempt) + ); + assert_eq!(RecordKind::Attempt.prefix(), "att_"); + } + + #[test] + fn schema_kind_error_has_no_record_kind() { + assert_eq!(SchemaKind::Error.record_kind(), None); + } + + #[test] + fn schema_version_accepts_exactly_the_sixteen_known_strings() { + for known in [ + "psyche.identity_snapshot.v1", + "psyche.intent.v1", + "psyche.surface_event.v1", + "psyche.graph.v1", + "psyche.graph_node.v1", + "psyche.delegation.v1", + "psyche.budget.v1", + "psyche.approval.v1", + "psyche.execution_binding.v1", + "psyche.evidence.v1", + "psyche.verdict.v1", + "psyche.recovery.v1", + "psyche.addon.v1", + "psyche.surface_effect.v1", + "psyche.delivery.v1", + "psyche.error.v1", + ] { + let parsed = SchemaVersion::parse(known).unwrap_or_else(|err| { + panic!("expected {known:?} to parse, got {err:?}"); + }); + assert_eq!(parsed.to_string(), known); + } + } + + #[test] + fn schema_version_rejects_an_unknown_kind() { + let err = SchemaVersion::parse("psyche.unknown_kind.v1").unwrap_err(); + assert!(matches!(err, ContractError::UnknownSchema { .. })); + } + + #[test] + fn schema_version_rejects_a_known_kind_with_the_wrong_major() { + let err = SchemaVersion::parse("psyche.intent.v2").unwrap_err(); + assert!(matches!(err, ContractError::UnsupportedMajor { .. })); + } + + #[test] + fn schema_version_try_from_string_validates() { + let ok = SchemaVersion::try_from("psyche.intent.v1".to_string()).unwrap(); + assert_eq!(ok.kind, SchemaKind::Intent); + assert_eq!(ok.major, 1); + assert!(SchemaVersion::try_from("psyche.intent.v9".to_string()).is_err()); + } + + #[test] + fn schema_version_serde_round_trips_and_rejects() { + let json = + serde_json::to_string(&SchemaVersion::parse("psyche.graph.v1").unwrap()).unwrap(); + assert_eq!(json, "\"psyche.graph.v1\""); + let back: SchemaVersion = serde_json::from_str(&json).unwrap(); + assert_eq!(back.to_string(), "psyche.graph.v1"); + + assert!(serde_json::from_str::("\"psyche.graph.v2\"").is_err()); + assert!(serde_json::from_str::("\"psyche.nope.v1\"").is_err()); + } + + // Mirrors schema.rs's denies_near_misses and secret.rs's rejects_near_misses: + // without these, someone "helpfully" adding .trim(), case-insensitive + // matching, or leading-zero tolerance would break registry strictness + // silently. + #[test] + fn schema_version_denies_near_misses() { + for (near, expect_unknown) in [ + ("", true), + ("psyche.intent", true), // missing major segment + ("psyche.intent.v1.v2", true), // extra segment + ("Psyche.intent.v1", true), // wrong namespace case + ("psyche.Intent.v1", true), // wrong kind case + ("psyche.intent.V1", false), // wrong major case: known kind, bad major + (" psyche.intent.v1", true), // leading whitespace on namespace + ("psyche.intent.v1 ", false), // trailing whitespace: known kind, bad major + ("psyche.intent.v01", false), // leading zero: known kind, bad major + ("psyche.intent.v", false), // empty digits: known kind, bad major + ("psyche.intent.v1x", false), // trailing junk: known kind, bad major + ("psyche.intent.v-1", false), // negative: known kind, bad major + ] { + let err = SchemaVersion::parse(near).unwrap_err(); + if expect_unknown { + assert!( + matches!(err, ContractError::UnknownSchema { .. }), + "expected UnknownSchema for {near:?}, got {err:?}" + ); + } else { + assert!( + matches!(err, ContractError::UnsupportedMajor { .. }), + "expected UnsupportedMajor for {near:?}, got {err:?}" + ); + } + } + } +} diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs new file mode 100644 index 0000000..05a9074 --- /dev/null +++ b/crates/psyche-core/src/digest.rs @@ -0,0 +1,178 @@ +//! Canonical JSON bytes and the SHA-256 digest computed over them. +//! +//! Canonicalisation follows RFC 8785 (JSON Canonicalization Scheme), whose +//! defining property is exercised directly in this module's tests: two JSON +//! values that differ only in object key order canonicalize to identical +//! bytes, and so hash identically. +use std::fmt; +use std::fmt::Write as _; + +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use crate::contracts::ContractError; + +/// Length of the hex-encoded digest after the `sha256:` prefix. +const HEX_DIGEST_LEN: usize = 64; + +/// The RFC 8785 canonical JSON bytes for `value`. +/// +/// Two values that serialize to the same JSON data but differ in object key +/// order produce identical bytes — canonicalisation, not merely +/// serialization, is the point of this function. +pub fn canonical_bytes(value: &T) -> Result, ContractError> { + serde_json_canonicalizer::to_vec(value).map_err(|err| ContractError::CanonicalizationFailed { + reason: err.to_string(), + }) +} + +/// The [`Sha256Digest`] of `value`'s canonical JSON bytes. +pub fn digest(value: &T) -> Result { + let bytes = canonical_bytes(value)?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Ok(Sha256Digest(format!( + "{}{}", + Sha256Digest::PREFIX, + to_lower_hex(hasher.finalize().as_slice()) + ))) +} + +/// Encodes `bytes` as lowercase hex, two characters per byte. +fn to_lower_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + // `write!` to a `String` cannot fail; the result is discarded rather + // than unwrapped, since this crate denies `unwrap`/`expect`. + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// A validated `sha256:` digest: the fixed prefix followed by exactly 64 +/// lowercase hex characters. +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Sha256Digest(String); + +impl Sha256Digest { + /// The fixed prefix every digest begins with. + pub const PREFIX: &'static str = "sha256:"; + + /// Validates `value` as `sha256:` followed by exactly 64 lowercase hex + /// characters, with no trailing data and no uppercase hex digits. + pub fn parse(value: &str) -> Result { + let Some(hex) = value.strip_prefix(Self::PREFIX) else { + return Err(ContractError::UnsupportedDigestPrefix); + }; + if hex.len() != HEX_DIGEST_LEN + || !hex + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(ContractError::MalformedDigest); + } + Ok(Sha256Digest(value.to_string())) + } + + /// The full digest string, e.g. `"sha256:<64 lowercase hex chars>"`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Sha256Digest").field(&self.0).finish() + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for Sha256Digest { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: Sha256Digest) -> Self { + value.0 + } +} + +#[cfg(test)] +mod tests { + use crate::digest::{Sha256Digest, canonical_bytes, digest}; + use proptest::prelude::*; + use serde::Serialize; + use serde_json::json; + + #[test] + fn canonical_digest_ignores_key_order() { + let a = json!({"b": 1, "a": 2}); + let b = json!({"a": 2, "b": 1}); + assert_eq!(digest(&a).unwrap(), digest(&b).unwrap()); + assert_eq!(canonical_bytes(&a).unwrap(), canonical_bytes(&b).unwrap()); + } + + #[test] + fn digest_has_the_sha256_prefix_and_64_lowercase_hex_chars() { + let d = digest(&json!({"x": 1})).unwrap(); + let s = d.as_str(); + assert!(s.starts_with("sha256:")); + let hex = &s["sha256:".len()..]; + assert_eq!(hex.len(), 64); + assert!( + hex.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + } + + #[test] + fn sha256_digest_round_trips_through_serde() { + let d = digest(&json!({"a": 1})).unwrap(); + let json_str = serde_json::to_string(&d).unwrap(); + let back: Sha256Digest = serde_json::from_str(&json_str).unwrap(); + assert_eq!(back, d); + } + + #[test] + fn sha256_digest_rejects_malformed_values() { + let good = digest(&json!({"a": 1})).unwrap().as_str().to_string(); + for bad in [ + good.replacen("sha256:", "sha255:", 1), + good[..good.len() - 1].to_string(), // too short + format!("{good}0"), // too long / trailing + good.to_uppercase(), // uppercase hex + good.replace('a', "g"), // non-hex char (if any 'a' present) + ] { + assert!( + Sha256Digest::parse(&bad).is_err(), + "expected rejection for {bad:?}" + ); + } + } + + #[derive(Serialize)] + struct Wrapper { + value: i64, + label: String, + } + + proptest! { + #[test] + fn any_value_change_changes_the_digest(a in -1000i64..1000, b in -1000i64..1000, label in "[a-z]{1,8}") { + prop_assume!(a != b); + let d1 = digest(&Wrapper { value: a, label: label.clone() }).unwrap(); + let d2 = digest(&Wrapper { value: b, label }).unwrap(); + prop_assert_ne!(d1, d2); + } + } +} diff --git a/crates/psyche-core/src/id.rs b/crates/psyche-core/src/id.rs new file mode 100644 index 0000000..4bc85e2 --- /dev/null +++ b/crates/psyche-core/src/id.rs @@ -0,0 +1,337 @@ +//! Validated record and request identifiers. +//! +//! [`RecordId`] and [`RequestId`] are opaque newtypes: there is no public way +//! to build one except through validation, so once a caller holds a value of +//! either type it is already known-good. Neither type exposes its raw string +//! via `TryFrom`/constructor bypass — only through [`RecordId::as_str`] / +//! [`RequestId::as_str`] and `Display`, which read rather than construct. +use std::fmt; + +use crate::contracts::{ContractError, RecordKind}; + +/// Length of a canonical ULID string: 26 Crockford Base32 characters. +const ULID_LEN: usize = 26; + +/// Crockford's Base32 alphabet: digits plus uppercase letters, excluding +/// `I`, `L`, `O`, and `U` to avoid visual confusion with `1`, `1`, `0`, and +/// `V`. Lowercase letters are absent on purpose — a canonical ULID suffix is +/// uppercase only, so a lowercase character fails this membership check +/// without any separate case check. +const CROCKFORD_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// True if `suffix` is a canonical, uppercase, 26-character ULID. +/// +/// A ULID's 128 bits encode into 26 Base32 characters (130 bits of capacity), +/// leaving 2 spare bits at the top: the first character's value is +/// consequently restricted to `0..=7` rather than the full alphabet, or a +/// "valid-looking" string could denote a value the 128-bit ULID space cannot +/// hold. +fn is_canonical_ulid(suffix: &str) -> bool { + let bytes = suffix.as_bytes(); + bytes.len() == ULID_LEN + && bytes.iter().all(|b| CROCKFORD_ALPHABET.contains(b)) + && matches!(bytes[0], b'0'..=b'7') +} + +/// A validated identifier for one of the fifteen [`RecordKind`]s. +/// +/// The stored string always has the shape `<26-char canonical +/// ULID>`, where `` is exactly the four characters +/// [`RecordKind::prefix`] returns for this id's kind — there is no path that +/// produces a `RecordId` holding a mismatched, malformed, or lowercase value. +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct RecordId(String); + +impl RecordId { + /// Validates `value` as a `RecordId` of exactly `kind`. + /// + /// Rejects a prefix that names a different kind (including a + /// plausible-looking but wrong one, e.g. `dly_` where [`RecordKind::Delivery`] + /// requires `del_`), a suffix that is not exactly 26 characters (so + /// trailing data after a valid ULID is rejected, not silently dropped), + /// and a suffix that is not a canonical uppercase ULID. + pub fn parse(kind: RecordKind, value: &str) -> Result { + let prefix = kind.prefix(); + let Some(suffix) = value.strip_prefix(prefix) else { + return Err(ContractError::WrongRecordPrefix { kind }); + }; + if suffix.len() != ULID_LEN { + return Err(ContractError::MalformedIdentifier); + } + if !is_canonical_ulid(suffix) { + return Err(ContractError::InvalidUlid); + } + Ok(RecordId(value.to_string())) + } + + /// Validates `value` as a `RecordId`, determining its kind from whichever + /// of the fifteen fixed prefixes it begins with. + /// + /// This is what `TryFrom` (and so `serde` deserialization) uses: + /// a deserializer has no way to supply an expected kind, so it must + /// accept any recognised kind rather than one caller-chosen kind. Use + /// [`RecordId::parse`] instead when a specific kind is expected. + pub fn parse_any(value: &str) -> Result { + for kind in RecordKind::ALL { + if value.starts_with(kind.prefix()) { + return Self::parse(kind, value); + } + } + Err(ContractError::MalformedIdentifier) + } + + /// The [`RecordKind`] this id was validated against. + pub fn kind(&self) -> RecordKind { + for kind in RecordKind::ALL { + if self.0.starts_with(kind.prefix()) { + return kind; + } + } + // Invariant: every `RecordId` is constructed through `parse` or + // `parse_any`, both of which require one of `RecordKind::ALL`'s + // prefixes before returning `Ok`. No other constructor exists. + unreachable!("RecordId held a value without a recognised RecordKind prefix") + } + + /// The full identifier string, e.g. `"att_01ARZ3NDEKTSV4RRFFQ69G5FAV"`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for RecordId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("RecordId").field(&self.0).finish() + } +} + +impl fmt::Display for RecordId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for RecordId { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::parse_any(&value) + } +} + +impl From for String { + fn from(value: RecordId) -> Self { + value.0 + } +} + +/// A validated identifier for an ephemeral request, distinct from every +/// [`RecordKind`] — a `RequestId` is never a stored record, so it is not one +/// of the fifteen kinds and carries its own fixed `req_` prefix instead of +/// [`RecordKind::prefix`]. +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct RequestId(String); + +impl RequestId { + /// The fixed prefix every `RequestId` begins with. + pub const PREFIX: &'static str = "req_"; + + /// Validates `value` as `req_` followed by a canonical uppercase + /// 26-character ULID, with no trailing data. + pub fn parse(value: &str) -> Result { + let Some(suffix) = value.strip_prefix(Self::PREFIX) else { + return Err(ContractError::MalformedIdentifier); + }; + if suffix.len() != ULID_LEN { + return Err(ContractError::MalformedIdentifier); + } + if !is_canonical_ulid(suffix) { + return Err(ContractError::InvalidUlid); + } + Ok(RequestId(value.to_string())) + } + + /// The full identifier string, e.g. `"req_01ARZ3NDEKTSV4RRFFQ69G5FAV"`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("RequestId").field(&self.0).finish() + } +} + +impl fmt::Display for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for RequestId { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: RequestId) -> Self { + value.0 + } +} + +#[cfg(test)] +mod tests { + use crate::contracts::{ContractError, RecordKind}; + use crate::id::{RecordId, RequestId}; + + const A_ULID: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + const ANOTHER_ULID: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; + + #[test] + fn rejects_a_record_id_with_the_wrong_kind_prefix() { + let value = format!("int_{A_ULID}"); + let err = RecordId::parse(RecordKind::Attempt, &value).unwrap_err(); + assert!(matches!( + err, + ContractError::WrongRecordPrefix { + kind: RecordKind::Attempt + } + )); + } + + #[test] + fn delivery_is_authoritatively_del_and_rejects_dly() { + let del = format!("del_{A_ULID}"); + assert!(RecordId::parse(RecordKind::Delivery, &del).is_ok()); + + let dly = format!("dly_{A_ULID}"); + assert!(RecordId::parse(RecordKind::Delivery, &dly).is_err()); + assert!(RecordId::try_from(dly).is_err()); + } + + #[test] + fn delegation_is_distinctly_dlg_and_rejects_del() { + let dlg = format!("dlg_{A_ULID}"); + assert!(RecordId::parse(RecordKind::Delegation, &dlg).is_ok()); + + let del = format!("del_{A_ULID}"); + assert!(RecordId::parse(RecordKind::Delegation, &del).is_err()); + } + + #[test] + fn attempt_accepts_only_att_prefix() { + let att = format!("att_{ANOTHER_ULID}"); + let id = RecordId::parse(RecordKind::Attempt, &att).unwrap(); + assert_eq!(id.kind(), RecordKind::Attempt); + assert_eq!(id.as_str(), att); + } + + #[test] + fn rejects_lowercase_ulid_suffix() { + let lower = format!("att_{}", A_ULID.to_lowercase()); + assert!(RecordId::parse(RecordKind::Attempt, &lower).is_err()); + } + + #[test] + fn rejects_non_canonical_ulid_characters() { + // 'U' is excluded from Crockford's Base32 alphabet. + let bad = format!("att_{}", "0".repeat(25) + "U"); + assert!(RecordId::parse(RecordKind::Attempt, &bad).is_err()); + } + + #[test] + fn rejects_trailing_data_after_the_ulid() { + let trailing = format!("att_{A_ULID}X"); + assert!(RecordId::parse(RecordKind::Attempt, &trailing).is_err()); + } + + #[test] + fn record_id_serde_round_trips_and_rejects() { + let id = RecordId::parse(RecordKind::Graph, &format!("grf_{A_ULID}")).unwrap(); + let json = serde_json::to_string(&id).unwrap(); + let back: RecordId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + + assert!(serde_json::from_str::("\"grf_not-a-ulid\"").is_err()); + } + + #[test] + fn request_id_is_not_a_record_kind_and_validates_its_own_prefix() { + let req = format!("req_{A_ULID}"); + let id = RequestId::parse(&req).unwrap(); + assert_eq!(id.as_str(), req); + + assert!(RequestId::parse(&format!("rqx_{A_ULID}")).is_err()); + assert!(RequestId::parse(&format!("req_{}", A_ULID.to_lowercase())).is_err()); + } + + #[test] + fn request_id_serde_round_trips_and_rejects() { + let id = RequestId::parse(&format!("req_{A_ULID}")).unwrap(); + let json = serde_json::to_string(&id).unwrap(); + let back: RequestId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + + assert!(serde_json::from_str::("\"req_short\"").is_err()); + } + + #[test] + fn record_id_all_fifteen_prefixes_round_trip_through_parse_any() { + // Every declared kind's canonical prefix + a valid ULID must both + // parse under its own kind and be recovered by `kind()` — pins that + // `RecordKind::ALL`, `RecordKind::prefix`, and `parse_any`'s lookup + // never drift out of sync with each other. + for kind in RecordKind::ALL { + let value = format!("{}{A_ULID}", kind.prefix()); + let via_kind = RecordId::parse(kind, &value).unwrap(); + let via_any = RecordId::try_from(value.clone()).unwrap(); + assert_eq!(via_kind, via_any); + assert_eq!(via_any.kind(), kind); + assert_eq!(via_any.as_str(), value); + } + } + + #[test] + fn record_id_rejects_near_misses() { + for near in [ + "", + "att_", + A_ULID, // missing prefix entirely + &format!("att{A_ULID}"), // missing underscore + &format!("att_{}", &A_ULID[..25]), // one char short + &format!("att_{A_ULID}Z"), // one char over (trailing) + &format!("att_{}", A_ULID.to_lowercase()), // lowercase suffix + &format!("ATT_{A_ULID}"), // uppercase prefix + ] { + assert!( + RecordId::try_from(near.to_string()).is_err(), + "expected rejection for {near:?}" + ); + } + } + + #[test] + fn request_id_rejects_near_misses() { + for near in [ + "", + "req_", + A_ULID, // missing prefix + &format!("req{A_ULID}"), // missing underscore + &format!("req_{}", &A_ULID[..25]), // one char short + &format!("req_{A_ULID}Z"), // one char over + &format!("REQ_{A_ULID}"), // uppercase prefix + &format!("del_{A_ULID}"), // a RecordKind prefix, not req_ + ] { + assert!( + RequestId::parse(near).is_err(), + "expected rejection for {near:?}" + ); + } + } +} diff --git a/crates/psyche-core/src/lib.rs b/crates/psyche-core/src/lib.rs index 20672c9..b9be4d8 100644 --- a/crates/psyche-core/src/lib.rs +++ b/crates/psyche-core/src/lib.rs @@ -4,5 +4,8 @@ // every type two spellings for downstream crates to drift between, and a glob // re-export would silently promote anything later added to `secret.rs` into the // public API. Callers write `psyche_core::schema::ensure_schema_version`. +pub mod contracts; +pub mod digest; +pub mod id; pub mod schema; pub mod secret; diff --git a/crates/psyche-core/src/schema.rs b/crates/psyche-core/src/schema.rs index 464e80d..c77faf2 100644 --- a/crates/psyche-core/src/schema.rs +++ b/crates/psyche-core/src/schema.rs @@ -1,4 +1,21 @@ //! Versioned schema identifiers. Unknown versions are denied, never coerced. +//! +//! This module gates exactly one thing: the on-disk `psyched` configuration +//! file's own `schema_version` field. It is a separate authority from +//! [`crate::contracts::SchemaVersion`] on purpose, not a duplicate one: +//! `crate::contracts` is the registry for the sixteen *domain contract* +//! kinds (`psyche.intent.v1`, `psyche.graph.v1`, and so on) that Psyche's +//! records and documents declare, and `"psyche.config.v1"` deliberately does +//! not appear in that registry's [`crate::contracts::SchemaKind`] — config +//! loading is not a contract record. Both authorities share the same +//! `psyche..v` spelling convention and the same "deny, never +//! coerce" policy, but they gate different files for different consumers +//! (`psyche-config` here; the store and runtime for the contracts registry), +//! so unifying them into one enum would either grow the contract registry +//! with a kind that is never stored, or make the config loader depend on +//! every future contract kind. If a config-schema-shaped kind is ever added +//! to the contracts registry, that is a deliberate, reviewed decision to +//! make here — not an accidental side effect of adding a contract kind. /// The only configuration schema this build accepts. pub const CONFIG_SCHEMA_VERSION: &str = "psyche.config.v1"; From 0b6de092c0009d84b492827a7e3cd6dc9891a507 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:53:48 -0500 Subject: [PATCH 03/66] feat(core): define minimum Psyche v1 records Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 54 ++ crates/psyche-core/Cargo.toml | 3 +- crates/psyche-core/src/contracts/error.rs | 215 ++++++++ crates/psyche-core/src/contracts/execution.rs | 490 ++++++++++++++++++ .../psyche-core/src/contracts/foundation.rs | 248 +++++++++ crates/psyche-core/src/contracts/graph.rs | 145 ++++++ crates/psyche-core/src/contracts/identity.rs | 77 +++ crates/psyche-core/src/contracts/intent.rs | 68 +++ crates/psyche-core/src/contracts/mod.rs | 369 ++++++++++++- crates/psyche-core/src/contracts/surface.rs | 265 ++++++++++ crates/psyche-core/tests/contracts.rs | 461 ++++++++++++++++ .../tests/fixtures/delivery-ready.json | 30 ++ .../tests/fixtures/error-codes-v1.json | 434 ++++++++++++++++ .../tests/fixtures/intent-local.json | 13 + .../psyche-core/tests/fixtures/node-root.json | 12 + .../tests/fixtures/surface-effect.json | 16 + .../tests/fixtures/surface-event.json | 11 + 17 files changed, 2909 insertions(+), 2 deletions(-) create mode 100644 crates/psyche-core/src/contracts/error.rs create mode 100644 crates/psyche-core/src/contracts/execution.rs create mode 100644 crates/psyche-core/src/contracts/foundation.rs create mode 100644 crates/psyche-core/src/contracts/graph.rs create mode 100644 crates/psyche-core/src/contracts/identity.rs create mode 100644 crates/psyche-core/src/contracts/intent.rs create mode 100644 crates/psyche-core/src/contracts/surface.rs create mode 100644 crates/psyche-core/tests/contracts.rs create mode 100644 crates/psyche-core/tests/fixtures/delivery-ready.json create mode 100644 crates/psyche-core/tests/fixtures/error-codes-v1.json create mode 100644 crates/psyche-core/tests/fixtures/intent-local.json create mode 100644 crates/psyche-core/tests/fixtures/node-root.json create mode 100644 crates/psyche-core/tests/fixtures/surface-effect.json create mode 100644 crates/psyche-core/tests/fixtures/surface-event.json diff --git a/Cargo.lock b/Cargo.lock index b5166ff..58ee57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "difflib" version = "0.4.0" @@ -379,6 +389,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -406,6 +422,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -510,6 +532,7 @@ dependencies = [ "serde_json_canonicalizer", "sha2", "thiserror", + "time", ] [[package]] @@ -838,6 +861,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/crates/psyche-core/Cargo.toml b/crates/psyche-core/Cargo.toml index 7bad630..833a810 100644 --- a/crates/psyche-core/Cargo.toml +++ b/crates/psyche-core/Cargo.toml @@ -14,7 +14,9 @@ publish.workspace = true # is what forces every deserialised reference through the validating # `TryFrom` impl rather than straight into the newtype. serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } # `digest::canonical_bytes` delegates to this for RFC 8785 canonical JSON — # the single authority for "ignores key order" rather than a hand-rolled # re-serialization that could drift from the spec. @@ -26,7 +28,6 @@ sha2 = { workspace = true } [dev-dependencies] # Exercises the `#[serde(try_from = "String")]` path — the way a reference # actually enters the type in production — rather than only `TryFrom` directly. -serde_json = { workspace = true } # Property test backing "any value change changes the digest" in digest.rs. proptest = { workspace = true } diff --git a/crates/psyche-core/src/contracts/error.rs b/crates/psyche-core/src/contracts/error.rs new file mode 100644 index 0000000..8b653ff --- /dev/null +++ b/crates/psyche-core/src/contracts/error.rs @@ -0,0 +1,215 @@ +//! Typed public error envelope. +#![allow(missing_docs)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::Value; + +use crate::contracts::{ + ContractError, SchemaKind, SchemaVersion, bounded, invalid, require_schema, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorCode { + ConfigInvalid, + SecretUnavailable, + TelegramUnauthorized, + TelegramBotIdentityMismatch, + TelegramConflict, + TelegramRateLimited, + TelegramUnavailable, + WebhookAuthFailed, + StorageUnavailable, + EventSchemaUnsupported, + PrincipalMappingInvalid, + GraphInvalid, + DelegationWidened, + BudgetUnenforceable, + EvidenceIncomplete, + VerdictInvalid, + RouteNotFound, + RouteAmbiguous, + SenderUnauthorized, + IdentityInvalid, + IdentityChanged, + CovenUnavailable, + CovenVersionUnsupported, + CovenCapabilityMissing, + CovenPolicyDenied, + CovenExecutionBindingInvalid, + CovenBindingMismatch, + CovenArtifactRejected, + CovenIntentConflict, + CovenAdoptionUnknown, + CovenCancellationUnknown, + CovenSessionFailed, + DeliveryUnknown, + PreviewFinalizeBlocked, + MediaRejected, + CallbackInvalid, +} + +impl ErrorCode { + pub const ALL: [Self; 36] = [ + Self::ConfigInvalid, + Self::SecretUnavailable, + Self::TelegramUnauthorized, + Self::TelegramBotIdentityMismatch, + Self::TelegramConflict, + Self::TelegramRateLimited, + Self::TelegramUnavailable, + Self::WebhookAuthFailed, + Self::StorageUnavailable, + Self::EventSchemaUnsupported, + Self::PrincipalMappingInvalid, + Self::GraphInvalid, + Self::DelegationWidened, + Self::BudgetUnenforceable, + Self::EvidenceIncomplete, + Self::VerdictInvalid, + Self::RouteNotFound, + Self::RouteAmbiguous, + Self::SenderUnauthorized, + Self::IdentityInvalid, + Self::IdentityChanged, + Self::CovenUnavailable, + Self::CovenVersionUnsupported, + Self::CovenCapabilityMissing, + Self::CovenPolicyDenied, + Self::CovenExecutionBindingInvalid, + Self::CovenBindingMismatch, + Self::CovenArtifactRejected, + Self::CovenIntentConflict, + Self::CovenAdoptionUnknown, + Self::CovenCancellationUnknown, + Self::CovenSessionFailed, + Self::DeliveryUnknown, + Self::PreviewFinalizeBlocked, + Self::MediaRejected, + Self::CallbackInvalid, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::ConfigInvalid => "config_invalid", + Self::SecretUnavailable => "secret_unavailable", + Self::TelegramUnauthorized => "telegram_unauthorized", + Self::TelegramBotIdentityMismatch => "telegram_bot_identity_mismatch", + Self::TelegramConflict => "telegram_conflict", + Self::TelegramRateLimited => "telegram_rate_limited", + Self::TelegramUnavailable => "telegram_unavailable", + Self::WebhookAuthFailed => "webhook_auth_failed", + Self::StorageUnavailable => "storage_unavailable", + Self::EventSchemaUnsupported => "event_schema_unsupported", + Self::PrincipalMappingInvalid => "principal_mapping_invalid", + Self::GraphInvalid => "graph_invalid", + Self::DelegationWidened => "delegation_widened", + Self::BudgetUnenforceable => "budget_unenforceable", + Self::EvidenceIncomplete => "evidence_incomplete", + Self::VerdictInvalid => "verdict_invalid", + Self::RouteNotFound => "route_not_found", + Self::RouteAmbiguous => "route_ambiguous", + Self::SenderUnauthorized => "sender_unauthorized", + Self::IdentityInvalid => "identity_invalid", + Self::IdentityChanged => "identity_changed", + Self::CovenUnavailable => "coven_unavailable", + Self::CovenVersionUnsupported => "coven_version_unsupported", + Self::CovenCapabilityMissing => "coven_capability_missing", + Self::CovenPolicyDenied => "coven_policy_denied", + Self::CovenExecutionBindingInvalid => "coven_execution_binding_invalid", + Self::CovenBindingMismatch => "coven_binding_mismatch", + Self::CovenArtifactRejected => "coven_artifact_rejected", + Self::CovenIntentConflict => "coven_intent_conflict", + Self::CovenAdoptionUnknown => "coven_adoption_unknown", + Self::CovenCancellationUnknown => "coven_cancellation_unknown", + Self::CovenSessionFailed => "coven_session_failed", + Self::DeliveryUnknown => "delivery_unknown", + Self::PreviewFinalizeBlocked => "preview_finalize_blocked", + Self::MediaRejected => "media_rejected", + Self::CallbackInvalid => "callback_invalid", + } + } + + fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|code| code.as_str() == value) + } +} + +impl Serialize for ErrorCode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ErrorBody { + pub code: ErrorCode, + pub message: String, + pub retryable: bool, + pub correlation_id: String, + pub details: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ErrorEnvelope { + pub schema_version: SchemaVersion, + pub error: ErrorBody, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ErrorEnvelopeWire { + schema_version: SchemaVersion, + error: ErrorBodyWire, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ErrorBodyWire { + code: String, + message: String, + retryable: bool, + correlation_id: String, + details: BTreeMap, +} + +impl ErrorEnvelope { + pub(crate) fn decode(value: Value) -> Result { + let wire: ErrorEnvelopeWire = + serde_json::from_value(value).map_err(|_| invalid(SchemaKind::Error, "document"))?; + let code = ErrorCode::parse(&wire.error.code).ok_or(ContractError::UnknownEnumValue { + schema: SchemaKind::Error, + field: "code", + })?; + let envelope = Self { + schema_version: wire.schema_version, + error: ErrorBody { + code, + message: wire.error.message, + retryable: wire.error.retryable, + correlation_id: wire.error.correlation_id, + details: wire.error.details, + }, + }; + envelope.validate()?; + Ok(envelope) + } + + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Error; + require_schema(self.schema_version, s)?; + bounded(&self.error.message, 4096, s, "message")?; + bounded(&self.error.correlation_id, 255, s, "correlation_id")?; + if self.error.details.len() > 128 { + return Err(invalid(s, "details")); + } + for (key, value) in &self.error.details { + bounded(key, 256, s, "details.key")?; + if value.len() > 4096 { + return Err(invalid(s, "details.value")); + } + } + Ok(()) + } +} diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs new file mode 100644 index 0000000..2c688b2 --- /dev/null +++ b/crates/psyche-core/src/contracts/execution.rs @@ -0,0 +1,490 @@ +//! Execution binding and cancellation evidence contracts. +#![allow(missing_docs)] + +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::contracts::{ + ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, + optional_bounded, reason_code, require_id, require_schema, timestamp, +}; +use crate::digest::Sha256Digest; +use crate::id::{RecordId, RequestId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdoptionState { + NotSubmitted, + Submitting, + Adopted, + ProvenNotAdopted, + AdoptionUnknown, + Fenced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CancellationState { + NotRequested, + TerminationRequested, + AcknowledgedTerminated, + AcknowledgedAlreadyTerminal, + TerminationUnknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CancellationAcknowledgementKind { + Terminated, + AlreadyAuthoritativelyTerminal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CancellationAcknowledgementEvidence { + pub acknowledgement_id: String, + pub termination_request_id: RequestId, + pub session_id: String, + pub execution_request_id: RequestId, + pub execution_request_digest: Sha256Digest, + pub kind: CancellationAcknowledgementKind, + pub authority_evidence_digest: Sha256Digest, + pub acknowledged_at: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcknowledgementWire { + acknowledgement_id: String, + termination_request_id: RequestId, + session_id: String, + execution_request_id: RequestId, + execution_request_digest: Sha256Digest, + kind: CancellationAcknowledgementKind, + authority_evidence_digest: Sha256Digest, + acknowledged_at: String, +} + +impl TryFrom for CancellationAcknowledgementEvidence { + type Error = ContractError; + fn try_from(w: AcknowledgementWire) -> Result { + let value = Self { + acknowledgement_id: w.acknowledgement_id, + termination_request_id: w.termination_request_id, + session_id: w.session_id, + execution_request_id: w.execution_request_id, + execution_request_digest: w.execution_request_digest, + kind: w.kind, + authority_evidence_digest: w.authority_evidence_digest, + acknowledged_at: w.acknowledged_at, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for CancellationAcknowledgementEvidence { + fn deserialize>(deserializer: D) -> Result { + AcknowledgementWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl CancellationAcknowledgementEvidence { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::ExecutionBinding; + bounded(&self.acknowledgement_id, 255, s, "acknowledgement_id")?; + bounded(&self.session_id, 255, s, "session_id")?; + timestamp(&self.acknowledged_at, s, "acknowledged_at")?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CancellationUnresolvedEvidence { + pub disposition_id: String, + pub termination_request_id: RequestId, + pub session_id: String, + pub execution_request_id: RequestId, + pub execution_request_digest: Sha256Digest, + pub reason_code: String, + pub recorded_at: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UnresolvedWire { + disposition_id: String, + termination_request_id: RequestId, + session_id: String, + execution_request_id: RequestId, + execution_request_digest: Sha256Digest, + reason_code: String, + recorded_at: String, +} + +impl TryFrom for CancellationUnresolvedEvidence { + type Error = ContractError; + fn try_from(w: UnresolvedWire) -> Result { + let value = Self { + disposition_id: w.disposition_id, + termination_request_id: w.termination_request_id, + session_id: w.session_id, + execution_request_id: w.execution_request_id, + execution_request_digest: w.execution_request_digest, + reason_code: w.reason_code, + recorded_at: w.recorded_at, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for CancellationUnresolvedEvidence { + fn deserialize>(deserializer: D) -> Result { + UnresolvedWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl CancellationUnresolvedEvidence { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::ExecutionBinding; + bounded(&self.disposition_id, 255, s, "disposition_id")?; + bounded(&self.session_id, 255, s, "session_id")?; + reason_code(&self.reason_code, s, "reason_code")?; + timestamp(&self.recorded_at, s, "recorded_at")?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TerminationRequestCorrelation { + pub termination_request_id: RequestId, + pub created_at: String, + pub valid_until: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TerminationWire { + termination_request_id: RequestId, + created_at: String, + valid_until: String, +} + +impl TryFrom for TerminationRequestCorrelation { + type Error = ContractError; + fn try_from(w: TerminationWire) -> Result { + let value = Self { + termination_request_id: w.termination_request_id, + created_at: w.created_at, + valid_until: w.valid_until, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for TerminationRequestCorrelation { + fn deserialize>(deserializer: D) -> Result { + TerminationWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl TerminationRequestCorrelation { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::ExecutionBinding; + let created = timestamp(&self.created_at, s, "termination_request.created_at")?; + let until = timestamp(&self.valid_until, s, "termination_request.valid_until")?; + if until <= created { + return Err(super::invalid(s, "termination_request.valid_until")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExecutionBinding { + pub schema_version: SchemaVersion, + pub attempt_id: RecordId, + pub revision: u64, + pub previous_revision_digest: Option, + pub revision_created_at: String, + pub familiar_snapshot_id: RecordId, + pub project_id: String, + pub request_id: RequestId, + pub request_digest: Sha256Digest, + pub request_created_at: String, + pub request_valid_until: String, + pub coven_contract_version: String, + pub coven_session_id: Option, + pub adoption_state: AdoptionState, + pub event_cursor: Option, + pub cancellation_state: CancellationState, + pub termination_request: Option, + pub termination_reason_code: Option, + pub cancellation_acknowledgement: Option, + pub cancellation_unresolved: Option, + pub terminal_state: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExecutionWire { + schema_version: SchemaVersion, + attempt_id: RecordId, + revision: u64, + previous_revision_digest: Option, + revision_created_at: String, + familiar_snapshot_id: RecordId, + project_id: String, + request_id: RequestId, + request_digest: Sha256Digest, + request_created_at: String, + request_valid_until: String, + coven_contract_version: String, + coven_session_id: Option, + adoption_state: AdoptionState, + event_cursor: Option, + cancellation_state: CancellationState, + termination_request: Option, + termination_reason_code: Option, + cancellation_acknowledgement: Option, + cancellation_unresolved: Option, + terminal_state: Option, +} + +impl From for ExecutionBinding { + fn from(w: ExecutionWire) -> Self { + Self { + schema_version: w.schema_version, + attempt_id: w.attempt_id, + revision: w.revision, + previous_revision_digest: w.previous_revision_digest, + revision_created_at: w.revision_created_at, + familiar_snapshot_id: w.familiar_snapshot_id, + project_id: w.project_id, + request_id: w.request_id, + request_digest: w.request_digest, + request_created_at: w.request_created_at, + request_valid_until: w.request_valid_until, + coven_contract_version: w.coven_contract_version, + coven_session_id: w.coven_session_id, + adoption_state: w.adoption_state, + event_cursor: w.event_cursor, + cancellation_state: w.cancellation_state, + termination_request: w.termination_request, + termination_reason_code: w.termination_reason_code, + cancellation_acknowledgement: w.cancellation_acknowledgement, + cancellation_unresolved: w.cancellation_unresolved, + terminal_state: w.terminal_state, + } + } +} + +impl<'de> Deserialize<'de> for ExecutionBinding { + fn deserialize>(deserializer: D) -> Result { + let value = Self::from(ExecutionWire::deserialize(deserializer)?); + value.validate().map_err(serde::de::Error::custom)?; + Ok(value) + } +} + +impl ExecutionBinding { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::ExecutionBinding; + require_schema(self.schema_version, s)?; + require_id(&self.attempt_id, RecordKind::Attempt, s, "attempt_id")?; + require_id( + &self.familiar_snapshot_id, + RecordKind::IdentitySnapshot, + s, + "familiar_snapshot_id", + )?; + if self.revision == 0 || (self.revision == 1) != self.previous_revision_digest.is_none() { + return Err(super::invalid(s, "revision")); + } + timestamp(&self.revision_created_at, s, "revision_created_at")?; + bounded(&self.project_id, 255, s, "project_id")?; + bounded( + &self.coven_contract_version, + 255, + s, + "coven_contract_version", + )?; + optional_bounded(&self.coven_session_id, 255, s, "coven_session_id")?; + optional_bounded(&self.event_cursor, 255, s, "event_cursor")?; + optional_bounded(&self.terminal_state, 255, s, "terminal_state")?; + let request_created = timestamp(&self.request_created_at, s, "request_created_at")?; + let request_until = timestamp(&self.request_valid_until, s, "request_valid_until")?; + if request_until <= request_created { + return Err(super::invalid(s, "request_valid_until")); + } + self.validate_cancellation(request_created) + } + + fn validate_cancellation( + &self, + request_created: time::OffsetDateTime, + ) -> Result<(), ContractError> { + let empty = self.termination_request.is_none() + && self.termination_reason_code.is_none() + && self.cancellation_acknowledgement.is_none() + && self.cancellation_unresolved.is_none(); + if self.cancellation_state == CancellationState::NotRequested { + return if empty { + Ok(()) + } else { + Err(ContractError::CancellationEvidenceMismatch) + }; + } + let correlation = self + .termination_request + .as_ref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + correlation.validate()?; + let reason = self + .termination_reason_code + .as_deref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + reason_code( + reason, + SchemaKind::ExecutionBinding, + "termination_reason_code", + )?; + let created = timestamp( + &correlation.created_at, + SchemaKind::ExecutionBinding, + "termination_request.created_at", + )?; + let valid_until = timestamp( + &correlation.valid_until, + SchemaKind::ExecutionBinding, + "termination_request.valid_until", + )?; + if created < request_created || correlation.termination_request_id == self.request_id { + return Err(ContractError::CancellationEvidenceMismatch); + } + match self.cancellation_state { + CancellationState::TerminationRequested => { + if self.cancellation_acknowledgement.is_none() + && self.cancellation_unresolved.is_none() + { + Ok(()) + } else { + Err(ContractError::CancellationEvidenceMismatch) + } + } + CancellationState::AcknowledgedTerminated => self.validate_ack( + CancellationAcknowledgementKind::Terminated, + created, + valid_until, + ), + CancellationState::AcknowledgedAlreadyTerminal => self.validate_ack( + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal, + created, + valid_until, + ), + CancellationState::TerminationUnknown => { + if self.cancellation_acknowledgement.is_some() { + return Err(ContractError::CancellationEvidenceMismatch); + } + let evidence = self + .cancellation_unresolved + .as_ref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + evidence.validate()?; + self.validate_evidence_bindings( + &evidence.termination_request_id, + &evidence.session_id, + &evidence.execution_request_id, + &evidence.execution_request_digest, + )?; + let at = timestamp( + &evidence.recorded_at, + SchemaKind::ExecutionBinding, + "recorded_at", + )?; + in_window(at, created, valid_until) + } + CancellationState::NotRequested => unreachable!(), + } + } + + fn validate_ack( + &self, + kind: CancellationAcknowledgementKind, + created: time::OffsetDateTime, + valid_until: time::OffsetDateTime, + ) -> Result<(), ContractError> { + if self.cancellation_unresolved.is_some() { + return Err(ContractError::CancellationEvidenceMismatch); + } + let evidence = self + .cancellation_acknowledgement + .as_ref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + evidence.validate()?; + if evidence.kind != kind { + return Err(ContractError::CancellationEvidenceMismatch); + } + self.validate_evidence_bindings( + &evidence.termination_request_id, + &evidence.session_id, + &evidence.execution_request_id, + &evidence.execution_request_digest, + )?; + let at = timestamp( + &evidence.acknowledged_at, + SchemaKind::ExecutionBinding, + "acknowledged_at", + )?; + in_window(at, created, valid_until) + } + + fn validate_evidence_bindings( + &self, + termination: &RequestId, + session: &str, + execution: &RequestId, + digest: &Sha256Digest, + ) -> Result<(), ContractError> { + let expected_termination = self + .termination_request + .as_ref() + .map(|v| &v.termination_request_id); + if expected_termination != Some(termination) + || self.coven_session_id.as_deref() != Some(session) + || &self.request_id != execution + || &self.request_digest != digest + { + Err(ContractError::CancellationEvidenceMismatch) + } else { + Ok(()) + } + } +} + +fn in_window( + at: time::OffsetDateTime, + created: time::OffsetDateTime, + valid_until: time::OffsetDateTime, +) -> Result<(), ContractError> { + if at < created || at > valid_until { + Err(ContractError::CancellationEvidenceMismatch) + } else { + Ok(()) + } +} + +impl VersionedRecord for ExecutionBinding { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.attempt_id + } +} diff --git a/crates/psyche-core/src/contracts/foundation.rs b/crates/psyche-core/src/contracts/foundation.rs new file mode 100644 index 0000000..7e69294 --- /dev/null +++ b/crates/psyche-core/src/contracts/foundation.rs @@ -0,0 +1,248 @@ +//! Minimum policy-free foundation records. +#![allow(missing_docs)] + +use crate::contracts::{ + ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, + optional_bounded, require_id, require_schema, string_list, timestamp, +}; +use crate::digest::Sha256Digest; +use crate::id::RecordId; + +macro_rules! versioned { + ($ty:ty, $id:ident) => { + impl VersionedRecord for $ty { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.$id + } + } + }; +} + +validated_struct! { + pub struct Delegation, DelegationWire { + pub schema_version: SchemaVersion, + pub delegation_id: RecordId, + pub parent_node_id: RecordId, + pub child_node_id: RecordId, + pub scope_digest: Sha256Digest, + pub budget_id: RecordId, + pub evidence_scope_digest: Sha256Digest, + pub cancellation_policy: String, + } +} + +impl Delegation { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Delegation; + require_schema(self.schema_version, s)?; + require_id( + &self.delegation_id, + RecordKind::Delegation, + s, + "delegation_id", + )?; + require_id( + &self.parent_node_id, + RecordKind::GraphNode, + s, + "parent_node_id", + )?; + require_id( + &self.child_node_id, + RecordKind::GraphNode, + s, + "child_node_id", + )?; + require_id(&self.budget_id, RecordKind::Budget, s, "budget_id")?; + bounded(&self.cancellation_policy, 256, s, "cancellation_policy") + } +} +versioned!(Delegation, delegation_id); + +validated_struct! { + pub struct Budget, BudgetWire { + pub schema_version: SchemaVersion, + pub budget_id: RecordId, + pub graph_id: RecordId, + pub resource_class: String, + pub limit: u64, + pub reserved: u64, + pub consumed: u64, + pub released: u64, + } +} + +impl Budget { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Budget; + require_schema(self.schema_version, s)?; + require_id(&self.budget_id, RecordKind::Budget, s, "budget_id")?; + require_id(&self.graph_id, RecordKind::Graph, s, "graph_id")?; + bounded(&self.resource_class, 256, s, "resource_class") + } +} +versioned!(Budget, budget_id); + +validated_struct! { + pub struct Approval, ApprovalWire { + pub schema_version: SchemaVersion, + pub approval_id: RecordId, + pub node_id: RecordId, + pub requester_principal_id: String, + pub decision: Option, + pub expires_at: String, + } +} + +impl Approval { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Approval; + require_schema(self.schema_version, s)?; + require_id(&self.approval_id, RecordKind::Approval, s, "approval_id")?; + require_id(&self.node_id, RecordKind::GraphNode, s, "node_id")?; + bounded( + &self.requester_principal_id, + 255, + s, + "requester_principal_id", + )?; + optional_bounded(&self.decision, 256, s, "decision")?; + timestamp(&self.expires_at, s, "expires_at")?; + Ok(()) + } +} +versioned!(Approval, approval_id); + +validated_struct! { + pub struct Evidence, EvidenceWire { + pub schema_version: SchemaVersion, + pub evidence_id: RecordId, + pub node_id: RecordId, + pub attempt_id: RecordId, + pub content_digest: Sha256Digest, + pub producer: String, + pub collection_method: String, + pub media_type: String, + pub size: u64, + pub created_at: String, + pub retention_policy: String, + } +} + +impl Evidence { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Evidence; + require_schema(self.schema_version, s)?; + require_id(&self.evidence_id, RecordKind::Evidence, s, "evidence_id")?; + require_id(&self.node_id, RecordKind::GraphNode, s, "node_id")?; + require_id(&self.attempt_id, RecordKind::Attempt, s, "attempt_id")?; + for (value, field) in [ + (&self.producer, "producer"), + (&self.collection_method, "collection_method"), + (&self.media_type, "media_type"), + (&self.retention_policy, "retention_policy"), + ] { + bounded(value, 256, s, field)?; + } + timestamp(&self.created_at, s, "created_at")?; + Ok(()) + } +} +versioned!(Evidence, evidence_id); + +validated_struct! { + pub struct Verdict, VerdictWire { + pub schema_version: SchemaVersion, + pub verdict_id: RecordId, + pub node_id: RecordId, + pub sealed_evidence_digest: Sha256Digest, + pub policy_revision: String, + pub verdict_type: String, + pub reviewer_id: String, + pub outcome: String, + pub reason_codes: Vec, + pub created_at: String, + } +} + +impl Verdict { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Verdict; + require_schema(self.schema_version, s)?; + require_id(&self.verdict_id, RecordKind::Verdict, s, "verdict_id")?; + require_id(&self.node_id, RecordKind::GraphNode, s, "node_id")?; + for (value, field) in [ + (&self.policy_revision, "policy_revision"), + (&self.verdict_type, "verdict_type"), + (&self.reviewer_id, "reviewer_id"), + (&self.outcome, "outcome"), + ] { + bounded(value, 256, s, field)?; + } + string_list(&self.reason_codes, s, "reason_codes")?; + timestamp(&self.created_at, s, "created_at")?; + Ok(()) + } +} +versioned!(Verdict, verdict_id); + +validated_struct! { + pub struct Recovery, RecoveryWire { + pub schema_version: SchemaVersion, + pub recovery_id: RecordId, + pub attempt_id: RecordId, + pub lease_id: String, + pub fence_token: Option, + pub ambiguity: String, + pub reconciliation_count: u64, + pub operator_disposition: Option, + } +} + +impl Recovery { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Recovery; + require_schema(self.schema_version, s)?; + require_id(&self.recovery_id, RecordKind::Recovery, s, "recovery_id")?; + require_id(&self.attempt_id, RecordKind::Attempt, s, "attempt_id")?; + bounded(&self.lease_id, 255, s, "lease_id")?; + bounded(&self.ambiguity, 256, s, "ambiguity")?; + optional_bounded(&self.fence_token, 255, s, "fence_token")?; + optional_bounded(&self.operator_disposition, 256, s, "operator_disposition") + } +} +versioned!(Recovery, recovery_id); + +validated_struct! { + pub struct Addon, AddonWire { + pub schema_version: SchemaVersion, + pub addon_id: RecordId, + pub package: String, + pub version: String, + pub package_digest: Sha256Digest, + pub provenance_digest: Sha256Digest, + pub contributions_digest: Sha256Digest, + pub allowlist_digest: Sha256Digest, + pub revocation_state: String, + } +} + +impl Addon { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Addon; + require_schema(self.schema_version, s)?; + require_id(&self.addon_id, RecordKind::Addon, s, "addon_id")?; + for (value, field) in [ + (&self.package, "package"), + (&self.version, "version"), + (&self.revocation_state, "revocation_state"), + ] { + bounded(value, 256, s, field)?; + } + Ok(()) + } +} +versioned!(Addon, addon_id); diff --git a/crates/psyche-core/src/contracts/graph.rs b/crates/psyche-core/src/contracts/graph.rs new file mode 100644 index 0000000..d47075a --- /dev/null +++ b/crates/psyche-core/src/contracts/graph.rs @@ -0,0 +1,145 @@ +//! Graph and node contracts. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; + +use crate::contracts::{ + ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, require_id, + require_schema, string_list, +}; +use crate::id::RecordId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphState { + Draft, + Admitted, + Rejected, + Running, + WaitingApproval, + WaitingEvidence, + Cancelling, + Completed, + Failed, + Cancelled, + RecoveryRequired, +} + +validated_struct! { + pub struct Graph, GraphWire { + pub schema_version: SchemaVersion, + pub graph_id: RecordId, + pub root_intent_id: RecordId, + pub owner_principal_id: String, + pub policy_revision: String, + pub state: GraphState, + pub version: u64, + } +} + +impl Graph { + pub fn validate(&self) -> Result<(), ContractError> { + let schema = SchemaKind::Graph; + require_schema(self.schema_version, schema)?; + require_id(&self.graph_id, RecordKind::Graph, schema, "graph_id")?; + require_id( + &self.root_intent_id, + RecordKind::Intent, + schema, + "root_intent_id", + )?; + bounded(&self.owner_principal_id, 255, schema, "owner_principal_id")?; + bounded(&self.policy_revision, 255, schema, "policy_revision")?; + if self.version == 0 { + return Err(super::invalid(schema, "version")); + } + Ok(()) + } +} + +impl VersionedRecord for Graph { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.graph_id + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeState { + Proposed, + Admitted, + Rejected, + Blocked, + Ready, + Skipped, + Reserved, + Dispatching, + Adopted, + AdoptionUnknown, + ProvenNotAdopted, + Failed, + Running, + WaitingApproval, + Candidate, + AwaitingVerification, + Verified, + EscalationRequired, + Cancelling, + Cancelled, + TerminationUnknown, + RecoveryRequired, +} + +validated_struct! { + pub struct GraphNode, GraphNodeWire { + pub schema_version: SchemaVersion, + pub node_id: RecordId, + pub graph_id: RecordId, + pub familiar_snapshot_id: RecordId, + pub dependencies: Vec, + pub delegation_id: Option, + pub budget_id: RecordId, + pub required_evidence: Vec, + pub state: NodeState, + pub version: u64, + } +} + +impl GraphNode { + pub fn validate(&self) -> Result<(), ContractError> { + let schema = SchemaKind::GraphNode; + require_schema(self.schema_version, schema)?; + require_id(&self.node_id, RecordKind::GraphNode, schema, "node_id")?; + require_id(&self.graph_id, RecordKind::Graph, schema, "graph_id")?; + require_id( + &self.familiar_snapshot_id, + RecordKind::IdentitySnapshot, + schema, + "familiar_snapshot_id", + )?; + self.dependencies + .iter() + .try_for_each(|id| require_id(id, RecordKind::GraphNode, schema, "dependencies"))?; + if let Some(id) = &self.delegation_id { + require_id(id, RecordKind::Delegation, schema, "delegation_id")?; + } + require_id(&self.budget_id, RecordKind::Budget, schema, "budget_id")?; + string_list(&self.required_evidence, schema, "required_evidence")?; + if self.version == 0 { + return Err(super::invalid(schema, "version")); + } + Ok(()) + } +} + +impl VersionedRecord for GraphNode { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.node_id + } +} diff --git a/crates/psyche-core/src/contracts/identity.rs b/crates/psyche-core/src/contracts/identity.rs new file mode 100644 index 0000000..bc29ba0 --- /dev/null +++ b/crates/psyche-core/src/contracts/identity.rs @@ -0,0 +1,77 @@ +//! Identity snapshot contract. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; + +use crate::contracts::{ + ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, require_id, + require_schema, timestamp, +}; +use crate::digest::Sha256Digest; +use crate::id::RecordId; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityProvenance { + pub familiar_home_id: String, + pub resolver_version: String, +} + +validated_struct! { + pub struct IdentitySnapshot, IdentitySnapshotWire { + pub schema_version: SchemaVersion, + pub snapshot_id: RecordId, + pub familiar_id: String, + pub principal_id: String, + pub revision: u64, + pub declaration_digest: Sha256Digest, + pub identity_file_digest: Sha256Digest, + pub identity_digest: Sha256Digest, + pub soul_digest: Sha256Digest, + pub role_skill_digest: Sha256Digest, + pub provenance: IdentityProvenance, + pub resolved_at: String, + } +} + +impl IdentitySnapshot { + pub fn validate(&self) -> Result<(), ContractError> { + let schema = SchemaKind::IdentitySnapshot; + require_schema(self.schema_version, schema)?; + require_id( + &self.snapshot_id, + RecordKind::IdentitySnapshot, + schema, + "snapshot_id", + )?; + bounded(&self.familiar_id, 255, schema, "familiar_id")?; + bounded(&self.principal_id, 255, schema, "principal_id")?; + if self.revision == 0 { + return Err(super::invalid(schema, "revision")); + } + bounded( + &self.provenance.familiar_home_id, + 255, + schema, + "provenance.familiar_home_id", + )?; + bounded( + &self.provenance.resolver_version, + 255, + schema, + "provenance.resolver_version", + )?; + timestamp(&self.resolved_at, schema, "resolved_at")?; + Ok(()) + } +} + +impl VersionedRecord for IdentitySnapshot { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + + fn record_id(&self) -> &RecordId { + &self.snapshot_id + } +} diff --git a/crates/psyche-core/src/contracts/intent.rs b/crates/psyche-core/src/contracts/intent.rs new file mode 100644 index 0000000..2997736 --- /dev/null +++ b/crates/psyche-core/src/contracts/intent.rs @@ -0,0 +1,68 @@ +//! Intent contract. +#![allow(missing_docs)] + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::contracts::{ + ContractError, MAX_DOCUMENT_BYTES, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, + bounded, require_id, require_schema, string_list, timestamp, +}; +use crate::digest::Sha256Digest; +use crate::id::RecordId; + +validated_struct! { + pub struct Intent, IntentWire { + pub schema_version: SchemaVersion, + pub intent_id: RecordId, + pub principal_id: String, + pub familiar_snapshot_id: RecordId, + pub project_id: String, + pub requested_outcome: String, + pub constraints: BTreeMap, + pub required_evidence: Vec, + pub surface_event_id: Option, + pub created_at: String, + pub digest: Sha256Digest, + } +} + +impl Intent { + pub fn validate(&self) -> Result<(), ContractError> { + let schema = SchemaKind::Intent; + require_schema(self.schema_version, schema)?; + require_id(&self.intent_id, RecordKind::Intent, schema, "intent_id")?; + require_id( + &self.familiar_snapshot_id, + RecordKind::IdentitySnapshot, + schema, + "familiar_snapshot_id", + )?; + if let Some(id) = &self.surface_event_id { + require_id(id, RecordKind::SurfaceEvent, schema, "surface_event_id")?; + } + bounded(&self.principal_id, 255, schema, "principal_id")?; + bounded(&self.project_id, 255, schema, "project_id")?; + bounded(&self.requested_outcome, 16_384, schema, "requested_outcome")?; + string_list(&self.required_evidence, schema, "required_evidence")?; + for key in self.constraints.keys() { + bounded(key, 256, schema, "constraints")?; + } + if crate::digest::canonical_bytes(&self.constraints)?.len() > MAX_DOCUMENT_BYTES { + return Err(super::invalid(schema, "constraints")); + } + timestamp(&self.created_at, schema, "created_at")?; + Ok(()) + } +} + +impl VersionedRecord for Intent { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + + fn record_id(&self) -> &RecordId { + &self.intent_id + } +} diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 2a596b8..81744f2 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -1,4 +1,4 @@ -//! Canonical contract primitives: record kinds and schema versions. +//! Canonical contract primitives and the strict Psyche v1 document decoder. //! //! This module is the sole authority mapping a [`SchemaKind`] to the //! [`RecordKind`] it produces (if any), and the sole authority for which @@ -7,6 +7,63 @@ //! store validation, and no `QuarantineId` — those are store-owned and land //! in later tasks (`QuarantineId` explicitly in Task 7). use std::fmt; +use std::str::FromStr; + +use serde::Serialize; +use serde_json::Value; + +use crate::id::RecordId; + +macro_rules! validated_struct { + ( + pub struct $name:ident, $wire:ident { + $(pub $field:ident: $ty:ty),+ $(,)? + } + ) => { + #[derive(Debug, Clone, PartialEq, serde::Serialize)] + pub struct $name { + $(pub $field: $ty),+ + } + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct $wire { + $($field: $ty),+ + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize>( + deserializer: D, + ) -> Result { + let wire = <$wire as serde::Deserialize>::deserialize(deserializer)?; + let value = Self { + $($field: wire.$field),+ + }; + value.validate().map_err(serde::de::Error::custom)?; + Ok(value) + } + } + }; +} + +pub mod error; +pub mod execution; +pub mod foundation; +pub mod graph; +pub mod identity; +pub mod intent; +pub mod surface; + +pub use error::ErrorEnvelope; +pub use execution::ExecutionBinding; +pub use foundation::{Addon, Approval, Budget, Delegation, Evidence, Recovery, Verdict}; +pub use graph::{Graph, GraphNode}; +pub use identity::IdentitySnapshot; +pub use intent::Intent; +pub use surface::{Delivery, SurfaceEffect, SurfaceEvent}; + +/// Maximum accepted encoded or embedded canonical document size. +pub const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; /// Reasons a contract primitive failed to validate. /// @@ -58,6 +115,28 @@ pub enum ContractError { /// `serde_json`'s description of the shape problem. reason: String, }, + /// A known schema did not have its exact v1 field shape or valid values. + #[error("invalid {schema:?} document shape at {field}")] + InvalidShape { + /// Schema whose document was rejected. + schema: SchemaKind, + /// Stable field name or validation category. + field: &'static str, + }, + /// A string did not name a member of a frozen enum vocabulary. + #[error("unknown enum value for {schema:?}.{field}")] + UnknownEnumValue { + /// Schema containing the enum. + schema: SchemaKind, + /// Enum field. + field: &'static str, + }, + /// Cancellation evidence did not authorize the declared cancellation state. + #[error("cancellation evidence does not match the execution binding")] + CancellationEvidenceMismatch, + /// The encoded document exceeded [`MAX_DOCUMENT_BYTES`]. + #[error("document exceeds the maximum encoded size")] + DocumentTooLarge, } /// The fifteen kinds of record this build persists, each identified by a @@ -309,6 +388,7 @@ impl SchemaVersion { if *namespace != "psyche" { return Err(unknown()); } + let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; let unsupported_major = || ContractError::UnsupportedMajor { @@ -354,6 +434,293 @@ impl From for String { } } +impl FromStr for SchemaVersion { + type Err = ContractError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +/// A persistable v1 domain record with a schema and durable record identifier. +pub trait VersionedRecord: Serialize { + /// The record's declared schema. + fn schema_version(&self) -> SchemaVersion; + /// The record's durable identifier. + fn record_id(&self) -> &RecordId; +} + +/// Every canonical document accepted by this build. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +pub enum CanonicalDocument { + /// Identity snapshot. + IdentitySnapshot(IdentitySnapshot), + /// Intent. + Intent(Intent), + /// Surface event. + SurfaceEvent(SurfaceEvent), + /// Graph. + Graph(Graph), + /// Graph node. + GraphNode(GraphNode), + /// Delegation. + Delegation(Delegation), + /// Budget. + Budget(Budget), + /// Approval. + Approval(Approval), + /// Execution binding, persisted as an attempt. + ExecutionBinding(ExecutionBinding), + /// Evidence. + Evidence(Evidence), + /// Verdict. + Verdict(Verdict), + /// Recovery. + Recovery(Recovery), + /// Add-on. + Addon(Addon), + /// Surface effect. + SurfaceEffect(SurfaceEffect), + /// Delivery. + Delivery(Delivery), + /// Non-persistable typed error envelope. + Error(ErrorEnvelope), +} + +impl CanonicalDocument { + /// Revalidates a decoded or directly constructed value. + pub fn validate(&self) -> Result<(), ContractError> { + match self { + Self::IdentitySnapshot(v) => v.validate(), + Self::Intent(v) => v.validate(), + Self::SurfaceEvent(v) => v.validate(), + Self::Graph(v) => v.validate(), + Self::GraphNode(v) => v.validate(), + Self::Delegation(v) => v.validate(), + Self::Budget(v) => v.validate(), + Self::Approval(v) => v.validate(), + Self::ExecutionBinding(v) => v.validate(), + Self::Evidence(v) => v.validate(), + Self::Verdict(v) => v.validate(), + Self::Recovery(v) => v.validate(), + Self::Addon(v) => v.validate(), + Self::SurfaceEffect(v) => v.validate(), + Self::Delivery(v) => v.validate(), + Self::Error(v) => v.validate(), + } + } + + /// Declared schema version. + pub fn schema_version(&self) -> SchemaVersion { + match self { + Self::IdentitySnapshot(v) => v.schema_version(), + Self::Intent(v) => v.schema_version(), + Self::SurfaceEvent(v) => v.schema_version(), + Self::Graph(v) => v.schema_version(), + Self::GraphNode(v) => v.schema_version(), + Self::Delegation(v) => v.schema_version(), + Self::Budget(v) => v.schema_version(), + Self::Approval(v) => v.schema_version(), + Self::ExecutionBinding(v) => v.schema_version(), + Self::Evidence(v) => v.schema_version(), + Self::Verdict(v) => v.schema_version(), + Self::Recovery(v) => v.schema_version(), + Self::Addon(v) => v.schema_version(), + Self::SurfaceEffect(v) => v.schema_version(), + Self::Delivery(v) => v.schema_version(), + Self::Error(v) => v.schema_version, + } + } + + /// Durable record ID, or `None` for an error envelope. + pub fn persistable_record_id(&self) -> Option<&RecordId> { + match self { + Self::IdentitySnapshot(v) => Some(v.record_id()), + Self::Intent(v) => Some(v.record_id()), + Self::SurfaceEvent(v) => Some(v.record_id()), + Self::Graph(v) => Some(v.record_id()), + Self::GraphNode(v) => Some(v.record_id()), + Self::Delegation(v) => Some(v.record_id()), + Self::Budget(v) => Some(v.record_id()), + Self::Approval(v) => Some(v.record_id()), + Self::ExecutionBinding(v) => Some(v.record_id()), + Self::Evidence(v) => Some(v.record_id()), + Self::Verdict(v) => Some(v.record_id()), + Self::Recovery(v) => Some(v.record_id()), + Self::Addon(v) => Some(v.record_id()), + Self::SurfaceEffect(v) => Some(v.record_id()), + Self::Delivery(v) => Some(v.record_id()), + Self::Error(_) => None, + } + } +} + +/// Strictly decodes one known v1 canonical document. +pub fn decode_document(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err(ContractError::DocumentTooLarge); + } + let value: Value = + serde_json::from_slice(bytes).map_err(|_| invalid(SchemaKind::Error, "json"))?; + let schema_text = value + .as_object() + .and_then(|v| v.get("schema_version")) + .and_then(Value::as_str) + .ok_or_else(|| invalid(SchemaKind::Error, "schema_version"))?; + let schema = SchemaVersion::parse(schema_text)?; + let document = match schema.kind { + SchemaKind::IdentitySnapshot => { + decode(value, CanonicalDocument::IdentitySnapshot, schema.kind)? + } + SchemaKind::Intent => decode(value, CanonicalDocument::Intent, schema.kind)?, + SchemaKind::SurfaceEvent => decode(value, CanonicalDocument::SurfaceEvent, schema.kind)?, + SchemaKind::Graph => decode(value, CanonicalDocument::Graph, schema.kind)?, + SchemaKind::GraphNode => decode(value, CanonicalDocument::GraphNode, schema.kind)?, + SchemaKind::Delegation => decode(value, CanonicalDocument::Delegation, schema.kind)?, + SchemaKind::Budget => decode(value, CanonicalDocument::Budget, schema.kind)?, + SchemaKind::Approval => decode(value, CanonicalDocument::Approval, schema.kind)?, + SchemaKind::ExecutionBinding => { + decode(value, CanonicalDocument::ExecutionBinding, schema.kind)? + } + SchemaKind::Evidence => decode(value, CanonicalDocument::Evidence, schema.kind)?, + SchemaKind::Verdict => decode(value, CanonicalDocument::Verdict, schema.kind)?, + SchemaKind::Recovery => decode(value, CanonicalDocument::Recovery, schema.kind)?, + SchemaKind::Addon => decode(value, CanonicalDocument::Addon, schema.kind)?, + SchemaKind::SurfaceEffect => decode(value, CanonicalDocument::SurfaceEffect, schema.kind)?, + SchemaKind::Delivery => decode(value, CanonicalDocument::Delivery, schema.kind)?, + SchemaKind::Error => { + return ErrorEnvelope::decode(value).map(CanonicalDocument::Error); + } + }; + document.validate()?; + Ok(document) +} + +fn decode( + value: Value, + wrap: impl FnOnce(T) -> CanonicalDocument, + schema: SchemaKind, +) -> Result { + serde_json::from_value(value) + .map(wrap) + .map_err(|_| invalid(schema, "document")) +} + +pub(crate) fn invalid(schema: SchemaKind, field: &'static str) -> ContractError { + ContractError::InvalidShape { schema, field } +} + +pub(crate) fn require_schema(value: SchemaVersion, kind: SchemaKind) -> Result<(), ContractError> { + if value.kind == kind && value.major == 1 { + Ok(()) + } else { + Err(invalid(kind, "schema_version")) + } +} + +pub(crate) fn require_id( + id: &RecordId, + kind: RecordKind, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + if id.kind() == kind { + Ok(()) + } else { + Err(invalid(schema, field)) + } +} + +pub(crate) fn bounded( + value: &str, + max: usize, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + if !value.is_empty() && value.len() <= max { + Ok(()) + } else { + Err(invalid(schema, field)) + } +} + +pub(crate) fn optional_bounded( + value: &Option, + max: usize, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + value + .as_deref() + .map_or(Ok(()), |v| bounded(v, max, schema, field)) +} + +pub(crate) fn string_list( + values: &[String], + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + if values.len() > 1024 { + return Err(invalid(schema, field)); + } + values + .iter() + .try_for_each(|v| bounded(v, 256, schema, field)) +} + +pub(crate) fn timestamp( + value: &str, + schema: SchemaKind, + field: &'static str, +) -> Result { + time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|_| invalid(schema, field)) +} + +pub(crate) fn object( + value: &Value, + schema: SchemaKind, + field: &'static str, + nonempty: bool, +) -> Result<(), ContractError> { + let Some(map) = value.as_object() else { + return Err(invalid(schema, field)); + }; + if nonempty && map.is_empty() { + return Err(invalid(schema, field)); + } + if crate::digest::canonical_bytes(value)?.len() > MAX_DOCUMENT_BYTES { + return Err(invalid(schema, field)); + } + Ok(()) +} + +pub(crate) fn reason_code( + value: &str, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + let mut segments = value.split('_'); + let first = segments.next().unwrap_or_default(); + let valid_segment = |segment: &str| { + !segment.is_empty() + && segment + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }; + if value.len() <= 128 + && first.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && valid_segment(first) + && segments.all(valid_segment) + { + Ok(()) + } else { + Err(invalid(schema, field)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/psyche-core/src/contracts/surface.rs b/crates/psyche-core/src/contracts/surface.rs new file mode 100644 index 0000000..fff336b --- /dev/null +++ b/crates/psyche-core/src/contracts/surface.rs @@ -0,0 +1,265 @@ +//! Surface observation, effect, and delivery contracts. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::contracts::{ + ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, object, + require_id, require_schema, timestamp, +}; +use crate::digest::{Sha256Digest, digest}; +use crate::id::RecordId; + +validated_struct! { + pub struct SurfaceEvent, SurfaceEventWire { + pub schema_version: SchemaVersion, + pub surface_event_id: RecordId, + pub adapter_id: String, + pub account_id: String, + pub actor: Value, + pub locator: Value, + pub adapter_event_digest: Sha256Digest, + pub received_at: String, + pub content: Value, + } +} + +impl SurfaceEvent { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::SurfaceEvent; + require_schema(self.schema_version, s)?; + require_id( + &self.surface_event_id, + RecordKind::SurfaceEvent, + s, + "surface_event_id", + )?; + bounded(&self.adapter_id, 256, s, "adapter_id")?; + bounded(&self.account_id, 256, s, "account_id")?; + object(&self.actor, s, "actor", false)?; + object(&self.locator, s, "locator", false)?; + object(&self.content, s, "content", false)?; + timestamp(&self.received_at, s, "received_at")?; + Ok(()) + } +} + +impl VersionedRecord for SurfaceEvent { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.surface_event_id + } +} + +validated_struct! { + pub struct SurfaceEffect, SurfaceEffectWire { + pub schema_version: SchemaVersion, + pub surface_effect_id: RecordId, + pub intent_id: RecordId, + pub graph_id: RecordId, + pub node_id: RecordId, + pub attempt_id: RecordId, + pub familiar_snapshot_id: RecordId, + pub project_id: String, + pub action_class: String, + pub account_id: String, + pub locator: Value, + pub effect: Value, + pub effect_digest: Sha256Digest, + pub created_at: String, + } +} + +impl SurfaceEffect { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::SurfaceEffect; + require_schema(self.schema_version, s)?; + for (id, kind, field) in [ + ( + &self.surface_effect_id, + RecordKind::SurfaceEffect, + "surface_effect_id", + ), + (&self.intent_id, RecordKind::Intent, "intent_id"), + (&self.graph_id, RecordKind::Graph, "graph_id"), + (&self.node_id, RecordKind::GraphNode, "node_id"), + (&self.attempt_id, RecordKind::Attempt, "attempt_id"), + ( + &self.familiar_snapshot_id, + RecordKind::IdentitySnapshot, + "familiar_snapshot_id", + ), + ] { + require_id(id, kind, s, field)?; + } + for (value, field) in [ + (&self.project_id, "project_id"), + (&self.action_class, "action_class"), + (&self.account_id, "account_id"), + ] { + bounded(value, 256, s, field)?; + } + object(&self.locator, s, "locator", false)?; + object(&self.effect, s, "effect", false)?; + if digest(&self.effect)? != self.effect_digest { + return Err(super::invalid(s, "effect_digest")); + } + timestamp(&self.created_at, s, "created_at")?; + Ok(()) + } +} + +impl VersionedRecord for SurfaceEffect { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.surface_effect_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeliveryTopic { + pub kind: String, + pub id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryRelationship { + ReplySameDm, + ReplySameGroup, + ReplySameTopic, + CrossChat, + Broadcast, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryDecisionState { + Reserved, + Consumed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeliverySurfaceDecision { + pub decision_id: String, + pub request_digest: Sha256Digest, + pub policy_revision: String, + pub expires_at: String, + pub state: DeliveryDecisionState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryState { + Ready, + Sending, + Sent, + Retryable, + DeliveryUnknown, + Failed, + Abandoned, + DeadLetter, + ResolvingUnknown, + Compensated, +} + +validated_struct! { + pub struct Delivery, DeliveryWire { + pub schema_version: SchemaVersion, + pub delivery_id: RecordId, + pub intent_id: RecordId, + pub action_class: String, + pub account_id: String, + pub chat_id: String, + pub topic: DeliveryTopic, + pub relationship: DeliveryRelationship, + pub effect: Value, + pub effect_digest: Sha256Digest, + pub surface_decision: DeliverySurfaceDecision, + pub logical_response_id: String, + pub logical_part: u32, + pub state: DeliveryState, + pub attempt_count: u32, + pub telegram_message_id: Option, + } +} + +impl Delivery { + pub fn validate(&self) -> Result<(), ContractError> { + let s = SchemaKind::Delivery; + require_schema(self.schema_version, s)?; + require_id(&self.delivery_id, RecordKind::Delivery, s, "delivery_id")?; + require_id(&self.intent_id, RecordKind::Intent, s, "intent_id")?; + for (value, field) in [ + (&self.action_class, "action_class"), + (&self.account_id, "account_id"), + (&self.topic.kind, "topic.kind"), + (&self.topic.id, "topic.id"), + ( + &self.surface_decision.policy_revision, + "surface_decision.policy_revision", + ), + (&self.logical_response_id, "logical_response_id"), + ( + &self.surface_decision.decision_id, + "surface_decision.decision_id", + ), + ] { + bounded(value, 256, s, field)?; + } + decimal(&self.chat_id, true, s, "chat_id")?; + if let Some(id) = &self.telegram_message_id { + decimal(id, false, s, "telegram_message_id")?; + } + object(&self.effect, s, "effect", true)?; + if digest(&self.effect)? != self.effect_digest { + return Err(super::invalid(s, "effect_digest")); + } + timestamp( + &self.surface_decision.expires_at, + s, + "surface_decision.expires_at", + )?; + if self.state == DeliveryState::Sent && self.telegram_message_id.is_none() { + return Err(super::invalid(s, "telegram_message_id")); + } + Ok(()) + } +} + +fn decimal( + value: &str, + signed: bool, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + if value.is_empty() || value.len() > 32 { + return Err(super::invalid(schema, field)); + } + let digits = if signed { + value.strip_prefix('-').unwrap_or(value) + } else { + value + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + Err(super::invalid(schema, field)) + } else { + Ok(()) + } +} + +impl VersionedRecord for Delivery { + fn schema_version(&self) -> SchemaVersion { + self.schema_version + } + fn record_id(&self) -> &RecordId { + &self.delivery_id + } +} diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs new file mode 100644 index 0000000..fc30ae3 --- /dev/null +++ b/crates/psyche-core/tests/contracts.rs @@ -0,0 +1,461 @@ +//! Psyche v1 contract fixtures and strict decoding integration tests. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use psyche_core::contracts::error::ErrorCode; +use psyche_core::contracts::execution::{ + AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, + CancellationState, ExecutionBinding, TerminationRequestCorrelation, +}; +use psyche_core::contracts::{CanonicalDocument, ContractError, SchemaKind, decode_document}; +use psyche_core::digest::canonical_bytes; +use psyche_core::id::{RecordId, RequestId}; +use serde_json::{Value, json}; + +const ULID_A: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const ULID_B: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn decode(name: &str) -> CanonicalDocument { + decode_document(&fixture(name)).unwrap() +} + +fn mutate(name: &str, f: impl FnOnce(&mut serde_json::Map)) -> Vec { + let mut value: Value = serde_json::from_slice(&fixture(name)).unwrap(); + f(value.as_object_mut().unwrap()); + serde_json::to_vec(&value).unwrap() +} + +#[test] +fn intent_rejects_unknown_fields() { + let bytes = mutate("intent-local.json", |object| { + object.insert("unexpected".into(), json!(true)); + }); + assert!(matches!( + decode_document(&bytes), + Err(ContractError::InvalidShape { .. }) + )); +} + +#[test] +fn graph_and_node_accept_only_the_two_frozen_nullable_bindings() { + let intent = decode("intent-local.json"); + let node = decode("node-root.json"); + assert!(matches!(intent, CanonicalDocument::Intent(_))); + assert!(matches!(node, CanonicalDocument::GraphNode(_))); + + let missing_required = mutate("intent-local.json", |object| { + object.insert("principal_id".into(), Value::Null); + }); + assert!(matches!( + decode_document(&missing_required), + Err(ContractError::InvalidShape { .. }) + )); + + let missing_node_binding = mutate("node-root.json", |object| { + object.insert("budget_id".into(), Value::Null); + }); + assert!(matches!( + decode_document(&missing_node_binding), + Err(ContractError::InvalidShape { .. }) + )); +} + +#[test] +fn delivery_v1_fixture_round_trips_canonically() { + let document = decode("delivery-ready.json"); + assert!(matches!(document, CanonicalDocument::Delivery(_))); + let expected: Value = serde_json::from_slice(&fixture("delivery-ready.json")).unwrap(); + assert_eq!( + canonical_bytes(&document).unwrap(), + canonical_bytes(&expected).unwrap() + ); + document.validate().unwrap(); +} + +#[test] +fn surface_event_and_effect_fixtures_round_trip() { + for (name, expected_kind) in [ + ("surface-event.json", SchemaKind::SurfaceEvent), + ("surface-effect.json", SchemaKind::SurfaceEffect), + ] { + let document = decode(name); + assert_eq!(document.schema_version().kind, expected_kind); + let expected: Value = serde_json::from_slice(&fixture(name)).unwrap(); + assert_eq!( + canonical_bytes(&document).unwrap(), + canonical_bytes(&expected).unwrap() + ); + } +} + +#[test] +fn delivery_keeps_the_canonical_del_prefix() { + let document = decode("delivery-ready.json"); + assert_eq!( + document.persistable_record_id().unwrap().as_str(), + format!("del_{ULID_A}") + ); + let wrong = mutate("delivery-ready.json", |o| { + o.insert("delivery_id".into(), json!(format!("dlg_{ULID_A}"))); + }); + assert!(decode_document(&wrong).is_err()); +} + +#[test] +fn delegation_uses_the_distinct_dlg_prefix() { + let value = json!({ + "schema_version": "psyche.delegation.v1", + "delegation_id": format!("dlg_{ULID_A}"), + "parent_node_id": format!("nod_{ULID_A}"), + "child_node_id": format!("nod_{ULID_B}"), + "scope_digest": format!("sha256:{}", "1".repeat(64)), + "budget_id": format!("bud_{ULID_A}"), + "evidence_scope_digest": format!("sha256:{}", "2".repeat(64)), + "cancellation_policy": "cascade" + }); + let document = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!( + document.persistable_record_id().unwrap().as_str(), + format!("dlg_{ULID_A}") + ); +} + +#[test] +fn execution_binding_uses_attempt_as_its_only_record_kind() { + let binding = valid_binding(CancellationState::NotRequested); + let document = CanonicalDocument::ExecutionBinding(binding); + document.validate().unwrap(); + assert_eq!( + document.persistable_record_id().unwrap().as_str(), + format!("att_{ULID_A}") + ); +} + +#[test] +fn surface_values_reject_scalars_oversize_unknown_fields_wrong_ids_and_bad_digest() { + for field in ["actor", "locator", "content"] { + let bytes = mutate("surface-event.json", |o| { + o.insert(field.into(), json!("scalar")); + }); + assert!(decode_document(&bytes).is_err(), "{field}"); + } + for (field, value) in [ + ("surface_event_id", format!("sfx_{ULID_A}")), + ("intent_id", format!("grf_{ULID_A}")), + ("graph_id", format!("int_{ULID_A}")), + ("node_id", format!("att_{ULID_A}")), + ("attempt_id", format!("nod_{ULID_A}")), + ("familiar_snapshot_id", format!("int_{ULID_A}")), + ] { + let name = if field == "surface_event_id" { + "surface-event.json" + } else { + "surface-effect.json" + }; + let bytes = mutate(name, |o| { + o.insert(field.into(), json!(value)); + }); + assert!(decode_document(&bytes).is_err(), "{field}"); + } + let scalar_effect = mutate("surface-effect.json", |o| { + o.insert("effect".into(), json!(false)); + }); + assert!(decode_document(&scalar_effect).is_err()); + let bad_digest = mutate("surface-effect.json", |o| { + o.insert( + "effect_digest".into(), + json!(format!("sha256:{}", "0".repeat(64))), + ); + }); + assert!(decode_document(&bad_digest).is_err()); + let unknown = mutate("surface-event.json", |o| { + o.insert("extra".into(), json!(1)); + }); + assert!(decode_document(&unknown).is_err()); + let oversized = mutate("surface-event.json", |o| { + o.insert("content".into(), json!({"text": "x".repeat(1_048_577)})); + }); + assert!(decode_document(&oversized).is_err()); +} + +#[test] +fn delivery_rejects_removed_fields_bad_enums_ids_effects_and_sent_without_message_id() { + for removed in ["surface_effect_id", "surface_decision_digest", "attempts"] { + let bytes = mutate("delivery-ready.json", |o| { + o.insert(removed.into(), json!("removed")); + }); + assert!(decode_document(&bytes).is_err(), "{removed}"); + } + for (field, bad) in [ + ("relationship", json!("same_chat")), + ("state", json!("done")), + ("delivery_id", json!(format!("dly_{ULID_A}"))), + ("intent_id", json!(format!("del_{ULID_B}"))), + ("chat_id", json!("12x")), + ] { + let bytes = mutate("delivery-ready.json", |o| { + o.insert(field.into(), bad); + }); + assert!(decode_document(&bytes).is_err(), "{field}"); + } + for effect in [ + json!("scalar"), + json!({}), + json!({"x": "y".repeat(1_048_577)}), + ] { + let bytes = mutate("delivery-ready.json", |o| { + o.insert("effect".into(), effect); + }); + assert!(decode_document(&bytes).is_err()); + } + let bad_digest = mutate("delivery-ready.json", |o| { + o.insert( + "effect_digest".into(), + json!(format!("sha256:{}", "f".repeat(64))), + ); + }); + assert!(decode_document(&bad_digest).is_err()); + let bad_expiry = mutate("delivery-ready.json", |o| { + o.get_mut("surface_decision") + .unwrap() + .as_object_mut() + .unwrap() + .insert("expires_at".into(), json!("tomorrow")); + }); + assert!(decode_document(&bad_expiry).is_err()); + let sent = mutate("delivery-ready.json", |o| { + o.insert("state".into(), json!("sent")); + }); + assert!(decode_document(&sent).is_err()); +} + +#[test] +fn all_canonical_error_codes_decode() { + let envelopes: Vec = serde_json::from_slice(&fixture("error-codes-v1.json")).unwrap(); + assert_eq!(envelopes.len(), ErrorCode::ALL.len()); + let mut codes = Vec::new(); + for (value, expected) in envelopes.iter().zip(ErrorCode::ALL) { + let code = value["error"]["code"].as_str().unwrap(); + codes.push(code); + assert_eq!(code, expected.as_str()); + let document = decode_document(&serde_json::to_vec(value).unwrap()).unwrap(); + assert!(matches!(document, CanonicalDocument::Error(_))); + assert_eq!(serde_json::to_value(document).unwrap(), *value); + } + let unique: std::collections::HashSet<_> = codes.iter().collect(); + assert_eq!(unique.len(), ErrorCode::ALL.len()); +} + +#[test] +fn error_envelope_is_strict_and_never_persistable() { + let envelope = json!({ + "schema_version": "psyche.error.v1", + "error": { + "code": "config_invalid", + "message": "bad config", + "retryable": false, + "correlation_id": "corr-1", + "details": {} + } + }); + let document = decode_document(&serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(document.persistable_record_id().is_none()); + for bad in [ + json!({"schema_version":"psyche.error.v1","error":{"code":"CONFIG_INVALID","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"config-invalid","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"future_error","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"","retryable":false,"correlation_id":"c","details":{}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"bad","retryable":false,"correlation_id":"c","details":{"x":1}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"bad","retryable":false,"correlation_id":"c","details":{},"extra":true}}), + ] { + let result = decode_document(&serde_json::to_vec(&bad).unwrap()); + if bad["error"]["code"] == "future_error" { + assert!(matches!( + result, + Err(ContractError::UnknownEnumValue { + schema: SchemaKind::Error, + field: "code" + }) + )); + } else { + assert!(result.is_err()); + } + } +} + +fn valid_binding(state: CancellationState) -> ExecutionBinding { + let request = RequestId::parse(&format!("req_{ULID_A}")).unwrap(); + let termination = RequestId::parse(&format!("req_{ULID_B}")).unwrap(); + let digest_value = format!("sha256:{}", "1".repeat(64)); + let mut binding = ExecutionBinding { + schema_version: "psyche.execution_binding.v1".parse().unwrap(), + attempt_id: RecordId::parse( + psyche_core::contracts::RecordKind::Attempt, + &format!("att_{ULID_A}"), + ) + .unwrap(), + revision: 1, + previous_revision_digest: None, + revision_created_at: "2026-08-01T00:00:00Z".into(), + familiar_snapshot_id: RecordId::parse( + psyche_core::contracts::RecordKind::IdentitySnapshot, + &format!("ids_{ULID_B}"), + ) + .unwrap(), + project_id: "project:one".into(), + request_id: request.clone(), + request_digest: digest_value.clone().try_into().unwrap(), + request_created_at: "2026-08-01T00:00:00Z".into(), + request_valid_until: "2026-08-01T00:10:00Z".into(), + coven_contract_version: "coven.execution.v1".into(), + coven_session_id: None, + adoption_state: AdoptionState::NotSubmitted, + event_cursor: None, + cancellation_state: state, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + }; + if state != CancellationState::NotRequested { + binding.coven_session_id = Some("session-1".into()); + binding.termination_request = Some(TerminationRequestCorrelation { + termination_request_id: termination.clone(), + created_at: "2026-08-01T00:01:00Z".into(), + valid_until: "2026-08-01T00:05:00Z".into(), + }); + binding.termination_reason_code = Some("operator_requested".into()); + } + if matches!( + state, + CancellationState::AcknowledgedTerminated | CancellationState::AcknowledgedAlreadyTerminal + ) { + binding.cancellation_acknowledgement = Some(CancellationAcknowledgementEvidence { + acknowledgement_id: "ack-1".into(), + termination_request_id: termination.clone(), + session_id: "session-1".into(), + execution_request_id: request.clone(), + execution_request_digest: digest_value.clone().try_into().unwrap(), + kind: if state == CancellationState::AcknowledgedTerminated { + CancellationAcknowledgementKind::Terminated + } else { + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal + }, + authority_evidence_digest: format!("sha256:{}", "2".repeat(64)).try_into().unwrap(), + acknowledged_at: "2026-08-01T00:02:00Z".into(), + }); + } + if state == CancellationState::TerminationUnknown { + binding.cancellation_unresolved = Some( + psyche_core::contracts::execution::CancellationUnresolvedEvidence { + disposition_id: "disp-1".into(), + termination_request_id: termination, + session_id: "session-1".into(), + execution_request_id: request, + execution_request_digest: digest_value.try_into().unwrap(), + reason_code: "authority_unreachable".into(), + recorded_at: "2026-08-01T00:03:00Z".into(), + }, + ); + } + binding +} + +#[test] +fn cancellation_state_vocabulary_requires_matching_o5_evidence() { + for state in [ + CancellationState::NotRequested, + CancellationState::TerminationRequested, + CancellationState::AcknowledgedTerminated, + CancellationState::AcknowledgedAlreadyTerminal, + CancellationState::TerminationUnknown, + ] { + let document = CanonicalDocument::ExecutionBinding(valid_binding(state)); + document.validate().unwrap(); + let bytes = serde_json::to_vec(&document).unwrap(); + let decoded = decode_document(&bytes).unwrap(); + decoded.validate().unwrap(); + } + + let unknown = serde_json::to_vec(&json!({ + "schema_version":"psyche.execution_binding.v1", + "cancellation_state":"cancelled" + })) + .unwrap(); + assert!(decode_document(&unknown).is_err()); + + let mut missing = valid_binding(CancellationState::AcknowledgedTerminated); + missing.cancellation_acknowledgement = None; + assert!(matches!( + CanonicalDocument::ExecutionBinding(missing).validate(), + Err(ContractError::CancellationEvidenceMismatch) + )); + + let mut mismatch = valid_binding(CancellationState::AcknowledgedTerminated); + mismatch + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = "other".into(); + assert!(matches!( + CanonicalDocument::ExecutionBinding(mismatch).validate(), + Err(ContractError::CancellationEvidenceMismatch) + )); + + let mut raw_ledger = valid_binding(CancellationState::AcknowledgedTerminated); + raw_ledger.cancellation_acknowledgement = None; + raw_ledger.terminal_state = Some("terminated".into()); + assert!(matches!( + CanonicalDocument::ExecutionBinding(raw_ledger).validate(), + Err(ContractError::CancellationEvidenceMismatch) + )); +} + +#[test] +fn strict_probe_and_document_limit_fail_closed() { + assert!(matches!( + decode_document(br#"{"schema_version":"psyche.unknown.v1"}"#), + Err(ContractError::UnknownSchema { .. }) + )); + assert!(decode_document(&vec![b' '; 1_048_577]).is_err()); +} + +#[test] +fn directly_constructed_values_are_revalidated() { + let mut binding = valid_binding(CancellationState::NotRequested); + binding.revision = 0; + assert!( + CanonicalDocument::ExecutionBinding(binding) + .validate() + .is_err() + ); +} + +#[test] +fn typed_deserialization_cannot_bypass_validation() { + let wrong_id = mutate("intent-local.json", |object| { + object.insert("intent_id".into(), json!(format!("grf_{ULID_A}"))); + }); + assert!(serde_json::from_slice::(&wrong_id).is_err()); + + let mismatched_digest = mutate("surface-effect.json", |object| { + object.insert( + "effect_digest".into(), + json!(format!("sha256:{}", "0".repeat(64))), + ); + }); + assert!( + serde_json::from_slice::( + &mismatched_digest + ) + .is_err() + ); +} diff --git a/crates/psyche-core/tests/fixtures/delivery-ready.json b/crates/psyche-core/tests/fixtures/delivery-ready.json new file mode 100644 index 0000000..f4bfcee --- /dev/null +++ b/crates/psyche-core/tests/fixtures/delivery-ready.json @@ -0,0 +1,30 @@ +{ + "schema_version": "psyche.delivery.v1", + "delivery_id": "del_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "intent_id": "int_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "action_class": "telegram.reply.send", + "account_id": "main", + "chat_id": "-1001234567890", + "topic": {"kind": "forum", "id": "42"}, + "relationship": "reply_same_topic", + "effect": { + "schema_version": "psyche.telegram_effect.v1", + "type": "send_message", + "format": "html", + "text": "Review complete.", + "buttons": [] + }, + "effect_digest": "sha256:81fe163b620a8bedc0f7aa98ec44f1c04376ca649cf97e1161a6b7d2924cbeb2", + "surface_decision": { + "decision_id": "decision_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "request_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "policy_revision": "policy:sha256:0123456789abcdef", + "expires_at": "2026-08-01T00:05:00Z", + "state": "reserved" + }, + "logical_response_id": "response_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "logical_part": 0, + "state": "ready", + "attempt_count": 0, + "telegram_message_id": null +} diff --git a/crates/psyche-core/tests/fixtures/error-codes-v1.json b/crates/psyche-core/tests/fixtures/error-codes-v1.json new file mode 100644 index 0000000..a2c5fe5 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/error-codes-v1.json @@ -0,0 +1,434 @@ +[ + { + "schema_version": "psyche.error.v1", + "error": { + "code": "config_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-1", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "secret_unavailable", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-2", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "telegram_unauthorized", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-3", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "telegram_bot_identity_mismatch", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-4", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "telegram_conflict", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-5", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "telegram_rate_limited", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-6", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "telegram_unavailable", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-7", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "webhook_auth_failed", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-8", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_unavailable", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-9", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "event_schema_unsupported", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-10", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "principal_mapping_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-11", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "graph_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-12", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "delegation_widened", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-13", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "budget_unenforceable", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-14", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "evidence_incomplete", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-15", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "verdict_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-16", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "route_not_found", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-17", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "route_ambiguous", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-18", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "sender_unauthorized", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-19", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "identity_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-20", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "identity_changed", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-21", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_unavailable", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-22", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_version_unsupported", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-23", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_capability_missing", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-24", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_policy_denied", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-25", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_execution_binding_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-26", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_binding_mismatch", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-27", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_artifact_rejected", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-28", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_intent_conflict", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-29", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_adoption_unknown", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-30", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_cancellation_unknown", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-31", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "coven_session_failed", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-32", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "delivery_unknown", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-33", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "preview_finalize_blocked", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-34", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "media_rejected", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-35", + "details": { + "scope": "public" + } + } + }, + { + "schema_version": "psyche.error.v1", + "error": { + "code": "callback_invalid", + "message": "redacted public message", + "retryable": false, + "correlation_id": "fixture-36", + "details": { + "scope": "public" + } + } + } +] diff --git a/crates/psyche-core/tests/fixtures/intent-local.json b/crates/psyche-core/tests/fixtures/intent-local.json new file mode 100644 index 0000000..e7d1c74 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/intent-local.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.intent.v1", + "intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "principal_id": "principal:val", + "familiar_snapshot_id": "ids_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "project_id": "project:sha256:0123456789abcdef", + "requested_outcome": "Review and verify the scoped change.", + "constraints": {}, + "required_evidence": ["tests", "diff_review"], + "surface_event_id": null, + "created_at": "2026-08-01T00:00:00Z", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/crates/psyche-core/tests/fixtures/node-root.json b/crates/psyche-core/tests/fixtures/node-root.json new file mode 100644 index 0000000..8fe76b8 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/node-root.json @@ -0,0 +1,12 @@ +{ + "schema_version": "psyche.graph_node.v1", + "node_id": "nod_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "graph_id": "grf_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "familiar_snapshot_id": "ids_01C3F7YQ4R2M8N6P5K1J9H0GTS", + "dependencies": [], + "delegation_id": null, + "budget_id": "bud_01D4G8ZR5S3N9P7Q6M2K0J1HTV", + "required_evidence": ["tests", "diff_review"], + "state": "ready", + "version": 1 +} diff --git a/crates/psyche-core/tests/fixtures/surface-effect.json b/crates/psyche-core/tests/fixtures/surface-effect.json new file mode 100644 index 0000000..da430da --- /dev/null +++ b/crates/psyche-core/tests/fixtures/surface-effect.json @@ -0,0 +1,16 @@ +{ + "schema_version": "psyche.surface_effect.v1", + "surface_effect_id": "sfx_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "intent_id": "int_01BX5ZZKBKACTAV9WEVGEMMVRZ", + "graph_id": "grf_01C3F7YQ4R2M8N6P5K1J9H0GTS", + "node_id": "nod_01D4G8ZR5S3N9P7Q6M2K0J1HTV", + "attempt_id": "att_01E5H90S6T4P0Q8R7N3M1K2JVW", + "familiar_snapshot_id": "ids_01F6JA1T7V5Q1R9S8P4N2M3KWX", + "project_id": "project:sha256:0123456789abcdef", + "action_class": "telegram.reply.send", + "account_id": "main", + "locator": {"chat_id": "-100123", "message_id": "42"}, + "effect": {"type": "message", "text": "Review complete."}, + "effect_digest": "sha256:c16ac6fce1ddecbddc6bf0b51544975741901289ec2248ddeffe6951fc8be995", + "created_at": "2026-08-01T00:01:00Z" +} diff --git a/crates/psyche-core/tests/fixtures/surface-event.json b/crates/psyche-core/tests/fixtures/surface-event.json new file mode 100644 index 0000000..87afa51 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/surface-event.json @@ -0,0 +1,11 @@ +{ + "schema_version": "psyche.surface_event.v1", + "surface_event_id": "sev_01ARZ3NDEKTSV4RRFFQ69G5FAV", + "adapter_id": "telegram", + "account_id": "main", + "actor": {"type": "user", "id": "123"}, + "locator": {"type": "message", "chat_id": "-100123", "message_id": "42"}, + "adapter_event_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "received_at": "2026-08-01T00:00:00Z", + "content": {"type": "text", "text": "Please review this."} +} From b7ad03805e20e01b9e4eee5f69dc5011ceac7f94 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:12:36 -0500 Subject: [PATCH 04/66] fix(core): close v1 record validation gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/error.rs | 37 +- crates/psyche-core/src/contracts/execution.rs | 164 +++-- .../psyche-core/src/contracts/foundation.rs | 14 +- crates/psyche-core/src/contracts/identity.rs | 6 +- crates/psyche-core/src/contracts/intent.rs | 12 +- crates/psyche-core/src/contracts/mod.rs | 21 +- crates/psyche-core/src/contracts/surface.rs | 20 +- crates/psyche-core/tests/contracts.rs | 686 ++++++++++++++++-- .../tests/fixtures/delivery-ready.json | 6 +- 9 files changed, 790 insertions(+), 176 deletions(-) diff --git a/crates/psyche-core/src/contracts/error.rs b/crates/psyche-core/src/contracts/error.rs index 8b653ff..c7732fc 100644 --- a/crates/psyche-core/src/contracts/error.rs +++ b/crates/psyche-core/src/contracts/error.rs @@ -3,13 +3,24 @@ use std::collections::BTreeMap; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; use crate::contracts::{ ContractError, SchemaKind, SchemaVersion, bounded, invalid, require_schema, }; +/// Maximum UTF-8 bytes in a public error message. +pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096; +/// Maximum UTF-8 bytes in a public error correlation identifier. +pub const MAX_ERROR_CORRELATION_ID_BYTES: usize = 255; +/// Maximum public classification entries in an error details map. +pub const MAX_ERROR_DETAILS_ENTRIES: usize = 128; +/// Maximum UTF-8 bytes in a nonempty error details key. +pub const MAX_ERROR_DETAIL_KEY_BYTES: usize = 256; +/// Maximum UTF-8 bytes in a nonempty error details value. +pub const MAX_ERROR_DETAIL_VALUE_BYTES: usize = 4096; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ErrorCode { ConfigInvalid, @@ -142,6 +153,13 @@ impl Serialize for ErrorCode { } } +impl<'de> Deserialize<'de> for ErrorCode { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(&value).ok_or_else(|| serde::de::Error::custom("unknown error code")) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ErrorBody { pub code: ErrorCode, @@ -199,16 +217,19 @@ impl ErrorEnvelope { pub fn validate(&self) -> Result<(), ContractError> { let s = SchemaKind::Error; require_schema(self.schema_version, s)?; - bounded(&self.error.message, 4096, s, "message")?; - bounded(&self.error.correlation_id, 255, s, "correlation_id")?; - if self.error.details.len() > 128 { + bounded(&self.error.message, MAX_ERROR_MESSAGE_BYTES, s, "message")?; + bounded( + &self.error.correlation_id, + MAX_ERROR_CORRELATION_ID_BYTES, + s, + "correlation_id", + )?; + if self.error.details.len() > MAX_ERROR_DETAILS_ENTRIES { return Err(invalid(s, "details")); } for (key, value) in &self.error.details { - bounded(key, 256, s, "details.key")?; - if value.len() > 4096 { - return Err(invalid(s, "details.value")); - } + bounded(key, MAX_ERROR_DETAIL_KEY_BYTES, s, "details.key")?; + bounded(value, MAX_ERROR_DETAIL_VALUE_BYTES, s, "details.value")?; } Ok(()) } diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs index 2c688b2..1cdccc3 100644 --- a/crates/psyche-core/src/contracts/execution.rs +++ b/crates/psyche-core/src/contracts/execution.rs @@ -2,10 +2,11 @@ #![allow(missing_docs)] use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, - optional_bounded, reason_code, require_id, require_schema, timestamp, + optional_bounded, reason_code, require_id, require_schema, }; use crate::digest::Sha256Digest; use crate::id::{RecordId, RequestId}; @@ -47,7 +48,8 @@ pub struct CancellationAcknowledgementEvidence { pub execution_request_digest: Sha256Digest, pub kind: CancellationAcknowledgementKind, pub authority_evidence_digest: Sha256Digest, - pub acknowledged_at: String, + #[serde(with = "time::serde::rfc3339")] + pub acknowledged_at: time::OffsetDateTime, } #[derive(Deserialize)] @@ -60,7 +62,8 @@ struct AcknowledgementWire { execution_request_digest: Sha256Digest, kind: CancellationAcknowledgementKind, authority_evidence_digest: Sha256Digest, - acknowledged_at: String, + #[serde(with = "time::serde::rfc3339")] + acknowledged_at: time::OffsetDateTime, } impl TryFrom for CancellationAcknowledgementEvidence { @@ -94,7 +97,6 @@ impl CancellationAcknowledgementEvidence { let s = SchemaKind::ExecutionBinding; bounded(&self.acknowledgement_id, 255, s, "acknowledgement_id")?; bounded(&self.session_id, 255, s, "session_id")?; - timestamp(&self.acknowledged_at, s, "acknowledged_at")?; Ok(()) } } @@ -107,7 +109,8 @@ pub struct CancellationUnresolvedEvidence { pub execution_request_id: RequestId, pub execution_request_digest: Sha256Digest, pub reason_code: String, - pub recorded_at: String, + #[serde(with = "time::serde::rfc3339")] + pub recorded_at: time::OffsetDateTime, } #[derive(Deserialize)] @@ -119,7 +122,8 @@ struct UnresolvedWire { execution_request_id: RequestId, execution_request_digest: Sha256Digest, reason_code: String, - recorded_at: String, + #[serde(with = "time::serde::rfc3339")] + recorded_at: time::OffsetDateTime, } impl TryFrom for CancellationUnresolvedEvidence { @@ -153,7 +157,6 @@ impl CancellationUnresolvedEvidence { bounded(&self.disposition_id, 255, s, "disposition_id")?; bounded(&self.session_id, 255, s, "session_id")?; reason_code(&self.reason_code, s, "reason_code")?; - timestamp(&self.recorded_at, s, "recorded_at")?; Ok(()) } } @@ -161,16 +164,20 @@ impl CancellationUnresolvedEvidence { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TerminationRequestCorrelation { pub termination_request_id: RequestId, - pub created_at: String, - pub valid_until: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub valid_until: time::OffsetDateTime, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TerminationWire { termination_request_id: RequestId, - created_at: String, - valid_until: String, + #[serde(with = "time::serde::rfc3339")] + created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + valid_until: time::OffsetDateTime, } impl TryFrom for TerminationRequestCorrelation { @@ -197,9 +204,7 @@ impl<'de> Deserialize<'de> for TerminationRequestCorrelation { impl TerminationRequestCorrelation { pub fn validate(&self) -> Result<(), ContractError> { let s = SchemaKind::ExecutionBinding; - let created = timestamp(&self.created_at, s, "termination_request.created_at")?; - let until = timestamp(&self.valid_until, s, "termination_request.valid_until")?; - if until <= created { + if self.valid_until <= self.created_at { return Err(super::invalid(s, "termination_request.valid_until")); } Ok(()) @@ -212,13 +217,16 @@ pub struct ExecutionBinding { pub attempt_id: RecordId, pub revision: u64, pub previous_revision_digest: Option, - pub revision_created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub revision_created_at: time::OffsetDateTime, pub familiar_snapshot_id: RecordId, pub project_id: String, pub request_id: RequestId, pub request_digest: Sha256Digest, - pub request_created_at: String, - pub request_valid_until: String, + #[serde(with = "time::serde::rfc3339")] + pub request_created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub request_valid_until: time::OffsetDateTime, pub coven_contract_version: String, pub coven_session_id: Option, pub adoption_state: AdoptionState, @@ -238,28 +246,41 @@ struct ExecutionWire { attempt_id: RecordId, revision: u64, previous_revision_digest: Option, - revision_created_at: String, + #[serde(with = "time::serde::rfc3339")] + revision_created_at: time::OffsetDateTime, familiar_snapshot_id: RecordId, project_id: String, request_id: RequestId, request_digest: Sha256Digest, - request_created_at: String, - request_valid_until: String, + #[serde(with = "time::serde::rfc3339")] + request_created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + request_valid_until: time::OffsetDateTime, coven_contract_version: String, coven_session_id: Option, adoption_state: AdoptionState, event_cursor: Option, cancellation_state: CancellationState, - termination_request: Option, - termination_reason_code: Option, - cancellation_acknowledgement: Option, - cancellation_unresolved: Option, + termination_request: Option, + termination_reason_code: Option, + cancellation_acknowledgement: Option, + cancellation_unresolved: Option, terminal_state: Option, } -impl From for ExecutionBinding { - fn from(w: ExecutionWire) -> Self { - Self { +impl TryFrom for ExecutionBinding { + type Error = ContractError; + + fn try_from(w: ExecutionWire) -> Result { + let termination_request = cancellation_value(w.termination_request)?; + let termination_reason_code = match w.termination_reason_code { + Some(Value::String(reason)) => Some(reason), + Some(_) => return Err(ContractError::CancellationEvidenceMismatch), + None => None, + }; + let cancellation_acknowledgement = cancellation_value(w.cancellation_acknowledgement)?; + let cancellation_unresolved = cancellation_value(w.cancellation_unresolved)?; + let value = Self { schema_version: w.schema_version, attempt_id: w.attempt_id, revision: w.revision, @@ -276,24 +297,32 @@ impl From for ExecutionBinding { adoption_state: w.adoption_state, event_cursor: w.event_cursor, cancellation_state: w.cancellation_state, - termination_request: w.termination_request, - termination_reason_code: w.termination_reason_code, - cancellation_acknowledgement: w.cancellation_acknowledgement, - cancellation_unresolved: w.cancellation_unresolved, + termination_request, + termination_reason_code, + cancellation_acknowledgement, + cancellation_unresolved, terminal_state: w.terminal_state, - } + }; + value.validate()?; + Ok(value) } } impl<'de> Deserialize<'de> for ExecutionBinding { fn deserialize>(deserializer: D) -> Result { - let value = Self::from(ExecutionWire::deserialize(deserializer)?); - value.validate().map_err(serde::de::Error::custom)?; - Ok(value) + ExecutionWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) } } impl ExecutionBinding { + pub(crate) fn decode(value: Value) -> Result { + let wire: ExecutionWire = serde_json::from_value(value) + .map_err(|_| super::invalid(SchemaKind::ExecutionBinding, "document"))?; + wire.try_into() + } + pub fn validate(&self) -> Result<(), ContractError> { let s = SchemaKind::ExecutionBinding; require_schema(self.schema_version, s)?; @@ -307,7 +336,6 @@ impl ExecutionBinding { if self.revision == 0 || (self.revision == 1) != self.previous_revision_digest.is_none() { return Err(super::invalid(s, "revision")); } - timestamp(&self.revision_created_at, s, "revision_created_at")?; bounded(&self.project_id, 255, s, "project_id")?; bounded( &self.coven_contract_version, @@ -315,15 +343,19 @@ impl ExecutionBinding { s, "coven_contract_version", )?; - optional_bounded(&self.coven_session_id, 255, s, "coven_session_id")?; + if let Err(error) = optional_bounded(&self.coven_session_id, 255, s, "coven_session_id") { + return if self.cancellation_state == CancellationState::NotRequested { + Err(error) + } else { + Err(ContractError::CancellationEvidenceMismatch) + }; + } optional_bounded(&self.event_cursor, 255, s, "event_cursor")?; optional_bounded(&self.terminal_state, 255, s, "terminal_state")?; - let request_created = timestamp(&self.request_created_at, s, "request_created_at")?; - let request_until = timestamp(&self.request_valid_until, s, "request_valid_until")?; - if request_until <= request_created { + if self.request_valid_until <= self.request_created_at { return Err(super::invalid(s, "request_valid_until")); } - self.validate_cancellation(request_created) + self.validate_cancellation(self.request_created_at) } fn validate_cancellation( @@ -345,26 +377,18 @@ impl ExecutionBinding { .termination_request .as_ref() .ok_or(ContractError::CancellationEvidenceMismatch)?; - correlation.validate()?; + cancellation_result(correlation.validate())?; let reason = self .termination_reason_code .as_deref() .ok_or(ContractError::CancellationEvidenceMismatch)?; - reason_code( + cancellation_result(reason_code( reason, SchemaKind::ExecutionBinding, "termination_reason_code", - )?; - let created = timestamp( - &correlation.created_at, - SchemaKind::ExecutionBinding, - "termination_request.created_at", - )?; - let valid_until = timestamp( - &correlation.valid_until, - SchemaKind::ExecutionBinding, - "termination_request.valid_until", - )?; + ))?; + let created = correlation.created_at; + let valid_until = correlation.valid_until; if created < request_created || correlation.termination_request_id == self.request_id { return Err(ContractError::CancellationEvidenceMismatch); } @@ -396,19 +420,14 @@ impl ExecutionBinding { .cancellation_unresolved .as_ref() .ok_or(ContractError::CancellationEvidenceMismatch)?; - evidence.validate()?; + cancellation_result(evidence.validate())?; self.validate_evidence_bindings( &evidence.termination_request_id, &evidence.session_id, &evidence.execution_request_id, &evidence.execution_request_digest, )?; - let at = timestamp( - &evidence.recorded_at, - SchemaKind::ExecutionBinding, - "recorded_at", - )?; - in_window(at, created, valid_until) + in_window(evidence.recorded_at, created, valid_until) } CancellationState::NotRequested => unreachable!(), } @@ -427,7 +446,7 @@ impl ExecutionBinding { .cancellation_acknowledgement .as_ref() .ok_or(ContractError::CancellationEvidenceMismatch)?; - evidence.validate()?; + cancellation_result(evidence.validate())?; if evidence.kind != kind { return Err(ContractError::CancellationEvidenceMismatch); } @@ -437,12 +456,7 @@ impl ExecutionBinding { &evidence.execution_request_id, &evidence.execution_request_digest, )?; - let at = timestamp( - &evidence.acknowledged_at, - SchemaKind::ExecutionBinding, - "acknowledged_at", - )?; - in_window(at, created, valid_until) + in_window(evidence.acknowledged_at, created, valid_until) } fn validate_evidence_bindings( @@ -468,6 +482,20 @@ impl ExecutionBinding { } } +fn cancellation_value( + value: Option, +) -> Result, ContractError> { + value + .map(|value| { + serde_json::from_value(value).map_err(|_| ContractError::CancellationEvidenceMismatch) + }) + .transpose() +} + +fn cancellation_result(result: Result) -> Result { + result.map_err(|_| ContractError::CancellationEvidenceMismatch) +} + fn in_window( at: time::OffsetDateTime, created: time::OffsetDateTime, diff --git a/crates/psyche-core/src/contracts/foundation.rs b/crates/psyche-core/src/contracts/foundation.rs index 7e69294..f769c7b 100644 --- a/crates/psyche-core/src/contracts/foundation.rs +++ b/crates/psyche-core/src/contracts/foundation.rs @@ -3,7 +3,7 @@ use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, - optional_bounded, require_id, require_schema, string_list, timestamp, + optional_bounded, require_id, require_schema, string_list, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -93,7 +93,8 @@ validated_struct! { pub node_id: RecordId, pub requester_principal_id: String, pub decision: Option, - pub expires_at: String, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: time::OffsetDateTime, } } @@ -110,7 +111,6 @@ impl Approval { "requester_principal_id", )?; optional_bounded(&self.decision, 256, s, "decision")?; - timestamp(&self.expires_at, s, "expires_at")?; Ok(()) } } @@ -127,7 +127,8 @@ validated_struct! { pub collection_method: String, pub media_type: String, pub size: u64, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, pub retention_policy: String, } } @@ -147,7 +148,6 @@ impl Evidence { ] { bounded(value, 256, s, field)?; } - timestamp(&self.created_at, s, "created_at")?; Ok(()) } } @@ -164,7 +164,8 @@ validated_struct! { pub reviewer_id: String, pub outcome: String, pub reason_codes: Vec, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, } } @@ -183,7 +184,6 @@ impl Verdict { bounded(value, 256, s, field)?; } string_list(&self.reason_codes, s, "reason_codes")?; - timestamp(&self.created_at, s, "created_at")?; Ok(()) } } diff --git a/crates/psyche-core/src/contracts/identity.rs b/crates/psyche-core/src/contracts/identity.rs index bc29ba0..e946cc6 100644 --- a/crates/psyche-core/src/contracts/identity.rs +++ b/crates/psyche-core/src/contracts/identity.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, require_id, - require_schema, timestamp, + require_schema, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -30,7 +30,8 @@ validated_struct! { pub soul_digest: Sha256Digest, pub role_skill_digest: Sha256Digest, pub provenance: IdentityProvenance, - pub resolved_at: String, + #[serde(with = "time::serde::rfc3339")] + pub resolved_at: time::OffsetDateTime, } } @@ -61,7 +62,6 @@ impl IdentitySnapshot { schema, "provenance.resolver_version", )?; - timestamp(&self.resolved_at, schema, "resolved_at")?; Ok(()) } } diff --git a/crates/psyche-core/src/contracts/intent.rs b/crates/psyche-core/src/contracts/intent.rs index 2997736..1b2a219 100644 --- a/crates/psyche-core/src/contracts/intent.rs +++ b/crates/psyche-core/src/contracts/intent.rs @@ -1,13 +1,11 @@ //! Intent contract. #![allow(missing_docs)] -use std::collections::BTreeMap; - -use serde_json::Value; +use serde_json::{Map, Value}; use crate::contracts::{ ContractError, MAX_DOCUMENT_BYTES, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, - bounded, require_id, require_schema, string_list, timestamp, + bounded, require_id, require_schema, string_list, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -20,10 +18,11 @@ validated_struct! { pub familiar_snapshot_id: RecordId, pub project_id: String, pub requested_outcome: String, - pub constraints: BTreeMap, + pub constraints: Map, pub required_evidence: Vec, pub surface_event_id: Option, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, pub digest: Sha256Digest, } } @@ -52,7 +51,6 @@ impl Intent { if crate::digest::canonical_bytes(&self.constraints)?.len() > MAX_DOCUMENT_BYTES { return Err(super::invalid(schema, "constraints")); } - timestamp(&self.created_at, schema, "created_at")?; Ok(()) } } diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 81744f2..2888371 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -17,18 +17,18 @@ use crate::id::RecordId; macro_rules! validated_struct { ( pub struct $name:ident, $wire:ident { - $(pub $field:ident: $ty:ty),+ $(,)? + $($(#[$field_meta:meta])* pub $field:ident: $ty:ty),+ $(,)? } ) => { #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct $name { - $(pub $field: $ty),+ + $($(#[$field_meta])* pub $field: $ty),+ } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct $wire { - $($field: $ty),+ + $($(#[$field_meta])* $field: $ty),+ } impl<'de> serde::Deserialize<'de> for $name { @@ -509,7 +509,11 @@ impl CanonicalDocument { Self::SurfaceEffect(v) => v.validate(), Self::Delivery(v) => v.validate(), Self::Error(v) => v.validate(), + }?; + if crate::digest::canonical_bytes(self)?.len() > MAX_DOCUMENT_BYTES { + return Err(ContractError::DocumentTooLarge); } + Ok(()) } /// Declared schema version. @@ -582,7 +586,7 @@ pub fn decode_document(bytes: &[u8]) -> Result SchemaKind::Budget => decode(value, CanonicalDocument::Budget, schema.kind)?, SchemaKind::Approval => decode(value, CanonicalDocument::Approval, schema.kind)?, SchemaKind::ExecutionBinding => { - decode(value, CanonicalDocument::ExecutionBinding, schema.kind)? + CanonicalDocument::ExecutionBinding(ExecutionBinding::decode(value)?) } SchemaKind::Evidence => decode(value, CanonicalDocument::Evidence, schema.kind)?, SchemaKind::Verdict => decode(value, CanonicalDocument::Verdict, schema.kind)?, @@ -670,15 +674,6 @@ pub(crate) fn string_list( .try_for_each(|v| bounded(v, 256, schema, field)) } -pub(crate) fn timestamp( - value: &str, - schema: SchemaKind, - field: &'static str, -) -> Result { - time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) - .map_err(|_| invalid(schema, field)) -} - pub(crate) fn object( value: &Value, schema: SchemaKind, diff --git a/crates/psyche-core/src/contracts/surface.rs b/crates/psyche-core/src/contracts/surface.rs index fff336b..3e6d70c 100644 --- a/crates/psyche-core/src/contracts/surface.rs +++ b/crates/psyche-core/src/contracts/surface.rs @@ -6,7 +6,7 @@ use serde_json::Value; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, object, - require_id, require_schema, timestamp, + require_id, require_schema, }; use crate::digest::{Sha256Digest, digest}; use crate::id::RecordId; @@ -20,7 +20,8 @@ validated_struct! { pub actor: Value, pub locator: Value, pub adapter_event_digest: Sha256Digest, - pub received_at: String, + #[serde(with = "time::serde::rfc3339")] + pub received_at: time::OffsetDateTime, pub content: Value, } } @@ -40,7 +41,6 @@ impl SurfaceEvent { object(&self.actor, s, "actor", false)?; object(&self.locator, s, "locator", false)?; object(&self.content, s, "content", false)?; - timestamp(&self.received_at, s, "received_at")?; Ok(()) } } @@ -69,7 +69,8 @@ validated_struct! { pub locator: Value, pub effect: Value, pub effect_digest: Sha256Digest, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, } } @@ -107,7 +108,6 @@ impl SurfaceEffect { if digest(&self.effect)? != self.effect_digest { return Err(super::invalid(s, "effect_digest")); } - timestamp(&self.created_at, s, "created_at")?; Ok(()) } } @@ -151,7 +151,8 @@ pub struct DeliverySurfaceDecision { pub decision_id: String, pub request_digest: Sha256Digest, pub policy_revision: String, - pub expires_at: String, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: time::OffsetDateTime, pub state: DeliveryDecisionState, } @@ -222,12 +223,7 @@ impl Delivery { if digest(&self.effect)? != self.effect_digest { return Err(super::invalid(s, "effect_digest")); } - timestamp( - &self.surface_decision.expires_at, - s, - "surface_decision.expires_at", - )?; - if self.state == DeliveryState::Sent && self.telegram_message_id.is_none() { + if (self.state == DeliveryState::Sent) != self.telegram_message_id.is_some() { return Err(super::invalid(s, "telegram_message_id")); } Ok(()) diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index fc30ae3..6f80933 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -4,15 +4,24 @@ use psyche_core::contracts::error::ErrorCode; use psyche_core::contracts::execution::{ AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, - CancellationState, ExecutionBinding, TerminationRequestCorrelation, + CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, + TerminationRequestCorrelation, +}; +use psyche_core::contracts::foundation::{Approval, Evidence, Verdict}; +use psyche_core::contracts::identity::IdentitySnapshot; +use psyche_core::contracts::intent::Intent; +use psyche_core::contracts::surface::{ + Delivery, DeliverySurfaceDecision, SurfaceEffect, SurfaceEvent, }; use psyche_core::contracts::{CanonicalDocument, ContractError, SchemaKind, decode_document}; use psyche_core::digest::canonical_bytes; use psyche_core::id::{RecordId, RequestId}; use serde_json::{Value, json}; +use time::OffsetDateTime; const ULID_A: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const ULID_B: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; +const ULID_C: &str = "01C3F7YQ4R2M8N6P5K1J9H0GTS"; fn fixture(name: &str) -> Vec { std::fs::read(format!( @@ -32,6 +41,147 @@ fn mutate(name: &str, f: impl FnOnce(&mut serde_json::Map)) -> Ve serde_json::to_vec(&value).unwrap() } +fn timestamp(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap() +} + +#[test] +fn canonical_timestamp_fields_are_typed_and_serialize_as_rfc3339_strings() { + let assert_types = |identity: IdentitySnapshot, + intent: Intent, + binding: ExecutionBinding, + acknowledgement: CancellationAcknowledgementEvidence, + unresolved: CancellationUnresolvedEvidence, + correlation: TerminationRequestCorrelation, + approval: Approval, + evidence: Evidence, + verdict: Verdict, + event: SurfaceEvent, + effect: SurfaceEffect, + delivery: Delivery, + decision: DeliverySurfaceDecision| { + let _: OffsetDateTime = identity.resolved_at; + let _: OffsetDateTime = intent.created_at; + let _: serde_json::Map = intent.constraints; + let _: OffsetDateTime = binding.revision_created_at; + let _: OffsetDateTime = binding.request_created_at; + let _: OffsetDateTime = binding.request_valid_until; + let _: OffsetDateTime = acknowledgement.acknowledged_at; + let _: OffsetDateTime = unresolved.recorded_at; + let _: OffsetDateTime = correlation.created_at; + let _: OffsetDateTime = correlation.valid_until; + let _: OffsetDateTime = approval.expires_at; + let _: OffsetDateTime = evidence.created_at; + let _: OffsetDateTime = verdict.created_at; + let _: OffsetDateTime = event.received_at; + let _: OffsetDateTime = effect.created_at; + let _: OffsetDateTime = delivery.surface_decision.expires_at; + let _: OffsetDateTime = decision.expires_at; + }; + let _ = assert_types; + + let binding = valid_binding(CancellationState::AcknowledgedTerminated); + let value = serde_json::to_value(binding).unwrap(); + assert_eq!(value["revision_created_at"], "2026-08-01T00:00:00Z"); + assert_eq!( + value["termination_request"]["created_at"], + "2026-08-01T00:01:00Z" + ); + assert_eq!( + value["cancellation_acknowledgement"]["acknowledged_at"], + "2026-08-01T00:02:00Z" + ); +} + +#[test] +fn directly_constructed_typed_timestamps_still_enforce_cross_field_windows() { + let mut binding = valid_binding(CancellationState::NotRequested); + binding.request_valid_until = binding.request_created_at; + assert!(matches!( + CanonicalDocument::ExecutionBinding(binding).validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::ExecutionBinding, + field: "request_valid_until" + }) + )); +} + +#[test] +fn foundation_timestamp_fields_parse_and_serialize_as_rfc3339_strings() { + let digest = format!("sha256:{}", "a".repeat(64)); + let documents = [ + ( + json!({ + "schema_version": "psyche.identity_snapshot.v1", + "snapshot_id": format!("ids_{ULID_A}"), + "familiar_id": "familiar:one", + "principal_id": "principal:one", + "revision": 1, + "declaration_digest": digest, + "identity_file_digest": digest, + "identity_digest": digest, + "soul_digest": digest, + "role_skill_digest": digest, + "provenance": { + "familiar_home_id": "home:one", + "resolver_version": "1" + }, + "resolved_at": "2026-08-01T00:00:00Z" + }), + "resolved_at", + ), + ( + json!({ + "schema_version": "psyche.approval.v1", + "approval_id": format!("apr_{ULID_A}"), + "node_id": format!("nod_{ULID_A}"), + "requester_principal_id": "principal:one", + "decision": null, + "expires_at": "2026-08-01T00:00:00Z" + }), + "expires_at", + ), + ( + json!({ + "schema_version": "psyche.evidence.v1", + "evidence_id": format!("evd_{ULID_A}"), + "node_id": format!("nod_{ULID_A}"), + "attempt_id": format!("att_{ULID_A}"), + "content_digest": digest, + "producer": "test", + "collection_method": "test", + "media_type": "text/plain", + "size": 1, + "created_at": "2026-08-01T00:00:00Z", + "retention_policy": "default" + }), + "created_at", + ), + ( + json!({ + "schema_version": "psyche.verdict.v1", + "verdict_id": format!("vrd_{ULID_A}"), + "node_id": format!("nod_{ULID_A}"), + "sealed_evidence_digest": digest, + "policy_revision": "policy:one", + "verdict_type": "review", + "reviewer_id": "reviewer:one", + "outcome": "allow", + "reason_codes": ["verified"], + "created_at": "2026-08-01T00:00:00Z" + }), + "created_at", + ), + ]; + for (value, field) in documents { + let document = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(document).unwrap()[field], + "2026-08-01T00:00:00Z" + ); + } +} + #[test] fn intent_rejects_unknown_fields() { let bytes = mutate("intent-local.json", |object| { @@ -45,26 +195,50 @@ fn intent_rejects_unknown_fields() { #[test] fn graph_and_node_accept_only_the_two_frozen_nullable_bindings() { - let intent = decode("intent-local.json"); - let node = decode("node-root.json"); - assert!(matches!(intent, CanonicalDocument::Intent(_))); - assert!(matches!(node, CanonicalDocument::GraphNode(_))); - - let missing_required = mutate("intent-local.json", |object| { - object.insert("principal_id".into(), Value::Null); - }); - assert!(matches!( - decode_document(&missing_required), - Err(ContractError::InvalidShape { .. }) - )); + for (fixture_name, nullable) in [ + ("intent-local.json", "surface_event_id"), + ("node-root.json", "delegation_id"), + ] { + let value: Value = serde_json::from_slice(&fixture(fixture_name)).unwrap(); + let object = value.as_object().unwrap(); + let null_fields: Vec<_> = object + .iter() + .filter_map(|(key, value)| value.is_null().then_some(key.as_str())) + .collect(); + assert_eq!(null_fields, [nullable], "{fixture_name}"); + assert!(decode_document(&fixture(fixture_name)).is_ok()); + } - let missing_node_binding = mutate("node-root.json", |object| { - object.insert("budget_id".into(), Value::Null); - }); - assert!(matches!( - decode_document(&missing_node_binding), - Err(ContractError::InvalidShape { .. }) - )); + for (fixture_name, required_ids) in [ + ( + "intent-local.json", + &["intent_id", "familiar_snapshot_id"][..], + ), + ( + "node-root.json", + &["node_id", "graph_id", "familiar_snapshot_id", "budget_id"][..], + ), + ("surface-event.json", &["surface_event_id"][..]), + ( + "surface-effect.json", + &[ + "surface_effect_id", + "intent_id", + "graph_id", + "node_id", + "attempt_id", + "familiar_snapshot_id", + ][..], + ), + ("delivery-ready.json", &["delivery_id", "intent_id"][..]), + ] { + for field in required_ids { + let bytes = mutate(fixture_name, |object| { + object.insert((*field).into(), Value::Null); + }); + assert!(decode_document(&bytes).is_err(), "{fixture_name}.{field}"); + } + } } #[test] @@ -164,10 +338,12 @@ fn surface_values_reject_scalars_oversize_unknown_fields_wrong_ids_and_bad_diges }); assert!(decode_document(&bytes).is_err(), "{field}"); } - let scalar_effect = mutate("surface-effect.json", |o| { - o.insert("effect".into(), json!(false)); - }); - assert!(decode_document(&scalar_effect).is_err()); + for field in ["locator", "effect"] { + let scalar = mutate("surface-effect.json", |o| { + o.insert(field.into(), json!(false)); + }); + assert!(decode_document(&scalar).is_err(), "{field}"); + } let bad_digest = mutate("surface-effect.json", |o| { o.insert( "effect_digest".into(), @@ -175,14 +351,29 @@ fn surface_values_reject_scalars_oversize_unknown_fields_wrong_ids_and_bad_diges ); }); assert!(decode_document(&bad_digest).is_err()); - let unknown = mutate("surface-event.json", |o| { - o.insert("extra".into(), json!(1)); - }); - assert!(decode_document(&unknown).is_err()); - let oversized = mutate("surface-event.json", |o| { - o.insert("content".into(), json!({"text": "x".repeat(1_048_577)})); - }); - assert!(decode_document(&oversized).is_err()); + for fixture_name in ["surface-event.json", "surface-effect.json"] { + let unknown = mutate(fixture_name, |o| { + o.insert("extra".into(), json!(1)); + }); + assert!(decode_document(&unknown).is_err(), "{fixture_name}"); + } + for (fixture_name, fields) in [ + ("surface-event.json", &["actor", "locator", "content"][..]), + ("surface-effect.json", &["locator", "effect"][..]), + ] { + for field in fields { + let oversized = mutate(fixture_name, |o| { + o.insert( + (*field).into(), + json!({"nested": {"text": "x".repeat(1_048_577)}}), + ); + }); + assert!( + decode_document(&oversized).is_err(), + "{fixture_name}.{field}" + ); + } + } } #[test] @@ -205,9 +396,18 @@ fn delivery_rejects_removed_fields_bad_enums_ids_effects_and_sent_without_messag }); assert!(decode_document(&bytes).is_err(), "{field}"); } + let bad_decision_state = mutate("delivery-ready.json", |o| { + o.get_mut("surface_decision") + .unwrap() + .as_object_mut() + .unwrap() + .insert("state".into(), json!("future")); + }); + assert!(decode_document(&bad_decision_state).is_err()); for effect in [ json!("scalar"), json!({}), + json!({"type": "different"}), json!({"x": "y".repeat(1_048_577)}), ] { let bytes = mutate("delivery-ready.json", |o| { @@ -230,10 +430,29 @@ fn delivery_rejects_removed_fields_bad_enums_ids_effects_and_sent_without_messag .insert("expires_at".into(), json!("tomorrow")); }); assert!(decode_document(&bad_expiry).is_err()); + for chat_id in ["", "-", "12x", "1".repeat(33).as_str()] { + let bytes = mutate("delivery-ready.json", |o| { + o.insert("chat_id".into(), json!(chat_id)); + }); + assert!(decode_document(&bytes).is_err(), "chat_id={chat_id:?}"); + } + for message_id in ["", "-1", "12x", "1".repeat(33).as_str()] { + let bytes = mutate("delivery-ready.json", |o| { + o.insert("telegram_message_id".into(), json!(message_id)); + }); + assert!( + decode_document(&bytes).is_err(), + "telegram_message_id={message_id:?}" + ); + } let sent = mutate("delivery-ready.json", |o| { o.insert("state".into(), json!("sent")); }); assert!(decode_document(&sent).is_err()); + let non_sent_with_id = mutate("delivery-ready.json", |o| { + o.insert("telegram_message_id".into(), json!("314")); + }); + assert!(decode_document(&non_sent_with_id).is_err()); } #[test] @@ -253,6 +472,45 @@ fn all_canonical_error_codes_decode() { assert_eq!(unique.len(), ErrorCode::ALL.len()); } +#[test] +fn error_code_typed_deserialization_is_strict() { + assert_eq!( + serde_json::from_str::("\"coven_capability_missing\"").unwrap(), + ErrorCode::CovenCapabilityMissing + ); + for rejected in [ + "\"\"", + "\"future_error\"", + "\"CONFIG_INVALID\"", + "\"config-invalid\"", + "null", + "1", + ] { + assert!( + serde_json::from_str::(rejected).is_err(), + "{rejected}" + ); + } + + let unknown = json!({ + "schema_version": "psyche.error.v1", + "error": { + "code": "future_error", + "message": "bad", + "retryable": false, + "correlation_id": "corr-1", + "details": {} + } + }); + assert!(matches!( + decode_document(&serde_json::to_vec(&unknown).unwrap()), + Err(ContractError::UnknownEnumValue { + schema: SchemaKind::Error, + field: "code" + }) + )); +} + #[test] fn error_envelope_is_strict_and_never_persistable() { let envelope = json!({ @@ -271,6 +529,7 @@ fn error_envelope_is_strict_and_never_persistable() { json!({"schema_version":"psyche.error.v1","error":{"code":"CONFIG_INVALID","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), json!({"schema_version":"psyche.error.v1","error":{"code":"config-invalid","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), json!({"schema_version":"psyche.error.v1","error":{"code":"future_error","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), + json!({"schema_version":"psyche.error.v1","error":{"code":"","message":"bad","retryable":false,"correlation_id":"c","details":{}}}), json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"","retryable":false,"correlation_id":"c","details":{}}}), json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"bad","retryable":false,"correlation_id":"c","details":{"x":1}}}), json!({"schema_version":"psyche.error.v1","error":{"code":"config_invalid","message":"bad","retryable":false,"correlation_id":"c","details":{},"extra":true}}), @@ -290,6 +549,99 @@ fn error_envelope_is_strict_and_never_persistable() { } } +#[test] +fn required_error_codes_have_dedicated_success_coverage() { + for (spelling, expected) in [ + ( + "coven_capability_missing", + ErrorCode::CovenCapabilityMissing, + ), + ("coven_adoption_unknown", ErrorCode::CovenAdoptionUnknown), + ( + "preview_finalize_blocked", + ErrorCode::PreviewFinalizeBlocked, + ), + ] { + let envelope = json!({ + "schema_version": "psyche.error.v1", + "error": { + "code": spelling, + "message": "public", + "retryable": false, + "correlation_id": "corr-1", + "details": {"scope": "public"} + } + }); + let CanonicalDocument::Error(decoded) = + decode_document(&serde_json::to_vec(&envelope).unwrap()).unwrap() + else { + panic!("expected error envelope"); + }; + assert_eq!(decoded.error.code, expected); + } +} + +#[test] +fn error_public_envelope_bounds_reject_empty_and_oversized_content() { + let valid = || { + json!({ + "schema_version": "psyche.error.v1", + "error": { + "code": "config_invalid", + "message": "public", + "retryable": false, + "correlation_id": "corr-1", + "details": {"scope": "public"} + } + }) + }; + for (label, mutate_error) in [ + ( + "empty detail key", + (|error: &mut serde_json::Map| { + error.insert("details".into(), json!({"": "public"})); + }) as fn(&mut serde_json::Map), + ), + ( + "empty detail value", + |error: &mut serde_json::Map| { + error.insert("details".into(), json!({"scope": ""})); + }, + ), + ( + "oversized detail key", + |error: &mut serde_json::Map| { + error.insert("details".into(), json!({"k".repeat(257): "public"})); + }, + ), + ( + "oversized detail value", + |error: &mut serde_json::Map| { + error.insert("details".into(), json!({"scope": "v".repeat(4097)})); + }, + ), + ( + "oversized message", + |error: &mut serde_json::Map| { + error.insert("message".into(), json!("m".repeat(4097))); + }, + ), + ( + "oversized correlation id", + |error: &mut serde_json::Map| { + error.insert("correlation_id".into(), json!("c".repeat(256))); + }, + ), + ] { + let mut value = valid(); + mutate_error(value.get_mut("error").unwrap().as_object_mut().unwrap()); + assert!( + decode_document(&serde_json::to_vec(&value).unwrap()).is_err(), + "{label}" + ); + } +} + fn valid_binding(state: CancellationState) -> ExecutionBinding { let request = RequestId::parse(&format!("req_{ULID_A}")).unwrap(); let termination = RequestId::parse(&format!("req_{ULID_B}")).unwrap(); @@ -303,7 +655,7 @@ fn valid_binding(state: CancellationState) -> ExecutionBinding { .unwrap(), revision: 1, previous_revision_digest: None, - revision_created_at: "2026-08-01T00:00:00Z".into(), + revision_created_at: timestamp("2026-08-01T00:00:00Z"), familiar_snapshot_id: RecordId::parse( psyche_core::contracts::RecordKind::IdentitySnapshot, &format!("ids_{ULID_B}"), @@ -312,8 +664,8 @@ fn valid_binding(state: CancellationState) -> ExecutionBinding { project_id: "project:one".into(), request_id: request.clone(), request_digest: digest_value.clone().try_into().unwrap(), - request_created_at: "2026-08-01T00:00:00Z".into(), - request_valid_until: "2026-08-01T00:10:00Z".into(), + request_created_at: timestamp("2026-08-01T00:00:00Z"), + request_valid_until: timestamp("2026-08-01T00:10:00Z"), coven_contract_version: "coven.execution.v1".into(), coven_session_id: None, adoption_state: AdoptionState::NotSubmitted, @@ -329,8 +681,8 @@ fn valid_binding(state: CancellationState) -> ExecutionBinding { binding.coven_session_id = Some("session-1".into()); binding.termination_request = Some(TerminationRequestCorrelation { termination_request_id: termination.clone(), - created_at: "2026-08-01T00:01:00Z".into(), - valid_until: "2026-08-01T00:05:00Z".into(), + created_at: timestamp("2026-08-01T00:01:00Z"), + valid_until: timestamp("2026-08-01T00:05:00Z"), }); binding.termination_reason_code = Some("operator_requested".into()); } @@ -350,25 +702,37 @@ fn valid_binding(state: CancellationState) -> ExecutionBinding { CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal }, authority_evidence_digest: format!("sha256:{}", "2".repeat(64)).try_into().unwrap(), - acknowledged_at: "2026-08-01T00:02:00Z".into(), + acknowledged_at: timestamp("2026-08-01T00:02:00Z"), }); } if state == CancellationState::TerminationUnknown { - binding.cancellation_unresolved = Some( - psyche_core::contracts::execution::CancellationUnresolvedEvidence { - disposition_id: "disp-1".into(), - termination_request_id: termination, - session_id: "session-1".into(), - execution_request_id: request, - execution_request_digest: digest_value.try_into().unwrap(), - reason_code: "authority_unreachable".into(), - recorded_at: "2026-08-01T00:03:00Z".into(), - }, - ); + binding.cancellation_unresolved = Some(CancellationUnresolvedEvidence { + disposition_id: "disp-1".into(), + termination_request_id: termination, + session_id: "session-1".into(), + execution_request_id: request, + execution_request_digest: digest_value.try_into().unwrap(), + reason_code: "authority_unreachable".into(), + recorded_at: timestamp("2026-08-01T00:03:00Z"), + }); } binding } +fn assert_cancellation_mismatch(binding: ExecutionBinding, label: &str) { + assert!( + matches!( + CanonicalDocument::ExecutionBinding(binding).validate(), + Err(ContractError::CancellationEvidenceMismatch) + ), + "{label}" + ); +} + +fn binding_value(state: CancellationState) -> Value { + serde_json::to_value(valid_binding(state)).unwrap() +} + #[test] fn cancellation_state_vocabulary_requires_matching_o5_evidence() { for state in [ @@ -419,6 +783,206 @@ fn cancellation_state_vocabulary_requires_matching_o5_evidence() { )); } +#[test] +fn cancellation_binding_normalizes_every_correlation_and_evidence_failure() { + let other_request = RequestId::parse(&format!("req_{ULID_C}")).unwrap(); + let other_digest = format!("sha256:{}", "3".repeat(64)).try_into().unwrap(); + + let mut wrong_termination = valid_binding(CancellationState::AcknowledgedTerminated); + wrong_termination + .cancellation_acknowledgement + .as_mut() + .unwrap() + .termination_request_id = other_request.clone(); + assert_cancellation_mismatch(wrong_termination, "termination request id"); + + let mut wrong_execution = valid_binding(CancellationState::AcknowledgedTerminated); + wrong_execution + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_id = other_request.clone(); + assert_cancellation_mismatch(wrong_execution, "execution request id"); + + let mut wrong_digest = valid_binding(CancellationState::AcknowledgedTerminated); + wrong_digest + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_digest = other_digest; + assert_cancellation_mismatch(wrong_digest, "execution digest"); + + let mut wrong_session = valid_binding(CancellationState::AcknowledgedTerminated); + wrong_session + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = "other".into(); + assert_cancellation_mismatch(wrong_session, "session"); + + let mut wrong_kind = valid_binding(CancellationState::AcknowledgedTerminated); + wrong_kind + .cancellation_acknowledgement + .as_mut() + .unwrap() + .kind = CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + assert_cancellation_mismatch(wrong_kind, "acknowledgement kind"); + + let mut missing_evidence = valid_binding(CancellationState::AcknowledgedTerminated); + missing_evidence.cancellation_acknowledgement = None; + assert_cancellation_mismatch(missing_evidence, "missing evidence"); + + let mut dual_evidence = valid_binding(CancellationState::AcknowledgedTerminated); + dual_evidence.cancellation_unresolved = + valid_binding(CancellationState::TerminationUnknown).cancellation_unresolved; + assert_cancellation_mismatch(dual_evidence, "dual evidence"); + + let mut missing_correlation = valid_binding(CancellationState::AcknowledgedTerminated); + missing_correlation.termination_request = None; + assert_cancellation_mismatch(missing_correlation, "missing termination correlation"); + + let mut mismatched_correlation = valid_binding(CancellationState::AcknowledgedTerminated); + mismatched_correlation + .termination_request + .as_mut() + .unwrap() + .termination_request_id = other_request; + assert_cancellation_mismatch(mismatched_correlation, "mismatched termination correlation"); + + let mut missing_reason = valid_binding(CancellationState::AcknowledgedTerminated); + missing_reason.termination_reason_code = None; + assert_cancellation_mismatch(missing_reason, "missing reason"); + + let mut unexpected_reason = valid_binding(CancellationState::NotRequested); + unexpected_reason.termination_reason_code = Some("operator_requested".into()); + assert_cancellation_mismatch(unexpected_reason, "unexpected reason"); + + for invalid_reason in ["UPPER", "bad-", "", &"a".repeat(129)] { + let mut binding = valid_binding(CancellationState::AcknowledgedTerminated); + binding.termination_reason_code = Some(invalid_reason.into()); + assert_cancellation_mismatch(binding, "invalid reason"); + } + + let mut reused_request = valid_binding(CancellationState::TerminationRequested); + reused_request + .termination_request + .as_mut() + .unwrap() + .termination_request_id = reused_request.request_id.clone(); + assert_cancellation_mismatch(reused_request, "reused execution request id"); + + let mut invalid_window = valid_binding(CancellationState::AcknowledgedTerminated); + invalid_window + .termination_request + .as_mut() + .unwrap() + .valid_until = timestamp("2026-08-01T00:00:59Z"); + assert_cancellation_mismatch(invalid_window, "invalid termination window"); + + let mut early_correlation = valid_binding(CancellationState::TerminationRequested); + early_correlation + .termination_request + .as_mut() + .unwrap() + .created_at = timestamp("2026-07-31T23:59:59Z"); + assert_cancellation_mismatch(early_correlation, "early termination correlation"); + + for (label, at) in [ + ("evidence before window", "2026-08-01T00:00:59Z"), + ("evidence after window", "2026-08-01T00:05:01Z"), + ] { + let mut binding = valid_binding(CancellationState::AcknowledgedTerminated); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = timestamp(at); + assert_cancellation_mismatch(binding, label); + } + + for at in ["2026-08-01T00:01:00Z", "2026-08-01T00:05:00Z"] { + let mut binding = valid_binding(CancellationState::AcknowledgedTerminated); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = timestamp(at); + CanonicalDocument::ExecutionBinding(binding) + .validate() + .unwrap(); + } + + for invalid_reason in ["UPPER", "bad-", "", &"a".repeat(129)] { + let mut binding = valid_binding(CancellationState::TerminationUnknown); + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .reason_code = invalid_reason.into(); + assert_cancellation_mismatch(binding, "invalid unresolved reason"); + } +} + +#[test] +fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() { + for (label, mutate_value) in [ + ( + "authority digest", + (|value: &mut Value| { + value["cancellation_acknowledgement"]["authority_evidence_digest"] = + json!("sha256:not-a-digest"); + }) as fn(&mut Value), + ), + ("acknowledgement timestamp", |value: &mut Value| { + value["cancellation_acknowledgement"]["acknowledged_at"] = json!("tomorrow"); + }), + ("acknowledgement kind", |value: &mut Value| { + value["cancellation_acknowledgement"]["kind"] = json!("future_kind"); + }), + ("termination request id", |value: &mut Value| { + value["termination_request"]["termination_request_id"] = json!("not-a-request"); + }), + ("execution request id", |value: &mut Value| { + value["cancellation_acknowledgement"]["execution_request_id"] = json!("not-a-request"); + }), + ("execution request digest", |value: &mut Value| { + value["cancellation_acknowledgement"]["execution_request_digest"] = + json!("not-a-digest"); + }), + ("evidence session", |value: &mut Value| { + value["cancellation_acknowledgement"]["session_id"] = json!(""); + }), + ("termination timestamp", |value: &mut Value| { + value["termination_request"]["created_at"] = json!("tomorrow"); + }), + ("termination reason shape", |value: &mut Value| { + value["termination_reason_code"] = json!(42); + }), + ] { + let mut value = binding_value(CancellationState::AcknowledgedTerminated); + mutate_value(&mut value); + assert!( + matches!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::CancellationEvidenceMismatch) + ), + "{label}" + ); + } + + let mut unknown = binding_value(CancellationState::TerminationRequested); + unknown["cancellation_state"] = json!("cancelled"); + assert!(decode_document(&serde_json::to_vec(&unknown).unwrap()).is_err()); + + let mut raw_ledger = binding_value(CancellationState::AcknowledgedTerminated); + raw_ledger["cancellation_acknowledgement"] = Value::Null; + raw_ledger["terminal_state"] = json!("killed"); + assert!(matches!( + decode_document(&serde_json::to_vec(&raw_ledger).unwrap()), + Err(ContractError::CancellationEvidenceMismatch) + )); +} + #[test] fn strict_probe_and_document_limit_fail_closed() { assert!(matches!( @@ -428,6 +992,21 @@ fn strict_probe_and_document_limit_fail_closed() { assert!(decode_document(&vec![b' '; 1_048_577]).is_err()); } +#[test] +fn directly_constructed_document_rejects_oversized_canonical_bytes() { + let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { + panic!("expected intent"); + }; + intent.constraints.insert( + "payload".into(), + Value::String("x".repeat(psyche_core::contracts::MAX_DOCUMENT_BYTES - 128)), + ); + assert!(matches!( + CanonicalDocument::Intent(intent).validate(), + Err(ContractError::DocumentTooLarge) + )); +} + #[test] fn directly_constructed_values_are_revalidated() { let mut binding = valid_binding(CancellationState::NotRequested); @@ -444,7 +1023,7 @@ fn typed_deserialization_cannot_bypass_validation() { let wrong_id = mutate("intent-local.json", |object| { object.insert("intent_id".into(), json!(format!("grf_{ULID_A}"))); }); - assert!(serde_json::from_slice::(&wrong_id).is_err()); + assert!(serde_json::from_slice::(&wrong_id).is_err()); let mismatched_digest = mutate("surface-effect.json", |object| { object.insert( @@ -452,10 +1031,5 @@ fn typed_deserialization_cannot_bypass_validation() { json!(format!("sha256:{}", "0".repeat(64))), ); }); - assert!( - serde_json::from_slice::( - &mismatched_digest - ) - .is_err() - ); + assert!(serde_json::from_slice::(&mismatched_digest).is_err()); } diff --git a/crates/psyche-core/tests/fixtures/delivery-ready.json b/crates/psyche-core/tests/fixtures/delivery-ready.json index f4bfcee..9e97e95 100644 --- a/crates/psyche-core/tests/fixtures/delivery-ready.json +++ b/crates/psyche-core/tests/fixtures/delivery-ready.json @@ -12,9 +12,11 @@ "type": "send_message", "format": "html", "text": "Review complete.", - "buttons": [] + "reply_to_message_id": "314", + "buttons": [], + "link_preview": {"enabled": true} }, - "effect_digest": "sha256:81fe163b620a8bedc0f7aa98ec44f1c04376ca649cf97e1161a6b7d2924cbeb2", + "effect_digest": "sha256:26fae759f51eafdfa1277616327d942068948182fb0aaa6b34bbedb0a8ca7dc7", "surface_decision": { "decision_id": "decision_01ARZ3NDEKTSV4RRFFQ69G5FAV", "request_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", From 24765ee74da155a193d1b9b53c3c7a20863fdd6a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:36:42 -0500 Subject: [PATCH 05/66] fix(core): preserve canonical numeric identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 20 ++ Cargo.toml | 1 + crates/psyche-core/Cargo.toml | 1 + crates/psyche-core/src/contracts/execution.rs | 6 +- .../psyche-core/src/contracts/foundation.rs | 13 +- crates/psyche-core/src/contracts/graph.rs | 4 +- crates/psyche-core/src/contracts/identity.rs | 3 +- crates/psyche-core/src/contracts/mod.rs | 28 ++- crates/psyche-core/src/contracts/surface.rs | 4 +- crates/psyche-core/src/digest.rs | 129 ++++++++++- crates/psyche-core/tests/contracts.rs | 210 +++++++++++++++++- 11 files changed, 401 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58ee57d..a68b2a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,6 +416,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -528,6 +537,7 @@ version = "0.0.0" dependencies = [ "proptest", "serde", + "serde-value", "serde_json", "serde_json_canonicalizer", "sha2", @@ -696,6 +706,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index 331a9f1..e7d9e9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ time = { version = "0.3", features = ["formatting", "parsing", "serde"] } ulid = { version = "1", features = ["serde"] } proptest = "1" serde = { version = "1", features = ["derive"] } +serde-value = "0.7" toml = "1" thiserror = "2" # "env": `--config` falls back to $PSYCHE_CONFIG, which is how a container image diff --git a/crates/psyche-core/Cargo.toml b/crates/psyche-core/Cargo.toml index 833a810..b4ecb84 100644 --- a/crates/psyche-core/Cargo.toml +++ b/crates/psyche-core/Cargo.toml @@ -15,6 +15,7 @@ publish.workspace = true # `TryFrom` impl rather than straight into the newtype. serde = { workspace = true } serde_json = { workspace = true } +serde-value = { workspace = true } thiserror = { workspace = true } time = { workspace = true } # `digest::canonical_bytes` delegates to this for RFC 8785 canonical JSON — diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs index 1cdccc3..e2c823e 100644 --- a/crates/psyche-core/src/contracts/execution.rs +++ b/crates/psyche-core/src/contracts/execution.rs @@ -6,7 +6,7 @@ use serde_json::Value; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, - optional_bounded, reason_code, require_id, require_schema, + optional_bounded, reason_code, require_id, require_schema, safe_integer, }; use crate::digest::Sha256Digest; use crate::id::{RecordId, RequestId}; @@ -336,6 +336,10 @@ impl ExecutionBinding { if self.revision == 0 || (self.revision == 1) != self.previous_revision_digest.is_none() { return Err(super::invalid(s, "revision")); } + safe_integer(self.revision, s, "revision")?; + if self.revision_created_at.offset() != time::UtcOffset::UTC { + return Err(super::invalid(s, "revision_created_at")); + } bounded(&self.project_id, 255, s, "project_id")?; bounded( &self.coven_contract_version, diff --git a/crates/psyche-core/src/contracts/foundation.rs b/crates/psyche-core/src/contracts/foundation.rs index f769c7b..1b3ef4d 100644 --- a/crates/psyche-core/src/contracts/foundation.rs +++ b/crates/psyche-core/src/contracts/foundation.rs @@ -3,7 +3,7 @@ use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, - optional_bounded, require_id, require_schema, string_list, + optional_bounded, require_id, require_schema, safe_integer, string_list, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -81,7 +81,11 @@ impl Budget { require_schema(self.schema_version, s)?; require_id(&self.budget_id, RecordKind::Budget, s, "budget_id")?; require_id(&self.graph_id, RecordKind::Graph, s, "graph_id")?; - bounded(&self.resource_class, 256, s, "resource_class") + bounded(&self.resource_class, 256, s, "resource_class")?; + safe_integer(self.limit, s, "limit")?; + safe_integer(self.reserved, s, "reserved")?; + safe_integer(self.consumed, s, "consumed")?; + safe_integer(self.released, s, "released") } } versioned!(Budget, budget_id); @@ -148,7 +152,7 @@ impl Evidence { ] { bounded(value, 256, s, field)?; } - Ok(()) + safe_integer(self.size, s, "size") } } versioned!(Evidence, evidence_id); @@ -211,7 +215,8 @@ impl Recovery { bounded(&self.lease_id, 255, s, "lease_id")?; bounded(&self.ambiguity, 256, s, "ambiguity")?; optional_bounded(&self.fence_token, 255, s, "fence_token")?; - optional_bounded(&self.operator_disposition, 256, s, "operator_disposition") + optional_bounded(&self.operator_disposition, 256, s, "operator_disposition")?; + safe_integer(self.reconciliation_count, s, "reconciliation_count") } } versioned!(Recovery, recovery_id); diff --git a/crates/psyche-core/src/contracts/graph.rs b/crates/psyche-core/src/contracts/graph.rs index d47075a..7135544 100644 --- a/crates/psyche-core/src/contracts/graph.rs +++ b/crates/psyche-core/src/contracts/graph.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, require_id, - require_schema, string_list, + require_schema, safe_integer, string_list, }; use crate::id::RecordId; @@ -53,6 +53,7 @@ impl Graph { if self.version == 0 { return Err(super::invalid(schema, "version")); } + safe_integer(self.version, schema, "version")?; Ok(()) } } @@ -131,6 +132,7 @@ impl GraphNode { if self.version == 0 { return Err(super::invalid(schema, "version")); } + safe_integer(self.version, schema, "version")?; Ok(()) } } diff --git a/crates/psyche-core/src/contracts/identity.rs b/crates/psyche-core/src/contracts/identity.rs index e946cc6..93ebe20 100644 --- a/crates/psyche-core/src/contracts/identity.rs +++ b/crates/psyche-core/src/contracts/identity.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, require_id, - require_schema, + require_schema, safe_integer, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -50,6 +50,7 @@ impl IdentitySnapshot { if self.revision == 0 { return Err(super::invalid(schema, "revision")); } + safe_integer(self.revision, schema, "revision")?; bounded( &self.provenance.familiar_home_id, 255, diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 2888371..f49da00 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -64,6 +64,7 @@ pub use surface::{Delivery, SurfaceEffect, SurfaceEvent}; /// Maximum accepted encoded or embedded canonical document size. pub const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; /// Reasons a contract primitive failed to validate. /// @@ -106,15 +107,19 @@ pub enum ContractError { /// A digest was not exactly 64 lowercase hex characters after the prefix. #[error("digest is not exactly 64 lowercase hex characters")] MalformedDigest, - /// The value could not be serialized into canonical JSON — e.g. a - /// non-string map key, or a number requiring more than double precision. - /// The nested reason describes the *shape* problem `serde_json` found, - /// never the value's own field content. + /// The value could not be serialized into canonical JSON — e.g. because + /// it contains a non-string map key. + /// The nested reason describes the serialization *shape* problem, never + /// the value's own field content. #[error("value could not be canonicalized: {reason}")] CanonicalizationFailed { - /// `serde_json`'s description of the shape problem. + /// The serializer's description of the shape problem. reason: String, }, + /// A JSON integer was outside the exact range interoperable with IEEE-754 + /// implementations under I-JSON. + #[error("JSON number is outside the interoperable safe-integer range")] + NonInteroperableNumber, /// A known schema did not have its exact v1 field shape or valid values. #[error("invalid {schema:?} document shape at {field}")] InvalidShape { @@ -568,6 +573,7 @@ pub fn decode_document(bytes: &[u8]) -> Result } let value: Value = serde_json::from_slice(bytes).map_err(|_| invalid(SchemaKind::Error, "json"))?; + crate::digest::validate_json_domain(&value)?; let schema_text = value .as_object() .and_then(|v| v.get("schema_version")) @@ -650,6 +656,18 @@ pub(crate) fn bounded( } } +pub(crate) fn safe_integer( + value: u64, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + if value <= MAX_SAFE_INTEGER { + Ok(()) + } else { + Err(invalid(schema, field)) + } +} + pub(crate) fn optional_bounded( value: &Option, max: usize, diff --git a/crates/psyche-core/src/contracts/surface.rs b/crates/psyche-core/src/contracts/surface.rs index 3e6d70c..47d0c9a 100644 --- a/crates/psyche-core/src/contracts/surface.rs +++ b/crates/psyche-core/src/contracts/surface.rs @@ -6,7 +6,7 @@ use serde_json::Value; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, object, - require_id, require_schema, + require_id, require_schema, safe_integer, }; use crate::digest::{Sha256Digest, digest}; use crate::id::RecordId; @@ -226,6 +226,8 @@ impl Delivery { if (self.state == DeliveryState::Sent) != self.telegram_message_id.is_some() { return Err(super::invalid(s, "telegram_message_id")); } + safe_integer(u64::from(self.logical_part), s, "logical_part")?; + safe_integer(u64::from(self.attempt_count), s, "attempt_count")?; Ok(()) } } diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index 05a9074..db87da9 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -8,12 +8,14 @@ use std::fmt; use std::fmt::Write as _; use serde::Serialize; +use serde_json::Value; use sha2::{Digest as _, Sha256}; -use crate::contracts::ContractError; +use crate::contracts::{ContractError, MAX_SAFE_INTEGER}; /// Length of the hex-encoded digest after the `sha256:` prefix. const HEX_DIGEST_LEN: usize = 64; +const MIN_SAFE_INTEGER: i64 = -(MAX_SAFE_INTEGER as i64); /// The RFC 8785 canonical JSON bytes for `value`. /// @@ -21,9 +23,73 @@ const HEX_DIGEST_LEN: usize = 64; /// order produce identical bytes — canonicalisation, not merely /// serialization, is the point of this function. pub fn canonical_bytes(value: &T) -> Result, ContractError> { - serde_json_canonicalizer::to_vec(value).map_err(|err| ContractError::CanonicalizationFailed { - reason: err.to_string(), - }) + let value = serde_value::to_value(value).map_err(canonicalization_failed)?; + validate_serialized_domain(&value)?; + serde_json_canonicalizer::to_vec(&value).map_err(canonicalization_failed) +} + +fn canonicalization_failed(error: impl ToString) -> ContractError { + ContractError::CanonicalizationFailed { + reason: error.to_string(), + } +} + +pub(crate) fn validate_json_domain(value: &Value) -> Result<(), ContractError> { + match value { + Value::Array(values) => values.iter().try_for_each(validate_json_domain), + Value::Object(values) => values.values().try_for_each(validate_json_domain), + Value::Number(number) => { + let interoperable = if let Some(value) = number.as_i64() { + value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER as i64 + } else if let Some(value) = number.as_u64() { + value <= MAX_SAFE_INTEGER + } else if let Some(value) = number.as_f64() { + value.is_finite() + && (value.fract() != 0.0 + || (value >= MIN_SAFE_INTEGER as f64 && value <= MAX_SAFE_INTEGER as f64)) + } else { + false + }; + if interoperable { + Ok(()) + } else { + Err(ContractError::NonInteroperableNumber) + } + } + Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), + } +} + +fn validate_serialized_domain(value: &serde_value::Value) -> Result<(), ContractError> { + use serde_value::Value::{ + Bool, Bytes, Char, F32, F64, I8, I16, I32, I64, Map, Newtype, Option, Seq, String, U8, U16, + U32, U64, Unit, + }; + + match value { + U64(value) if *value > MAX_SAFE_INTEGER => Err(ContractError::NonInteroperableNumber), + I64(value) if *value < MIN_SAFE_INTEGER || *value > MAX_SAFE_INTEGER as i64 => { + Err(ContractError::NonInteroperableNumber) + } + F32(value) => validate_float(f64::from(*value)), + F64(value) => validate_float(*value), + Option(Some(value)) | Newtype(value) => validate_serialized_domain(value), + Seq(values) => values.iter().try_for_each(validate_serialized_domain), + Map(values) => values.values().try_for_each(validate_serialized_domain), + Bool(_) | U8(_) | U16(_) | U32(_) | U64(_) | I8(_) | I16(_) | I32(_) | I64(_) | Char(_) + | String(_) | Unit | Option(None) | Bytes(_) => Ok(()), + } +} + +fn validate_float(value: f64) -> Result<(), ContractError> { + if value.is_finite() + && (value.fract() != 0.0 + || (value >= MIN_SAFE_INTEGER as f64 && value <= MAX_SAFE_INTEGER as f64)) + { + Ok(()) + } else { + Err(ContractError::NonInteroperableNumber) + } } /// The [`Sha256Digest`] of `value`'s canonical JSON bytes. @@ -135,6 +201,61 @@ mod tests { ); } + #[test] + fn canonicalization_accepts_safe_integer_boundaries_and_fractional_numbers() { + let value = json!({ + "nested": [ + -9_007_199_254_740_991_i64, + {"maximum": 9_007_199_254_740_991_u64}, + 1.5 + ] + }); + + canonical_bytes(&value).unwrap(); + digest(&value).unwrap(); + } + + #[test] + fn canonicalization_rejects_unsafe_integers_anywhere_in_the_json_domain() { + for value in [ + json!({"unsafe": 9_007_199_254_740_992_u64}), + json!([{"nested": -9_007_199_254_740_992_i64}]), + json!(u64::MAX), + json!(9_007_199_254_740_992.0_f64), + ] { + assert_eq!( + canonical_bytes(&value), + Err(crate::contracts::ContractError::NonInteroperableNumber) + ); + } + } + + #[test] + fn adjacent_unsafe_integers_cannot_collapse_to_one_successful_digest() { + let first = digest(&9_007_199_254_740_992_u64); + let second = digest(&9_007_199_254_740_993_u64); + + assert!(first.is_err()); + assert!(second.is_err()); + } + + #[test] + fn canonicalization_rejects_non_finite_numbers_before_they_become_null() { + #[derive(Serialize)] + struct NestedFloat { + values: Vec, + } + + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!( + canonical_bytes(&NestedFloat { + values: vec![value], + }), + Err(crate::contracts::ContractError::NonInteroperableNumber) + ); + } + } + #[test] fn sha256_digest_round_trips_through_serde() { let d = digest(&json!({"a": 1})).unwrap(); diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index 6f80933..6175902 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -7,7 +7,8 @@ use psyche_core::contracts::execution::{ CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, TerminationRequestCorrelation, }; -use psyche_core::contracts::foundation::{Approval, Evidence, Verdict}; +use psyche_core::contracts::foundation::{Approval, Budget, Evidence, Recovery, Verdict}; +use psyche_core::contracts::graph::{Graph, GraphNode}; use psyche_core::contracts::identity::IdentitySnapshot; use psyche_core::contracts::intent::Intent; use psyche_core::contracts::surface::{ @@ -106,6 +107,34 @@ fn directly_constructed_typed_timestamps_still_enforce_cross_field_windows() { )); } +#[test] +fn execution_binding_requires_a_utc_revision_timestamp_when_directly_constructed() { + let mut binding = valid_binding(CancellationState::NotRequested); + binding.revision_created_at = timestamp("2026-08-05T01:00:00+01:00"); + + assert_eq!( + binding.validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::ExecutionBinding, + field: "revision_created_at", + }) + ); +} + +#[test] +fn execution_binding_requires_a_utc_revision_timestamp_when_decoded() { + let mut value = binding_value(CancellationState::NotRequested); + value["revision_created_at"] = json!("2026-08-05T01:00:00+01:00"); + + assert_eq!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::InvalidShape { + schema: SchemaKind::ExecutionBinding, + field: "revision_created_at", + }) + ); +} + #[test] fn foundation_timestamp_fields_parse_and_serialize_as_rfc3339_strings() { let digest = format!("sha256:{}", "a".repeat(64)); @@ -1018,6 +1047,185 @@ fn directly_constructed_values_are_revalidated() { ); } +#[test] +fn typed_u64_fields_accept_the_safe_boundary_and_reject_one_over() { + const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + const ONE_OVER: u64 = MAX_SAFE_INTEGER + 1; + let digest = format!("sha256:{}", "a".repeat(64)); + + let mut identity: IdentitySnapshot = serde_json::from_value(json!({ + "schema_version": "psyche.identity_snapshot.v1", + "snapshot_id": format!("ids_{ULID_A}"), + "familiar_id": "familiar:one", + "principal_id": "principal:one", + "revision": 1, + "declaration_digest": digest, + "identity_file_digest": digest, + "identity_digest": digest, + "soul_digest": digest, + "role_skill_digest": digest, + "provenance": {"familiar_home_id": "home:one", "resolver_version": "1"}, + "resolved_at": "2026-08-01T00:00:00Z" + })) + .unwrap(); + identity.revision = MAX_SAFE_INTEGER; + identity.validate().unwrap(); + identity.revision = ONE_OVER; + assert_invalid_numeric_field( + identity.validate(), + SchemaKind::IdentitySnapshot, + "revision", + ); + + let mut graph: Graph = serde_json::from_value(json!({ + "schema_version": "psyche.graph.v1", + "graph_id": format!("grf_{ULID_A}"), + "root_intent_id": format!("int_{ULID_A}"), + "owner_principal_id": "principal:one", + "policy_revision": "policy:one", + "state": "draft", + "version": 1 + })) + .unwrap(); + graph.version = MAX_SAFE_INTEGER; + graph.validate().unwrap(); + graph.version = ONE_OVER; + assert_invalid_numeric_field(graph.validate(), SchemaKind::Graph, "version"); + + let CanonicalDocument::GraphNode(mut node) = decode("node-root.json") else { + panic!("expected graph node"); + }; + let _: &GraphNode = &node; + node.version = MAX_SAFE_INTEGER; + node.validate().unwrap(); + node.version = ONE_OVER; + assert_invalid_numeric_field(node.validate(), SchemaKind::GraphNode, "version"); + + let mut budget: Budget = serde_json::from_value(json!({ + "schema_version": "psyche.budget.v1", + "budget_id": format!("bud_{ULID_A}"), + "graph_id": format!("grf_{ULID_A}"), + "resource_class": "tokens", + "limit": 1, + "reserved": 1, + "consumed": 1, + "released": 1 + })) + .unwrap(); + budget.limit = MAX_SAFE_INTEGER; + budget.reserved = MAX_SAFE_INTEGER; + budget.consumed = MAX_SAFE_INTEGER; + budget.released = MAX_SAFE_INTEGER; + budget.validate().unwrap(); + for field in ["limit", "reserved", "consumed", "released"] { + let mut unsafe_budget = budget.clone(); + match field { + "limit" => unsafe_budget.limit = ONE_OVER, + "reserved" => unsafe_budget.reserved = ONE_OVER, + "consumed" => unsafe_budget.consumed = ONE_OVER, + "released" => unsafe_budget.released = ONE_OVER, + _ => unreachable!(), + } + assert_invalid_numeric_field(unsafe_budget.validate(), SchemaKind::Budget, field); + } + + let mut evidence: Evidence = serde_json::from_value(json!({ + "schema_version": "psyche.evidence.v1", + "evidence_id": format!("evd_{ULID_A}"), + "node_id": format!("nod_{ULID_A}"), + "attempt_id": format!("att_{ULID_A}"), + "content_digest": digest, + "producer": "test", + "collection_method": "test", + "media_type": "text/plain", + "size": 1, + "created_at": "2026-08-01T00:00:00Z", + "retention_policy": "default" + })) + .unwrap(); + evidence.size = MAX_SAFE_INTEGER; + evidence.validate().unwrap(); + evidence.size = ONE_OVER; + assert_invalid_numeric_field(evidence.validate(), SchemaKind::Evidence, "size"); + + let mut recovery: Recovery = serde_json::from_value(json!({ + "schema_version": "psyche.recovery.v1", + "recovery_id": format!("rcv_{ULID_A}"), + "attempt_id": format!("att_{ULID_A}"), + "lease_id": "lease:one", + "fence_token": null, + "ambiguity": "none", + "reconciliation_count": 1, + "operator_disposition": null + })) + .unwrap(); + recovery.reconciliation_count = MAX_SAFE_INTEGER; + recovery.validate().unwrap(); + recovery.reconciliation_count = ONE_OVER; + assert_invalid_numeric_field( + recovery.validate(), + SchemaKind::Recovery, + "reconciliation_count", + ); + + let mut binding = valid_binding(CancellationState::NotRequested); + binding.previous_revision_digest = Some(digest.try_into().unwrap()); + binding.revision = MAX_SAFE_INTEGER; + binding.validate().unwrap(); + binding.revision = ONE_OVER; + assert_invalid_numeric_field(binding.validate(), SchemaKind::ExecutionBinding, "revision"); +} + +#[test] +fn typed_u32_delivery_fields_remain_within_the_safe_integer_domain() { + let CanonicalDocument::Delivery(mut delivery) = decode("delivery-ready.json") else { + panic!("expected delivery"); + }; + delivery.logical_part = u32::MAX; + delivery.attempt_count = u32::MAX; + + delivery.validate().unwrap(); +} + +#[test] +fn canonical_document_validation_rejects_nested_unsafe_integers() { + let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { + panic!("expected intent"); + }; + intent.constraints.insert( + "nested".into(), + json!({"array": [9_007_199_254_740_992_u64]}), + ); + + assert_eq!( + CanonicalDocument::Intent(intent).validate(), + Err(ContractError::NonInteroperableNumber) + ); +} + +#[test] +fn decoded_document_rejects_nested_unsafe_integers() { + let bytes = mutate("surface-event.json", |object| { + object.insert( + "content".into(), + json!({"nested": [9_007_199_254_740_992_u64]}), + ); + }); + + assert_eq!( + decode_document(&bytes), + Err(ContractError::NonInteroperableNumber) + ); +} + +fn assert_invalid_numeric_field( + result: Result<(), ContractError>, + schema: SchemaKind, + field: &'static str, +) { + assert_eq!(result, Err(ContractError::InvalidShape { schema, field })); +} + #[test] fn typed_deserialization_cannot_bypass_validation() { let wrong_id = mutate("intent-local.json", |object| { From e1d2d44fe2d49792541528e5ef2bec031de11bc5 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:43:15 -0500 Subject: [PATCH 06/66] fix(core): validate every serialized integer Replace the lossy serde_value traversal with a type-preserving Serde validation pass that covers numeric keys, i128/u128, every compound branch, and non-finite floats before canonicalizing the original value. Redact schema and serializer-controlled error text, and add boundary, collision, traversal, and near-1MiB input regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 20 - Cargo.toml | 1 - crates/psyche-core/Cargo.toml | 1 - crates/psyche-core/src/contracts/mod.rs | 103 +++-- crates/psyche-core/src/digest.rs | 560 ++++++++++++++++++++++-- crates/psyche-core/tests/contracts.rs | 49 ++- 6 files changed, 652 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a68b2a1..58ee57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,15 +416,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -537,7 +528,6 @@ version = "0.0.0" dependencies = [ "proptest", "serde", - "serde-value", "serde_json", "serde_json_canonicalizer", "sha2", @@ -706,16 +696,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_core" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index e7d9e9e..331a9f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,6 @@ time = { version = "0.3", features = ["formatting", "parsing", "serde"] } ulid = { version = "1", features = ["serde"] } proptest = "1" serde = { version = "1", features = ["derive"] } -serde-value = "0.7" toml = "1" thiserror = "2" # "env": `--config` falls back to $PSYCHE_CONFIG, which is how a container image diff --git a/crates/psyche-core/Cargo.toml b/crates/psyche-core/Cargo.toml index b4ecb84..833a810 100644 --- a/crates/psyche-core/Cargo.toml +++ b/crates/psyche-core/Cargo.toml @@ -15,7 +15,6 @@ publish.workspace = true # `TryFrom` impl rather than straight into the newtype. serde = { workspace = true } serde_json = { workspace = true } -serde-value = { workspace = true } thiserror = { workspace = true } time = { workspace = true } # `digest::canonical_bytes` delegates to this for RFC 8785 canonical JSON — diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index f49da00..da7ac27 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -75,17 +75,15 @@ pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ContractError { /// A `psyche..v` string named a kind this build does not define. - #[error("unknown schema kind in {found:?}")] - UnknownSchema { - /// The rejected string, verbatim. - found: String, - }, + /// The rejected string is intentionally not retained: attacker-controlled + /// schema text must not propagate into error payloads or logs. + #[error("schema version names an unknown kind")] + UnknownSchema, /// The kind is known but this build does not accept the declared major. - #[error("unsupported schema major in {found:?}")] - UnsupportedMajor { - /// The rejected string, verbatim. - found: String, - }, + /// The rejected string is intentionally not retained: attacker-controlled + /// schema text must not propagate into error payloads or logs. + #[error("schema version declares an unsupported major")] + UnsupportedMajor, /// A record identifier did not carry the exact prefix its requested /// `RecordKind` requires. The required prefix is [`RecordKind::prefix`], /// not stored redundantly on this error. @@ -107,15 +105,11 @@ pub enum ContractError { /// A digest was not exactly 64 lowercase hex characters after the prefix. #[error("digest is not exactly 64 lowercase hex characters")] MalformedDigest, - /// The value could not be serialized into canonical JSON — e.g. because - /// it contains a non-string map key. - /// The nested reason describes the serialization *shape* problem, never - /// the value's own field content. - #[error("value could not be canonicalized: {reason}")] - CanonicalizationFailed { - /// The serializer's description of the shape problem. - reason: String, - }, + /// The value could not be serialized into canonical JSON. Serializer error + /// text is intentionally not retained because custom serializers can emit + /// attacker-controlled messages. + #[error("value could not be canonicalized")] + CanonicalizationFailed, /// A JSON integer was outside the exact range interoperable with IEEE-754 /// implementations under I-JSON. #[error("JSON number is outside the interoperable safe-integer range")] @@ -383,9 +377,7 @@ impl SchemaVersion { /// registry has exactly two failure modes, and a garbled major on an /// otherwise-known kind is a version problem, not an unknown-kind one. pub fn parse(value: &str) -> Result { - let unknown = || ContractError::UnknownSchema { - found: value.to_string(), - }; + let unknown = || ContractError::UnknownSchema; let segments: Vec<&str> = value.split('.').collect(); let [namespace, kind_segment, major_segment] = segments.as_slice() else { return Err(unknown()); @@ -396,9 +388,7 @@ impl SchemaVersion { let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; - let unsupported_major = || ContractError::UnsupportedMajor { - found: value.to_string(), - }; + let unsupported_major = || ContractError::UnsupportedMajor; let digits = major_segment .strip_prefix('v') .ok_or_else(unsupported_major)?; @@ -738,6 +728,61 @@ pub(crate) fn reason_code( mod tests { use super::*; + // Attacker-input-redaction tests: these pin the payload-light contract for + // UnknownSchema and UnsupportedMajor. A nearly-1-MiB schema string with a + // unique control marker must NOT appear in Debug or Display output. + #[test] + fn unknown_schema_error_does_not_expose_attacker_input() { + let marker = "SENTINEL_UNKNOWN_XYZ"; + // Large unknown kind segment with embedded control marker → UnknownSchema + let attacker = format!("psyche.{}{}.v1", marker, "a".repeat(900_000)); + let err = SchemaVersion::parse(&attacker).unwrap_err(); + assert!( + matches!(err, ContractError::UnknownSchema), + "expected UnknownSchema, got {err:?}" + ); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!( + !debug.contains(marker), + "Debug must not contain attacker marker (output len = {})", + debug.len() + ); + assert!( + !display.contains(marker), + "Display must not contain attacker marker (output len = {})", + display.len() + ); + assert!(debug.len() < 256); + assert!(display.len() < 256); + } + + #[test] + fn unsupported_major_error_does_not_expose_attacker_input() { + let marker = "SENTINEL_MAJOR_XYZ"; + // Known kind, non-digit marker in major segment → UnsupportedMajor + let attacker = format!("psyche.intent.v{}{}", marker, "9".repeat(900_000)); + let err = SchemaVersion::parse(&attacker).unwrap_err(); + assert!( + matches!(err, ContractError::UnsupportedMajor), + "expected UnsupportedMajor, got {err:?}" + ); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!( + !debug.contains(marker), + "Debug must not contain attacker marker (output len = {})", + debug.len() + ); + assert!( + !display.contains(marker), + "Display must not contain attacker marker (output len = {})", + display.len() + ); + assert!(debug.len() < 256); + assert!(display.len() < 256); + } + #[test] fn record_kind_all_has_exactly_fifteen_entries() { assert_eq!(RecordKind::ALL.len(), 15); @@ -787,13 +832,13 @@ mod tests { #[test] fn schema_version_rejects_an_unknown_kind() { let err = SchemaVersion::parse("psyche.unknown_kind.v1").unwrap_err(); - assert!(matches!(err, ContractError::UnknownSchema { .. })); + assert!(matches!(err, ContractError::UnknownSchema)); } #[test] fn schema_version_rejects_a_known_kind_with_the_wrong_major() { let err = SchemaVersion::parse("psyche.intent.v2").unwrap_err(); - assert!(matches!(err, ContractError::UnsupportedMajor { .. })); + assert!(matches!(err, ContractError::UnsupportedMajor)); } #[test] @@ -839,12 +884,12 @@ mod tests { let err = SchemaVersion::parse(near).unwrap_err(); if expect_unknown { assert!( - matches!(err, ContractError::UnknownSchema { .. }), + matches!(err, ContractError::UnknownSchema), "expected UnknownSchema for {near:?}, got {err:?}" ); } else { assert!( - matches!(err, ContractError::UnsupportedMajor { .. }), + matches!(err, ContractError::UnsupportedMajor), "expected UnsupportedMajor for {near:?}, got {err:?}" ); } diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index db87da9..dafdc92 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -8,6 +8,10 @@ use std::fmt; use std::fmt::Write as _; use serde::Serialize; +use serde::ser::{ + self, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, + SerializeTupleStruct, SerializeTupleVariant, Serializer, +}; use serde_json::Value; use sha2::{Digest as _, Sha256}; @@ -16,21 +20,31 @@ use crate::contracts::{ContractError, MAX_SAFE_INTEGER}; /// Length of the hex-encoded digest after the `sha256:` prefix. const HEX_DIGEST_LEN: usize = 64; const MIN_SAFE_INTEGER: i64 = -(MAX_SAFE_INTEGER as i64); +const MAX_SAFE_INTEGER_I128: i128 = MAX_SAFE_INTEGER as i128; +const MIN_SAFE_INTEGER_I128: i128 = -MAX_SAFE_INTEGER_I128; /// The RFC 8785 canonical JSON bytes for `value`. /// /// Two values that serialize to the same JSON data but differ in object key /// order produce identical bytes — canonicalisation, not merely -/// serialization, is the point of this function. +/// serialization, is the point of this function. Every integer emitted by +/// `Serialize`, including map keys, is validated before the original value is +/// passed to the canonicalizer. pub fn canonical_bytes(value: &T) -> Result, ContractError> { - let value = serde_value::to_value(value).map_err(canonicalization_failed)?; - validate_serialized_domain(&value)?; - serde_json_canonicalizer::to_vec(&value).map_err(canonicalization_failed) + value + .serialize(DomainValidator) + .map_err(validation_failed)?; + serde_json_canonicalizer::to_vec(value).map_err(canonicalization_failed) } -fn canonicalization_failed(error: impl ToString) -> ContractError { - ContractError::CanonicalizationFailed { - reason: error.to_string(), +fn canonicalization_failed(_error: impl fmt::Display) -> ContractError { + ContractError::CanonicalizationFailed +} + +fn validation_failed(error: ValidationError) -> ContractError { + match error { + ValidationError::NonInteroperableNumber => ContractError::NonInteroperableNumber, + ValidationError::SerializationFailed => ContractError::CanonicalizationFailed, } } @@ -60,27 +74,6 @@ pub(crate) fn validate_json_domain(value: &Value) -> Result<(), ContractError> { } } -fn validate_serialized_domain(value: &serde_value::Value) -> Result<(), ContractError> { - use serde_value::Value::{ - Bool, Bytes, Char, F32, F64, I8, I16, I32, I64, Map, Newtype, Option, Seq, String, U8, U16, - U32, U64, Unit, - }; - - match value { - U64(value) if *value > MAX_SAFE_INTEGER => Err(ContractError::NonInteroperableNumber), - I64(value) if *value < MIN_SAFE_INTEGER || *value > MAX_SAFE_INTEGER as i64 => { - Err(ContractError::NonInteroperableNumber) - } - F32(value) => validate_float(f64::from(*value)), - F64(value) => validate_float(*value), - Option(Some(value)) | Newtype(value) => validate_serialized_domain(value), - Seq(values) => values.iter().try_for_each(validate_serialized_domain), - Map(values) => values.values().try_for_each(validate_serialized_domain), - Bool(_) | U8(_) | U16(_) | U32(_) | U64(_) | I8(_) | I16(_) | I32(_) | I64(_) | Char(_) - | String(_) | Unit | Option(None) | Bytes(_) => Ok(()), - } -} - fn validate_float(value: f64) -> Result<(), ContractError> { if value.is_finite() && (value.fract() != 0.0 @@ -92,6 +85,330 @@ fn validate_float(value: f64) -> Result<(), ContractError> { } } +#[derive(Debug, Clone, Copy)] +enum ValidationError { + NonInteroperableNumber, + SerializationFailed, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonInteroperableNumber => f.write_str("non-interoperable number"), + Self::SerializationFailed => f.write_str("serialization failed"), + } + } +} + +impl std::error::Error for ValidationError {} + +impl ser::Error for ValidationError { + fn custom(_message: T) -> Self { + Self::SerializationFailed + } +} + +#[derive(Clone, Copy)] +struct DomainValidator; + +fn validate_nested(value: &T) -> Result<(), ValidationError> { + value.serialize(DomainValidator) +} + +fn validate_signed(value: i128) -> Result<(), ValidationError> { + if (MIN_SAFE_INTEGER_I128..=MAX_SAFE_INTEGER_I128).contains(&value) { + Ok(()) + } else { + Err(ValidationError::NonInteroperableNumber) + } +} + +fn validate_unsigned(value: u128) -> Result<(), ValidationError> { + if value <= MAX_SAFE_INTEGER as u128 { + Ok(()) + } else { + Err(ValidationError::NonInteroperableNumber) + } +} + +fn validate_serialized_float(value: f64) -> Result<(), ValidationError> { + validate_float(value).map_err(|_| ValidationError::NonInteroperableNumber) +} + +impl Serializer for DomainValidator { + type Ok = (); + type Error = ValidationError; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = Self; + type SerializeMap = Self; + type SerializeStruct = Self; + type SerializeStructVariant = Self; + + fn serialize_bool(self, _value: bool) -> Result { + Ok(()) + } + + fn serialize_i8(self, value: i8) -> Result { + validate_signed(i128::from(value)) + } + + fn serialize_i16(self, value: i16) -> Result { + validate_signed(i128::from(value)) + } + + fn serialize_i32(self, value: i32) -> Result { + validate_signed(i128::from(value)) + } + + fn serialize_i64(self, value: i64) -> Result { + validate_signed(i128::from(value)) + } + + fn serialize_i128(self, value: i128) -> Result { + validate_signed(value) + } + + fn serialize_u8(self, value: u8) -> Result { + validate_unsigned(u128::from(value)) + } + + fn serialize_u16(self, value: u16) -> Result { + validate_unsigned(u128::from(value)) + } + + fn serialize_u32(self, value: u32) -> Result { + validate_unsigned(u128::from(value)) + } + + fn serialize_u64(self, value: u64) -> Result { + validate_unsigned(u128::from(value)) + } + + fn serialize_u128(self, value: u128) -> Result { + validate_unsigned(value) + } + + fn serialize_f32(self, value: f32) -> Result { + validate_serialized_float(f64::from(value)) + } + + fn serialize_f64(self, value: f64) -> Result { + validate_serialized_float(value) + } + + fn serialize_char(self, _value: char) -> Result { + Ok(()) + } + + fn serialize_str(self, _value: &str) -> Result { + Ok(()) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result { + Ok(()) + } + + fn serialize_none(self) -> Result { + Ok(()) + } + + fn serialize_some(self, value: &T) -> Result { + validate_nested(value) + } + + fn serialize_unit(self) -> Result { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + Ok(()) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result { + validate_nested(value) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result { + validate_nested(value) + } + + fn serialize_seq(self, _length: Option) -> Result { + Ok(self) + } + + fn serialize_tuple(self, _length: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Ok(self) + } + + fn serialize_map(self, _length: Option) -> Result { + Ok(self) + } + + fn serialize_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Ok(self) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Ok(self) + } + + fn is_human_readable(&self) -> bool { + true + } +} + +impl SerializeSeq for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeTuple for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeTupleStruct for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeTupleVariant for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeMap for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { + validate_nested(key) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeStruct for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_field( + &mut self, + _key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl SerializeStructVariant for DomainValidator { + type Ok = (); + type Error = ValidationError; + + fn serialize_field( + &mut self, + _key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + validate_nested(value) + } + + fn end(self) -> Result { + Ok(()) + } +} + /// The [`Sha256Digest`] of `value`'s canonical JSON bytes. pub fn digest(value: &T) -> Result { let bytes = canonical_bytes(value)?; @@ -175,6 +492,9 @@ impl From for String { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + + use crate::contracts::ContractError; use crate::digest::{Sha256Digest, canonical_bytes, digest}; use proptest::prelude::*; use serde::Serialize; @@ -225,7 +545,7 @@ mod tests { ] { assert_eq!( canonical_bytes(&value), - Err(crate::contracts::ContractError::NonInteroperableNumber) + Err(ContractError::NonInteroperableNumber) ); } } @@ -251,9 +571,189 @@ mod tests { canonical_bytes(&NestedFloat { values: vec![value], }), - Err(crate::contracts::ContractError::NonInteroperableNumber) + Err(ContractError::NonInteroperableNumber) + ); + } + } + + #[test] + fn canonicalization_validates_i128_and_u128_without_narrowing() { + macro_rules! assert_canonicalizes { + ($($value:expr),+ $(,)?) => { + $(assert!(canonical_bytes(&$value).is_ok());)+ + }; + } + + assert_canonicalizes!( + i8::MIN, + i16::MIN, + i32::MIN, + -9_007_199_254_740_991_i64, + -9_007_199_254_740_991_i128, + 9_007_199_254_740_991_i128, + u8::MAX, + u16::MAX, + u32::MAX, + 9_007_199_254_740_991_u64, + 9_007_199_254_740_991_u128, + ); + + for value in [ + canonical_bytes(&-9_007_199_254_740_992_i128), + canonical_bytes(&9_007_199_254_740_992_i128), + canonical_bytes(&9_007_199_254_740_992_u128), + canonical_bytes(&i128::MIN), + canonical_bytes(&i128::MAX), + canonical_bytes(&u128::MAX), + ] { + assert_eq!(value, Err(ContractError::NonInteroperableNumber)); + } + } + + #[test] + fn canonicalization_validates_numeric_map_keys_before_they_can_collapse() { + #[derive(Serialize)] + struct NestedMap<'a> { + values: BTreeMap, + } + + for key in [9_007_199_254_740_992_u64, 9_007_199_254_740_993_u64] { + let value = NestedMap { + values: BTreeMap::from([(key, "unsafe")]), + }; + assert_eq!( + canonical_bytes(&value), + Err(ContractError::NonInteroperableNumber) ); } + + let safe = BTreeMap::from([ + (-9_007_199_254_740_991_i128, "minimum"), + (9_007_199_254_740_991_i128, "maximum"), + ]); + assert_eq!( + String::from_utf8(canonical_bytes(&safe).unwrap()).unwrap(), + r#"{"-9007199254740991":"minimum","9007199254740991":"maximum"}"# + ); + } + + #[test] + fn canonicalization_traverses_every_compound_serialize_branch() { + const UNSAFE: i128 = 9_007_199_254_740_992; + + #[derive(Serialize)] + struct Struct { + value: i128, + } + + #[derive(Serialize)] + struct Newtype(i128); + + #[derive(Serialize)] + struct TupleStruct(i128, bool); + + #[derive(Serialize)] + enum Enum { + Newtype(i128), + Tuple(bool, i128), + Struct { value: i128 }, + } + + let map_value = BTreeMap::from([("value", UNSAFE)]); + let sequence = vec![UNSAFE]; + let tuple = (false, UNSAFE); + + assert_eq!( + canonical_bytes(&Struct { value: UNSAFE }), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&Newtype(UNSAFE)), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&TupleStruct(UNSAFE, false)), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&Enum::Newtype(UNSAFE)), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&Enum::Tuple(false, UNSAFE)), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&Enum::Struct { value: UNSAFE }), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&map_value), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&sequence), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&tuple), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&Some(UNSAFE)), + Err(ContractError::NonInteroperableNumber) + ); + } + + #[test] + fn canonicalization_errors_do_not_retain_custom_serializer_messages() { + struct MaliciousSerialize; + + impl Serialize for MaliciousSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom(format!( + "SERIALIZER_SENTINEL_{}", + "x".repeat(900_000) + ))) + } + } + + let err = canonical_bytes(&MaliciousSerialize).unwrap_err(); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!(!debug.contains("SERIALIZER_SENTINEL")); + assert!(!display.contains("SERIALIZER_SENTINEL")); + assert!(debug.len() < 256); + assert!(display.len() < 256); + + struct MaliciousSecondPass(std::cell::Cell); + + impl Serialize for MaliciousSecondPass { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.0.replace(true) { + Err(serde::ser::Error::custom(format!( + "SECOND_PASS_SENTINEL_{}", + "x".repeat(900_000) + ))) + } else { + serializer.serialize_unit() + } + } + } + + let err = canonical_bytes(&MaliciousSecondPass(std::cell::Cell::new(false))).unwrap_err(); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!(!debug.contains("SECOND_PASS_SENTINEL")); + assert!(!display.contains("SECOND_PASS_SENTINEL")); + assert!(debug.len() < 256); + assert!(display.len() < 256); } #[test] diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index 6175902..9729601 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -1016,7 +1016,7 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() fn strict_probe_and_document_limit_fail_closed() { assert!(matches!( decode_document(br#"{"schema_version":"psyche.unknown.v1"}"#), - Err(ContractError::UnknownSchema { .. }) + Err(ContractError::UnknownSchema) )); assert!(decode_document(&vec![b' '; 1_048_577]).is_err()); } @@ -1241,3 +1241,50 @@ fn typed_deserialization_cannot_bypass_validation() { }); assert!(serde_json::from_slice::(&mismatched_digest).is_err()); } + +// Attacker-input-redaction tests via decode_document: nearly-1-MiB schema +// strings must not appear in ContractError Debug or Display output. +#[test] +fn decode_document_unknown_schema_does_not_expose_attacker_input() { + let marker = "SENTINEL_DECODE_UNKNOWN"; + let version = format!("psyche.{}{}.v1", marker, "a".repeat(900_000)); + let json = format!(r#"{{"schema_version":{version:?}}}"#); + let err = decode_document(json.as_bytes()).unwrap_err(); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!( + !debug.contains(marker), + "Debug must not contain attacker marker (output len = {})", + debug.len() + ); + assert!( + !display.contains(marker), + "Display must not contain attacker marker (output len = {})", + display.len() + ); + assert!(debug.len() < 256); + assert!(display.len() < 256); +} + +#[test] +fn decode_document_unsupported_major_does_not_expose_attacker_input() { + let marker = "SENTINEL_DECODE_MAJOR"; + // Known kind, non-digit marker in major segment → UnsupportedMajor + let version = format!("psyche.intent.v{}{}", marker, "9".repeat(900_000)); + let json = format!(r#"{{"schema_version":{version:?}}}"#); + let err = decode_document(json.as_bytes()).unwrap_err(); + let debug = format!("{err:?}"); + let display = format!("{err}"); + assert!( + !debug.contains(marker), + "Debug must not contain attacker marker (output len = {})", + debug.len() + ); + assert!( + !display.contains(marker), + "Display must not contain attacker marker (output len = {})", + display.len() + ); + assert!(debug.len() < 256); + assert!(display.len() < 256); +} From 4e75c2da101878a8689c3501e1b465a9c29dd33d Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:59:19 -0500 Subject: [PATCH 07/66] fix(core): harden canonicalization failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 1 — map keys bypass safe-number validation: validate_serialized_domain (serde_value era) iterated only Map values, letting unsafe u64 keys like 9007199254740992 and 9007199254740993 reach serde_json_canonicalizer unchecked; those two values round through f64 to the same 9007199254740992.0, silently collapsing two distinct entries to one JSON key. The DomainValidator serializer (landed in a74266f) already calls serialize_key on every map key, ensuring the same validate_unsigned / validate_signed guard that applies to values is applied to keys. This commit adds the missing TDD evidence: - map_key_collision_pair_rejected_before_f64_canonicalization: both unsafe keys together in one BTreeMap must return NonInteroperableNumber before canonicalization. - map_key_u64_boundary_accepted_and_one_over_rejected: MAX_SAFE_INTEGER as a u64 key is accepted; MAX_SAFE_INTEGER+1 is rejected. - nested_map_unsafe_u64_keys_are_rejected: an unsafe u64 key nested inside a struct field is also rejected; a safe key is accepted. Defect 2 — serializer error text leaks: canonicalization_failed(error) previously stored error.to_string() in ContractError::CanonicalizationFailed { reason }, leaking arbitrary custom-serializer messages into Debug/Display. CanonicalizationFailed is now a payload-free unit variant (a74266f); canonicalization_failed discards the error argument. The existing test canonicalization_errors_do_not_retain_custom_serializer_messages covers both defects: it injects a 900 000-char sentinel and asserts that Debug/Display exclude it and remain under 256 bytes. No other crates match on CanonicalizationFailed { reason }. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/digest.rs | 59 +++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index dafdc92..6269379 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -494,7 +494,7 @@ impl From for String { mod tests { use std::collections::BTreeMap; - use crate::contracts::ContractError; + use crate::contracts::{ContractError, MAX_SAFE_INTEGER}; use crate::digest::{Sha256Digest, canonical_bytes, digest}; use proptest::prelude::*; use serde::Serialize; @@ -637,6 +637,63 @@ mod tests { ); } + /// Both 9007199254740992 and 9007199254740993 round through f64 to the same + /// value (9007199254740992.0), so a map containing both would emit duplicate + /// JSON keys — silently discarding one entry. The validator must reject + /// either key before canonicalization reaches that point. + #[test] + fn map_key_collision_pair_rejected_before_f64_canonicalization() { + let mut map: BTreeMap = BTreeMap::new(); + map.insert(MAX_SAFE_INTEGER + 1, "first"); // 9007199254740992 + map.insert(MAX_SAFE_INTEGER + 2, "second"); // 9007199254740993 + assert_eq!( + canonical_bytes(&map), + Err(ContractError::NonInteroperableNumber), + "both collision-prone keys must be rejected before canonicalization" + ); + } + + /// The exact MAX_SAFE_INTEGER boundary is a valid u64 map key; one past it + /// must be rejected as NonInteroperableNumber. + #[test] + fn map_key_u64_boundary_accepted_and_one_over_rejected() { + let at_boundary: BTreeMap = BTreeMap::from([(MAX_SAFE_INTEGER, "boundary")]); + assert!( + canonical_bytes(&at_boundary).is_ok(), + "u64 MAX_SAFE_INTEGER key must be accepted" + ); + + let one_over: BTreeMap = BTreeMap::from([(MAX_SAFE_INTEGER + 1, "one-over")]); + assert_eq!( + canonical_bytes(&one_over), + Err(ContractError::NonInteroperableNumber), + "u64 key one past MAX_SAFE_INTEGER must be rejected" + ); + } + + /// Key validation must recurse into maps nested inside other structures. + #[test] + fn nested_map_unsafe_u64_keys_are_rejected() { + #[derive(Serialize)] + struct Outer<'a> { + inner: BTreeMap, + } + + let unsafe_outer = Outer { + inner: BTreeMap::from([(MAX_SAFE_INTEGER + 1, "unsafe")]), + }; + assert_eq!( + canonical_bytes(&unsafe_outer), + Err(ContractError::NonInteroperableNumber), + ); + + // A nested map with a safe u64 key must be accepted. + let safe_outer = Outer { + inner: BTreeMap::from([(MAX_SAFE_INTEGER, "safe")]), + }; + assert!(canonical_bytes(&safe_outer).is_ok()); + } + #[test] fn canonicalization_traverses_every_compound_serialize_branch() { const UNSAFE: i128 = 9_007_199_254_740_992; From ba25a69994336c80962cf9ecd8b1d90b80c5432a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:04:17 -0500 Subject: [PATCH 08/66] fix(core): canonicalize one validated serialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/digest.rs | 606 +++++++++++++++++++++++++------ 1 file changed, 495 insertions(+), 111 deletions(-) diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index 6269379..b41e668 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -4,13 +4,14 @@ //! defining property is exercised directly in this module's tests: two JSON //! values that differ only in object key order canonicalize to identical //! bytes, and so hash identically. +use std::collections::BTreeMap; use std::fmt; use std::fmt::Write as _; use serde::Serialize; use serde::ser::{ - self, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, - SerializeTupleStruct, SerializeTupleVariant, Serializer, + self, Impossible, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, + SerializeTuple, SerializeTupleStruct, SerializeTupleVariant, Serializer, }; use serde_json::Value; use sha2::{Digest as _, Sha256}; @@ -27,14 +28,13 @@ const MIN_SAFE_INTEGER_I128: i128 = -MAX_SAFE_INTEGER_I128; /// /// Two values that serialize to the same JSON data but differ in object key /// order produce identical bytes — canonicalisation, not merely -/// serialization, is the point of this function. Every integer emitted by -/// `Serialize`, including map keys, is validated before the original value is -/// passed to the canonicalizer. +/// serialization, is the point of this function. The original value is +/// serialized exactly once into a validated representation, including map +/// keys, before that representation is passed to the canonicalizer. pub fn canonical_bytes(value: &T) -> Result, ContractError> { - value - .serialize(DomainValidator) - .map_err(validation_failed)?; - serde_json_canonicalizer::to_vec(value).map_err(canonicalization_failed) + let collected = value.serialize(ValueCollector).map_err(validation_failed)?; + let canonicalizer_input = collected.into_json().map_err(validation_failed)?; + serde_json_canonicalizer::to_vec(&canonicalizer_input).map_err(canonicalization_failed) } fn canonicalization_failed(_error: impl fmt::Display) -> ContractError { @@ -109,10 +109,42 @@ impl ser::Error for ValidationError { } #[derive(Clone, Copy)] -struct DomainValidator; +struct ValueCollector; + +enum CollectedValue { + Null, + Bool(bool), + Signed(i64), + Unsigned(u64), + Float(f64), + String(String), + Array(Vec), + Object(BTreeMap), +} -fn validate_nested(value: &T) -> Result<(), ValidationError> { - value.serialize(DomainValidator) +impl CollectedValue { + fn into_json(self) -> Result { + match self { + Self::Null => Ok(Value::Null), + Self::Bool(value) => Ok(Value::Bool(value)), + Self::Signed(value) => Ok(Value::Number(value.into())), + Self::Unsigned(value) => Ok(Value::Number(value.into())), + Self::Float(value) => serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or(ValidationError::NonInteroperableNumber), + Self::String(value) => Ok(Value::String(value)), + Self::Array(values) => values + .into_iter() + .map(Self::into_json) + .collect::, _>>() + .map(Value::Array), + Self::Object(values) => values + .into_iter() + .map(|(key, value)| Ok((key, value.into_json()?))) + .collect::, _>>() + .map(Value::Object), + } + } } fn validate_signed(value: i128) -> Result<(), ValidationError> { @@ -135,104 +167,111 @@ fn validate_serialized_float(value: f64) -> Result<(), ValidationError> { validate_float(value).map_err(|_| ValidationError::NonInteroperableNumber) } -impl Serializer for DomainValidator { - type Ok = (); +impl Serializer for ValueCollector { + type Ok = CollectedValue; type Error = ValidationError; - type SerializeSeq = Self; - type SerializeTuple = Self; - type SerializeTupleStruct = Self; - type SerializeTupleVariant = Self; - type SerializeMap = Self; - type SerializeStruct = Self; - type SerializeStructVariant = Self; - - fn serialize_bool(self, _value: bool) -> Result { - Ok(()) + type SerializeSeq = SequenceCollector; + type SerializeTuple = SequenceCollector; + type SerializeTupleStruct = SequenceCollector; + type SerializeTupleVariant = SequenceCollector; + type SerializeMap = ObjectCollector; + type SerializeStruct = ObjectCollector; + type SerializeStructVariant = ObjectCollector; + + fn serialize_bool(self, value: bool) -> Result { + Ok(CollectedValue::Bool(value)) } fn serialize_i8(self, value: i8) -> Result { - validate_signed(i128::from(value)) + collect_signed(i128::from(value)) } fn serialize_i16(self, value: i16) -> Result { - validate_signed(i128::from(value)) + collect_signed(i128::from(value)) } fn serialize_i32(self, value: i32) -> Result { - validate_signed(i128::from(value)) + collect_signed(i128::from(value)) } fn serialize_i64(self, value: i64) -> Result { - validate_signed(i128::from(value)) + collect_signed(i128::from(value)) } fn serialize_i128(self, value: i128) -> Result { - validate_signed(value) + collect_signed(value) } fn serialize_u8(self, value: u8) -> Result { - validate_unsigned(u128::from(value)) + collect_unsigned(u128::from(value)) } fn serialize_u16(self, value: u16) -> Result { - validate_unsigned(u128::from(value)) + collect_unsigned(u128::from(value)) } fn serialize_u32(self, value: u32) -> Result { - validate_unsigned(u128::from(value)) + collect_unsigned(u128::from(value)) } fn serialize_u64(self, value: u64) -> Result { - validate_unsigned(u128::from(value)) + collect_unsigned(u128::from(value)) } fn serialize_u128(self, value: u128) -> Result { - validate_unsigned(value) + collect_unsigned(value) } fn serialize_f32(self, value: f32) -> Result { - validate_serialized_float(f64::from(value)) + collect_float(f64::from(value)) } fn serialize_f64(self, value: f64) -> Result { - validate_serialized_float(value) + collect_float(value) } - fn serialize_char(self, _value: char) -> Result { - Ok(()) + fn serialize_char(self, value: char) -> Result { + Ok(CollectedValue::String(value.to_string())) } - fn serialize_str(self, _value: &str) -> Result { - Ok(()) + fn serialize_str(self, value: &str) -> Result { + Ok(CollectedValue::String(value.to_owned())) } - fn serialize_bytes(self, _value: &[u8]) -> Result { - Ok(()) + fn serialize_bytes(self, value: &[u8]) -> Result { + Ok(CollectedValue::Array( + value + .iter() + .copied() + .map(u64::from) + .map(CollectedValue::Unsigned) + .collect(), + )) } fn serialize_none(self) -> Result { - Ok(()) + Ok(CollectedValue::Null) } fn serialize_some(self, value: &T) -> Result { - validate_nested(value) + value.serialize(self) } fn serialize_unit(self) -> Result { - Ok(()) + Ok(CollectedValue::Null) } fn serialize_unit_struct(self, _name: &'static str) -> Result { - Ok(()) + Ok(CollectedValue::Null) } fn serialize_unit_variant( self, _name: &'static str, _variant_index: u32, - _variant: &'static str, + variant: &'static str, ) -> Result { - Ok(()) + Ok(CollectedValue::String(variant.to_owned())) } fn serialize_newtype_struct( @@ -240,47 +279,47 @@ impl Serializer for DomainValidator { _name: &'static str, value: &T, ) -> Result { - validate_nested(value) + value.serialize(self) } fn serialize_newtype_variant( self, _name: &'static str, _variant_index: u32, - _variant: &'static str, + variant: &'static str, value: &T, ) -> Result { - validate_nested(value) + singleton_object(variant, value.serialize(self)?) } - fn serialize_seq(self, _length: Option) -> Result { - Ok(self) + fn serialize_seq(self, length: Option) -> Result { + Ok(SequenceCollector::new(length.unwrap_or(0), None)) } - fn serialize_tuple(self, _length: usize) -> Result { - Ok(self) + fn serialize_tuple(self, length: usize) -> Result { + Ok(SequenceCollector::new(length, None)) } fn serialize_tuple_struct( self, _name: &'static str, - _length: usize, + length: usize, ) -> Result { - Ok(self) + Ok(SequenceCollector::new(length, None)) } fn serialize_tuple_variant( self, _name: &'static str, _variant_index: u32, - _variant: &'static str, - _length: usize, + variant: &'static str, + length: usize, ) -> Result { - Ok(self) + Ok(SequenceCollector::new(length, Some(variant))) } fn serialize_map(self, _length: Option) -> Result { - Ok(self) + Ok(ObjectCollector::new(None)) } fn serialize_struct( @@ -288,17 +327,17 @@ impl Serializer for DomainValidator { _name: &'static str, _length: usize, ) -> Result { - Ok(self) + Ok(ObjectCollector::new(None)) } fn serialize_struct_variant( self, _name: &'static str, _variant_index: u32, - _variant: &'static str, + variant: &'static str, _length: usize, ) -> Result { - Ok(self) + Ok(ObjectCollector::new(Some(variant))) } fn is_human_readable(&self) -> bool { @@ -306,109 +345,391 @@ impl Serializer for DomainValidator { } } -impl SerializeSeq for DomainValidator { - type Ok = (); +fn collect_signed(value: i128) -> Result { + validate_signed(value)?; + let value = i64::try_from(value).map_err(|_| ValidationError::NonInteroperableNumber)?; + Ok(CollectedValue::Signed(value)) +} + +fn collect_unsigned(value: u128) -> Result { + validate_unsigned(value)?; + let value = u64::try_from(value).map_err(|_| ValidationError::NonInteroperableNumber)?; + Ok(CollectedValue::Unsigned(value)) +} + +fn collect_float(value: f64) -> Result { + validate_serialized_float(value)?; + Ok(CollectedValue::Float(value)) +} + +fn singleton_object(key: &str, value: CollectedValue) -> Result { + Ok(CollectedValue::Object(BTreeMap::from([( + key.to_owned(), + value, + )]))) +} + +struct SequenceCollector { + values: Vec, + variant: Option<&'static str>, +} + +impl SequenceCollector { + fn new(_length: usize, variant: Option<&'static str>) -> Self { + Self { + values: Vec::new(), + variant, + } + } + + fn push(&mut self, value: &T) -> Result<(), ValidationError> { + self.values.push(value.serialize(ValueCollector)?); + Ok(()) + } + + fn finish(self) -> Result { + let value = CollectedValue::Array(self.values); + match self.variant { + Some(variant) => singleton_object(variant, value), + None => Ok(value), + } + } +} + +impl SerializeSeq for SequenceCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { - validate_nested(value) + self.push(value) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeTuple for DomainValidator { - type Ok = (); +impl SerializeTuple for SequenceCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { - validate_nested(value) + self.push(value) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeTupleStruct for DomainValidator { - type Ok = (); +impl SerializeTupleStruct for SequenceCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { - validate_nested(value) + self.push(value) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeTupleVariant for DomainValidator { - type Ok = (); +impl SerializeTupleVariant for SequenceCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { - validate_nested(value) + self.push(value) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeMap for DomainValidator { - type Ok = (); +struct ObjectCollector { + values: BTreeMap, + next_key: Option, + variant: Option<&'static str>, +} + +impl ObjectCollector { + fn new(variant: Option<&'static str>) -> Self { + Self { + values: BTreeMap::new(), + next_key: None, + variant, + } + } + + fn insert(&mut self, key: String, value: CollectedValue) -> Result<(), ValidationError> { + if self.values.insert(key, value).is_some() { + Err(ValidationError::SerializationFailed) + } else { + Ok(()) + } + } + + fn finish(self) -> Result { + if self.next_key.is_some() { + return Err(ValidationError::SerializationFailed); + } + let value = CollectedValue::Object(self.values); + match self.variant { + Some(variant) => singleton_object(variant, value), + None => Ok(value), + } + } +} + +impl SerializeMap for ObjectCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { - validate_nested(key) + if self.next_key.is_some() { + return Err(ValidationError::SerializationFailed); + } + self.next_key = Some(key.serialize(MapKeyCollector)?); + Ok(()) } fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { - validate_nested(value) + let key = self + .next_key + .take() + .ok_or(ValidationError::SerializationFailed)?; + self.insert(key, value.serialize(ValueCollector)?) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeStruct for DomainValidator { - type Ok = (); +impl SerializeStruct for ObjectCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_field( &mut self, - _key: &'static str, + key: &'static str, value: &T, ) -> Result<(), Self::Error> { - validate_nested(value) + self.insert(key.to_owned(), value.serialize(ValueCollector)?) } fn end(self) -> Result { - Ok(()) + self.finish() } } -impl SerializeStructVariant for DomainValidator { - type Ok = (); +impl SerializeStructVariant for ObjectCollector { + type Ok = CollectedValue; type Error = ValidationError; fn serialize_field( &mut self, - _key: &'static str, + key: &'static str, value: &T, ) -> Result<(), Self::Error> { - validate_nested(value) + self.insert(key.to_owned(), value.serialize(ValueCollector)?) } fn end(self) -> Result { - Ok(()) + self.finish() } } +#[derive(Clone, Copy)] +struct MapKeyCollector; + +impl Serializer for MapKeyCollector { + type Ok = String; + type Error = ValidationError; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; + + fn serialize_bool(self, value: bool) -> Result { + Ok(value.to_string()) + } + + fn serialize_i8(self, value: i8) -> Result { + collect_signed_key(i128::from(value)) + } + + fn serialize_i16(self, value: i16) -> Result { + collect_signed_key(i128::from(value)) + } + + fn serialize_i32(self, value: i32) -> Result { + collect_signed_key(i128::from(value)) + } + + fn serialize_i64(self, value: i64) -> Result { + collect_signed_key(i128::from(value)) + } + + fn serialize_i128(self, value: i128) -> Result { + collect_signed_key(value) + } + + fn serialize_u8(self, value: u8) -> Result { + collect_unsigned_key(u128::from(value)) + } + + fn serialize_u16(self, value: u16) -> Result { + collect_unsigned_key(u128::from(value)) + } + + fn serialize_u32(self, value: u32) -> Result { + collect_unsigned_key(u128::from(value)) + } + + fn serialize_u64(self, value: u64) -> Result { + collect_unsigned_key(u128::from(value)) + } + + fn serialize_u128(self, value: u128) -> Result { + collect_unsigned_key(value) + } + + fn serialize_f32(self, value: f32) -> Result { + validate_serialized_float(f64::from(value))?; + serde_json_canonicalizer::to_string(&value) + .map_err(|_| ValidationError::SerializationFailed) + } + + fn serialize_f64(self, value: f64) -> Result { + validate_serialized_float(value)?; + serde_json_canonicalizer::to_string(&value) + .map_err(|_| ValidationError::SerializationFailed) + } + + fn serialize_char(self, value: char) -> Result { + Ok(value.to_string()) + } + + fn serialize_str(self, value: &str) -> Result { + Ok(value.to_owned()) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_none(self) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + Ok(variant.to_owned()) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_seq(self, _length: Option) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple(self, _length: usize) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_map(self, _length: Option) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn is_human_readable(&self) -> bool { + true + } +} + +fn collect_signed_key(value: i128) -> Result { + validate_signed(value)?; + Ok(value.to_string()) +} + +fn collect_unsigned_key(value: u128) -> Result { + validate_unsigned(value)?; + Ok(value.to_string()) +} + /// The [`Sha256Digest`] of `value`'s canonical JSON bytes. pub fn digest(value: &T) -> Result { let bytes = canonical_bytes(value)?; @@ -637,6 +958,35 @@ mod tests { ); } + #[test] + fn canonicalization_validates_float_map_keys_before_string_conversion() { + struct FloatKeyMap(f64); + + impl Serialize for FloatKeyMap { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + serde::ser::SerializeMap::serialize_entry(&mut map, &self.0, "value")?; + serde::ser::SerializeMap::end(map) + } + } + + assert_eq!( + canonical_bytes(&FloatKeyMap(1.5)).unwrap(), + br#"{"1.5":"value"}"# + ); + assert_eq!( + canonical_bytes(&FloatKeyMap(9_007_199_254_740_992.0)), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!( + canonical_bytes(&FloatKeyMap(f64::NAN)), + Err(ContractError::NonInteroperableNumber) + ); + } + /// Both 9007199254740992 and 9007199254740993 round through f64 to the same /// value (9007199254740992.0), so a map containing both would emit duplicate /// JSON keys — silently discarding one entry. The validator must reject @@ -785,32 +1135,66 @@ mod tests { assert!(!display.contains("SERIALIZER_SENTINEL")); assert!(debug.len() < 256); assert!(display.len() < 256); + } - struct MaliciousSecondPass(std::cell::Cell); + #[test] + fn canonicalization_serializes_stateful_values_exactly_once() { + use std::cell::Cell; - impl Serialize for MaliciousSecondPass { + struct StatefulInteger { + invocations: Cell, + unsafe_first: bool, + } + + impl Serialize for StatefulInteger { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { - if self.0.replace(true) { - Err(serde::ser::Error::custom(format!( - "SECOND_PASS_SENTINEL_{}", - "x".repeat(900_000) - ))) - } else { - serializer.serialize_unit() + let invocation = self.invocations.get(); + self.invocations.set(invocation + 1); + match (self.unsafe_first, invocation) { + (true, 0) => serializer.serialize_u64(MAX_SAFE_INTEGER + 1), + (false, 0) => serializer.serialize_u64(0), + (_, 1) => serializer.serialize_u64(MAX_SAFE_INTEGER + 1), + _ => serializer.serialize_u64(MAX_SAFE_INTEGER + 2), } } } - let err = canonical_bytes(&MaliciousSecondPass(std::cell::Cell::new(false))).unwrap_err(); - let debug = format!("{err:?}"); - let display = format!("{err}"); - assert!(!debug.contains("SECOND_PASS_SENTINEL")); - assert!(!display.contains("SECOND_PASS_SENTINEL")); - assert!(debug.len() < 256); - assert!(display.len() < 256); + let safe_first = StatefulInteger { + invocations: Cell::new(0), + unsafe_first: false, + }; + assert_eq!(canonical_bytes(&safe_first).unwrap(), b"0"); + assert_eq!(safe_first.invocations.get(), 1); + + let unsafe_first = StatefulInteger { + invocations: Cell::new(0), + unsafe_first: true, + }; + assert_eq!( + canonical_bytes(&unsafe_first), + Err(ContractError::NonInteroperableNumber) + ); + assert_eq!(unsafe_first.invocations.get(), 1); + } + + #[test] + fn canonicalization_does_not_trust_compound_length_hints() { + struct UntrustedLengthHint; + + impl Serialize for UntrustedLengthHint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let sequence = serializer.serialize_seq(Some(usize::MAX))?; + serde::ser::SerializeSeq::end(sequence) + } + } + + assert_eq!(canonical_bytes(&UntrustedLengthHint).unwrap(), b"[]"); } #[test] From 830dbcb0529b4be9b56935146621d575be861f17 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:06:25 -0500 Subject: [PATCH 09/66] fix(core): canonicalize each source once Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/digest.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index b41e668..a400d84 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -920,6 +920,9 @@ mod tests { ); for value in [ + canonical_bytes(&-9_007_199_254_740_992_i64), + canonical_bytes(&9_007_199_254_740_992_i64), + canonical_bytes(&9_007_199_254_740_992_u64), canonical_bytes(&-9_007_199_254_740_992_i128), canonical_bytes(&9_007_199_254_740_992_i128), canonical_bytes(&9_007_199_254_740_992_u128), @@ -958,6 +961,39 @@ mod tests { ); } + #[test] + fn canonicalization_preserves_every_integer_map_key_type() { + macro_rules! assert_key_canonicalizes { + ($value:expr) => {{ + let map = BTreeMap::from([($value, "value")]); + let canonical = String::from_utf8(canonical_bytes(&map).unwrap()).unwrap(); + assert_eq!(canonical, format!(r#"{{"{}":"value"}}"#, $value)); + }}; + } + + assert_key_canonicalizes!(-1_i8); + assert_key_canonicalizes!(-2_i16); + assert_key_canonicalizes!(-3_i32); + assert_key_canonicalizes!(-9_007_199_254_740_991_i64); + assert_key_canonicalizes!(9_007_199_254_740_991_i128); + assert_key_canonicalizes!(u8::MAX); + assert_key_canonicalizes!(u16::MAX); + assert_key_canonicalizes!(u32::MAX); + assert_key_canonicalizes!(9_007_199_254_740_991_u64); + assert_key_canonicalizes!(9_007_199_254_740_991_u128); + } + + #[test] + fn canonicalization_rejects_unsafe_i128_and_u128_map_keys() { + for result in [ + canonical_bytes(&BTreeMap::from([(-9_007_199_254_740_992_i128, "unsafe")])), + canonical_bytes(&BTreeMap::from([(9_007_199_254_740_992_i128, "unsafe")])), + canonical_bytes(&BTreeMap::from([(9_007_199_254_740_992_u128, "unsafe")])), + ] { + assert_eq!(result, Err(ContractError::NonInteroperableNumber)); + } + } + #[test] fn canonicalization_validates_float_map_keys_before_string_conversion() { struct FloatKeyMap(f64); From 4309a95398520e3897edab1de76cabda053e5820 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:25:21 -0500 Subject: [PATCH 10/66] feat(core): fail closed on unsupported records Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/mod.rs | 457 +++++++++++++++++- crates/psyche-core/src/digest.rs | 10 + crates/psyche-core/tests/contracts.rs | 13 +- crates/psyche-core/tests/decode.rs | 365 ++++++++++++++ .../fixtures/error-storage-unavailable.json | 13 + 5 files changed, 834 insertions(+), 24 deletions(-) create mode 100644 crates/psyche-core/tests/decode.rs create mode 100644 crates/psyche-core/tests/fixtures/error-storage-unavailable.json diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index da7ac27..4903eb5 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -6,12 +6,15 @@ //! Task 2 stops at these primitives: no `records`, no `CanonicalDocument`, no //! store validation, and no `QuarantineId` — those are store-owned and land //! in later tasks (`QuarantineId` explicitly in Task 7). +use std::collections::HashSet; use std::fmt; use std::str::FromStr; use serde::Serialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde_json::Value; +use crate::digest::Sha256Digest; use crate::id::RecordId; macro_rules! validated_struct { @@ -82,8 +85,13 @@ pub enum ContractError { /// The kind is known but this build does not accept the declared major. /// The rejected string is intentionally not retained: attacker-controlled /// schema text must not propagate into error payloads or logs. - #[error("schema version declares an unsupported major")] - UnsupportedMajor, + #[error("schema version declares unsupported major {found}; supported major is {supported}")] + UnsupportedMajor { + /// Parsed unsupported major. Malformed major syntax is reported as zero. + found: u16, + /// Major accepted by this build. + supported: u16, + }, /// A record identifier did not carry the exact prefix its requested /// `RecordKind` requires. The required prefix is [`RecordKind::prefix`], /// not stored redundantly on this error. @@ -348,6 +356,9 @@ impl SchemaKind { /// presently at major 1, and a kind reaching major 2 is a deliberate, /// reviewed change to this file rather than a silent range extension. const SUPPORTED_MAJOR: u16 = 1; +const MAX_JSON_DEPTH: usize = 64; +const MAX_REJECTED_PAYLOAD_BYTES: usize = 64 * 1024; +const MAX_RETAINED_SCHEMA_VERSION_BYTES: usize = 128; /// A validated `psyche..v` contract schema version. /// @@ -388,10 +399,13 @@ impl SchemaVersion { let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; - let unsupported_major = || ContractError::UnsupportedMajor; + let unsupported_major = |found| ContractError::UnsupportedMajor { + found, + supported: SUPPORTED_MAJOR, + }; let digits = major_segment .strip_prefix('v') - .ok_or_else(unsupported_major)?; + .ok_or_else(|| unsupported_major(0))?; // Reject a leading zero on a multi-digit major ("v01"): it parses to // the same integer as "v1" but is not the canonical string, and the // registry only accepts the canonical form. @@ -399,11 +413,11 @@ impl SchemaVersion { || (digits.len() > 1 && digits.starts_with('0')) || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(unsupported_major()); + return Err(unsupported_major(0)); } - let major: u16 = digits.parse().map_err(|_| unsupported_major())?; + let major: u16 = digits.parse().map_err(|_| unsupported_major(0))?; if major != SUPPORTED_MAJOR { - return Err(unsupported_major()); + return Err(unsupported_major(major)); } Ok(SchemaVersion { kind, major }) } @@ -445,6 +459,78 @@ pub trait VersionedRecord: Serialize { fn record_id(&self) -> &RecordId; } +/// Payload-light classification attached to bytes retained for quarantine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectionReason { + /// The raw input exceeded [`MAX_DOCUMENT_BYTES`]. + TooLarge, + /// The schema kind is not in this build's registry. + UnknownSchema, + /// The schema kind is known but its major is unsupported. + UnsupportedMajor { + /// Parsed unsupported major. + found: u16, + /// Major accepted by this build. + supported: u16, + }, + /// A typed enum field used an unknown spelling. + UnknownEnumValue { + /// Schema containing the enum. + schema: SchemaKind, + /// Static field path. + field: &'static str, + }, + /// JSON or typed document shape was invalid. + InvalidShape { + /// Schema associated with the failure. + schema: SchemaKind, + /// Static field or validation category. + field: &'static str, + }, +} + +/// Bounded raw input and metadata suitable for a later quarantine store. +#[derive(Clone, PartialEq, Eq)] +pub struct RejectedDocument { + /// Safely bounded schema text, when a strict JSON parse can extract it. + pub schema_version: Option, + /// SHA-256 over the complete raw input, including bytes not retained. + pub payload_digest: Sha256Digest, + /// At most 64 KiB from the beginning of the raw input. + pub bounded_payload: Vec, + /// Payload-light rejection classification. + pub reason: RejectionReason, +} + +impl RejectedDocument { + /// Builds quarantine input without requiring valid UTF-8 or valid JSON. + pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { + let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { + strict_json(bytes) + .ok() + .and_then(|value| retained_schema_version(&value)) + } else { + None + }; + Self { + schema_version, + payload_digest: Sha256Digest::from_raw_bytes(bytes), + bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), + reason, + } + } +} + +impl fmt::Debug for RejectedDocument { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RejectedDocument") + .field("bounded_payload_bytes", &self.bounded_payload.len()) + .field("payload_digest", &self.payload_digest) + .field("reason", &self.reason) + .finish() + } +} + /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] @@ -561,15 +647,12 @@ pub fn decode_document(bytes: &[u8]) -> Result if bytes.len() > MAX_DOCUMENT_BYTES { return Err(ContractError::DocumentTooLarge); } - let value: Value = - serde_json::from_slice(bytes).map_err(|_| invalid(SchemaKind::Error, "json"))?; + let value = strict_json(bytes)?; crate::digest::validate_json_domain(&value)?; - let schema_text = value - .as_object() - .and_then(|v| v.get("schema_version")) - .and_then(Value::as_str) - .ok_or_else(|| invalid(SchemaKind::Error, "schema_version"))?; - let schema = SchemaVersion::parse(schema_text)?; + let probe: VersionProbe = serde_json::from_value(value.clone()) + .map_err(|_| invalid(SchemaKind::Error, "schema_version"))?; + let schema = SchemaVersion::parse(&probe.schema_version)?; + inspect_typed_enums(&value, schema.kind)?; let document = match schema.kind { SchemaKind::IdentitySnapshot => { decode(value, CanonicalDocument::IdentitySnapshot, schema.kind)? @@ -590,14 +673,346 @@ pub fn decode_document(bytes: &[u8]) -> Result SchemaKind::Addon => decode(value, CanonicalDocument::Addon, schema.kind)?, SchemaKind::SurfaceEffect => decode(value, CanonicalDocument::SurfaceEffect, schema.kind)?, SchemaKind::Delivery => decode(value, CanonicalDocument::Delivery, schema.kind)?, - SchemaKind::Error => { - return ErrorEnvelope::decode(value).map(CanonicalDocument::Error); - } + SchemaKind::Error => CanonicalDocument::Error(ErrorEnvelope::decode(value)?), }; document.validate()?; Ok(document) } +#[derive(serde::Deserialize)] +struct VersionProbe { + schema_version: String, +} + +fn strict_json(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValueSeed { depth: 0 } + .deserialize(&mut deserializer) + .map_err(|_| invalid(SchemaKind::Error, "json"))?; + deserializer + .end() + .map_err(|_| invalid(SchemaKind::Error, "json"))?; + Ok(value) +} + +#[derive(Clone, Copy)] +struct StrictValueSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictValueSeed { + type Value = Value; + + fn deserialize>( + self, + deserializer: D, + ) -> Result { + if self.depth > MAX_JSON_DEPTH { + return Err(serde::de::Error::custom("JSON nesting limit exceeded")); + } + deserializer.deserialize_any(StrictValueVisitor { depth: self.depth }) + } +} + +struct StrictValueVisitor { + depth: usize, +} + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(value.into())) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(value.into())) + } + + fn visit_f64(self, value: f64) -> Result { + serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| serde::de::Error::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + fn visit_none(self) -> Result { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_some>( + self, + deserializer: D, + ) -> Result { + StrictValueSeed { depth: self.depth }.deserialize(deserializer) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); + while let Some(value) = sequence.next_element_seed(StrictValueSeed { + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut object: A) -> Result { + let mut keys = HashSet::with_capacity(object.size_hint().unwrap_or(0)); + let mut values = serde_json::Map::with_capacity(object.size_hint().unwrap_or(0)); + while let Some(key) = object.next_key::()? { + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom("duplicate JSON object key")); + } + let value = object.next_value_seed(StrictValueSeed { + depth: self.depth + 1, + })?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +fn retained_schema_version(value: &Value) -> Option { + let schema = value.as_object()?.get("schema_version")?.as_str()?; + if schema.len() <= MAX_RETAINED_SCHEMA_VERSION_BYTES + && schema.starts_with("psyche.") + && schema.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_') + }) + { + Some(schema.to_owned()) + } else { + None + } +} + +fn inspect_typed_enums(value: &Value, schema: SchemaKind) -> Result<(), ContractError> { + match schema { + SchemaKind::Graph => inspect_enum( + value, + &["state"], + &[ + "draft", + "admitted", + "rejected", + "running", + "waiting_approval", + "waiting_evidence", + "cancelling", + "completed", + "failed", + "cancelled", + "recovery_required", + ], + schema, + "state", + ), + SchemaKind::GraphNode => inspect_enum( + value, + &["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", + ], + schema, + "state", + ), + SchemaKind::ExecutionBinding => { + inspect_enum( + value, + &["adoption_state"], + &[ + "not_submitted", + "submitting", + "adopted", + "proven_not_adopted", + "adoption_unknown", + "fenced", + ], + schema, + "adoption_state", + )?; + inspect_enum( + value, + &["cancellation_state"], + &[ + "not_requested", + "termination_requested", + "acknowledged_terminated", + "acknowledged_already_terminal", + "termination_unknown", + ], + schema, + "cancellation_state", + )?; + inspect_enum( + value, + &["cancellation_acknowledgement", "kind"], + &["terminated", "already_authoritatively_terminal"], + schema, + "cancellation_acknowledgement.kind", + ) + } + SchemaKind::Delivery => { + inspect_enum( + value, + &["relationship"], + &[ + "reply_same_dm", + "reply_same_group", + "reply_same_topic", + "cross_chat", + "broadcast", + ], + schema, + "relationship", + )?; + inspect_enum( + value, + &["state"], + &[ + "ready", + "sending", + "sent", + "retryable", + "delivery_unknown", + "failed", + "abandoned", + "dead_letter", + "resolving_unknown", + "compensated", + ], + schema, + "state", + )?; + inspect_enum( + value, + &["surface_decision", "state"], + &["reserved", "consumed"], + schema, + "surface_decision.state", + ) + } + SchemaKind::Error => inspect_enum( + value, + &["error", "code"], + &[ + "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", + ], + schema, + "code", + ), + SchemaKind::IdentitySnapshot + | SchemaKind::Intent + | SchemaKind::SurfaceEvent + | SchemaKind::Delegation + | SchemaKind::Budget + | SchemaKind::Approval + | SchemaKind::Evidence + | SchemaKind::Verdict + | SchemaKind::Recovery + | SchemaKind::Addon + | SchemaKind::SurfaceEffect => Ok(()), + } +} + +fn inspect_enum( + value: &Value, + path: &[&str], + accepted: &[&str], + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + let mut current = value; + for segment in path { + let Some(next) = current.as_object().and_then(|object| object.get(*segment)) else { + return Ok(()); + }; + current = next; + } + if let Some(spelling) = current.as_str() { + if !accepted.contains(&spelling) { + return Err(ContractError::UnknownEnumValue { schema, field }); + } + } + Ok(()) +} + fn decode( value: Value, wrap: impl FnOnce(T) -> CanonicalDocument, @@ -764,7 +1179,7 @@ mod tests { let attacker = format!("psyche.intent.v{}{}", marker, "9".repeat(900_000)); let err = SchemaVersion::parse(&attacker).unwrap_err(); assert!( - matches!(err, ContractError::UnsupportedMajor), + matches!(err, ContractError::UnsupportedMajor { .. }), "expected UnsupportedMajor, got {err:?}" ); let debug = format!("{err:?}"); @@ -838,7 +1253,7 @@ mod tests { #[test] fn schema_version_rejects_a_known_kind_with_the_wrong_major() { let err = SchemaVersion::parse("psyche.intent.v2").unwrap_err(); - assert!(matches!(err, ContractError::UnsupportedMajor)); + assert!(matches!(err, ContractError::UnsupportedMajor { .. })); } #[test] @@ -889,7 +1304,7 @@ mod tests { ); } else { assert!( - matches!(err, ContractError::UnsupportedMajor), + matches!(err, ContractError::UnsupportedMajor { .. }), "expected UnsupportedMajor for {near:?}, got {err:?}" ); } diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index a400d84..10a04fc 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -779,6 +779,16 @@ impl Sha256Digest { Ok(Sha256Digest(value.to_string())) } + pub(crate) fn from_raw_bytes(bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes); + Self(format!( + "{}{}", + Self::PREFIX, + to_lower_hex(hasher.finalize().as_slice()) + )) + } + /// The full digest string, e.g. `"sha256:<64 lowercase hex chars>"`. pub fn as_str(&self) -> &str { &self.0 diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index 9729601..f2d028a 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -965,9 +965,6 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ("acknowledgement timestamp", |value: &mut Value| { value["cancellation_acknowledgement"]["acknowledged_at"] = json!("tomorrow"); }), - ("acknowledgement kind", |value: &mut Value| { - value["cancellation_acknowledgement"]["kind"] = json!("future_kind"); - }), ("termination request id", |value: &mut Value| { value["termination_request"]["termination_request_id"] = json!("not-a-request"); }), @@ -999,6 +996,16 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ); } + let mut unknown_kind = binding_value(CancellationState::AcknowledgedTerminated); + unknown_kind["cancellation_acknowledgement"]["kind"] = json!("future_kind"); + assert!(matches!( + decode_document(&serde_json::to_vec(&unknown_kind).unwrap()), + Err(ContractError::UnknownEnumValue { + schema: SchemaKind::ExecutionBinding, + field: "cancellation_acknowledgement.kind", + }) + )); + let mut unknown = binding_value(CancellationState::TerminationRequested); unknown["cancellation_state"] = json!("cancelled"); assert!(decode_document(&serde_json::to_vec(&unknown).unwrap()).is_err()); diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs new file mode 100644 index 0000000..32f70f8 --- /dev/null +++ b/crates/psyche-core/tests/decode.rs @@ -0,0 +1,365 @@ +//! Fail-closed canonical document decoding and quarantine-input tests. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use psyche_core::contracts::{ + CanonicalDocument, ContractError, MAX_DOCUMENT_BYTES, RejectedDocument, RejectionReason, + SchemaKind, decode_document, +}; +use serde_json::{Value, json}; + +const ULID_A: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const ULID_B: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; +const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn assert_redacted(error: &ContractError, rejected_value: &str) { + assert!(!format!("{error:?}").contains(rejected_value)); + assert!(!error.to_string().contains(rejected_value)); +} + +fn graph() -> Value { + json!({ + "schema_version": "psyche.graph.v1", + "graph_id": format!("grf_{ULID_A}"), + "root_intent_id": format!("int_{ULID_A}"), + "owner_principal_id": "principal:one", + "policy_revision": "policy:one", + "state": "draft", + "version": 1 + }) +} + +fn execution_binding() -> Value { + json!({ + "schema_version": "psyche.execution_binding.v1", + "attempt_id": format!("att_{ULID_A}"), + "revision": 1, + "previous_revision_digest": null, + "revision_created_at": "2026-08-01T00:00:00Z", + "familiar_snapshot_id": format!("ids_{ULID_B}"), + "project_id": "project:one", + "request_id": format!("req_{ULID_A}"), + "request_digest": DIGEST, + "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": 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 + }) +} + +#[test] +fn unknown_major_never_decodes_as_a_known_record() { + let secret = "intent-secret-sentinel"; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v2","intent_id":"{secret}","raw":"{secret}"}}"# + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert_eq!( + error, + ContractError::UnsupportedMajor { + found: 2, + supported: 1, + } + ); + assert_redacted(&error, secret); +} + +#[test] +fn malformed_payload_is_bounded_before_quarantine() { + let bytes = vec![b'x'; MAX_DOCUMENT_BYTES + 1]; + assert_eq!( + decode_document(&bytes), + Err(ContractError::DocumentTooLarge) + ); + let rejected = RejectedDocument::from_bytes(&bytes, RejectionReason::TooLarge); + assert_eq!(rejected.bounded_payload.len(), 64 * 1024); +} + +#[test] +fn recognized_error_envelope_decodes_exhaustively() { + let document = + decode_document(&fixture("error-storage-unavailable.json")).expect("error fixture"); + assert!(matches!(document, CanonicalDocument::Error(_))); + assert_eq!(document.schema_version().kind, SchemaKind::Error); +} + +#[test] +fn error_non_string_details_is_invalid_shape() { + let mut value: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + value["error"]["details"]["attempt"] = json!(3); + assert!(matches!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + }) + )); +} + +#[test] +fn error_unknown_field_is_invalid_shape() { + let mut value: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + value["error"]["secret"] = json!("must-not-leak"); + assert!(matches!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + }) + )); +} + +#[test] +fn unknown_typed_enum_is_a_quarantinable_decode_failure() { + let rejected_value = "future_state_secret"; + let mut value = graph(); + value["state"] = json!(rejected_value); + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!( + error, + ContractError::UnknownEnumValue { + schema: SchemaKind::Graph, + field: "state", + } + ); + assert_redacted(&error, rejected_value); +} + +#[test] +fn every_typed_enum_reports_its_static_field_without_the_rejected_value() { + let rejected = "future_enum_secret"; + let mut cases = Vec::new(); + + let mut node: Value = serde_json::from_slice(&fixture("node-root.json")).unwrap(); + node["state"] = json!(rejected); + cases.push((node, SchemaKind::GraphNode, "state")); + + let mut adoption = execution_binding(); + adoption["adoption_state"] = json!(rejected); + cases.push((adoption, SchemaKind::ExecutionBinding, "adoption_state")); + + let mut cancellation = execution_binding(); + cancellation["cancellation_state"] = json!(rejected); + cases.push(( + cancellation, + SchemaKind::ExecutionBinding, + "cancellation_state", + )); + + let mut acknowledgement = execution_binding(); + acknowledgement["cancellation_acknowledgement"] = json!({"kind": rejected}); + cases.push(( + acknowledgement, + SchemaKind::ExecutionBinding, + "cancellation_acknowledgement.kind", + )); + + let delivery: Value = serde_json::from_slice(&fixture("delivery-ready.json")).unwrap(); + for (field, expected) in [ + ("relationship", "relationship"), + ("state", "state"), + ("surface_decision.state", "surface_decision.state"), + ] { + let mut value = delivery.clone(); + if field == "surface_decision.state" { + value["surface_decision"]["state"] = json!(rejected); + } else { + value[field] = json!(rejected); + } + cases.push((value, SchemaKind::Delivery, expected)); + } + + let mut error: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + error["error"]["code"] = json!(rejected); + cases.push((error, SchemaKind::Error, "code")); + + for (value, schema, field) in cases { + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!( + error, + ContractError::UnknownEnumValue { schema, field }, + "{schema:?}.{field}" + ); + assert_redacted(&error, rejected); + } +} + +#[test] +fn duplicate_keys_at_every_recursive_location_fail_closed() { + let sentinel = "duplicate-secret-sentinel"; + let cases = [ + format!( + r#"{{"schema_version":"psyche.intent.v1","schema_version":"psyche.intent.v2","raw":"{sentinel}"}}"# + ), + format!( + r#"{{"schema_version":"psyche.intent.v2","schema_version":"psyche.intent.v1","raw":"{sentinel}"}}"# + ), + format!(r#"{{"schema_version":"psyche.intent.v1","raw":"{sentinel}","raw":"second"}}"#), + format!( + r#"{{"schema_version":"psyche.intent.v1","constraints":{{"nested":{{"key":"{sentinel}","key":"second"}}}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","actor":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","locator":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","content":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_effect.v1","effect":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","content":{{"items":[{{"key":"{sentinel}","key":"second"}}]}}}}"# + ), + ]; + + for bytes in cases { + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!(matches!( + error, + ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + } + )); + assert_redacted(&error, sentinel); + } +} + +#[test] +fn excessive_json_nesting_is_rejected_without_echoing_payload() { + let sentinel = "depth-secret-sentinel"; + let depth = 80; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v1","constraints":{}"{sentinel}"{}}}}}"#, + "[".repeat(depth), + "]".repeat(depth) + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!(matches!(error, ContractError::InvalidShape { .. })); + assert_redacted(&error, sentinel); +} + +#[test] +fn recognized_registry_entries_never_report_unknown_or_unsupported() { + for schema in [ + "identity_snapshot", + "intent", + "surface_event", + "graph", + "graph_node", + "delegation", + "budget", + "approval", + "execution_binding", + "evidence", + "verdict", + "recovery", + "addon", + "surface_effect", + "delivery", + "error", + ] { + let bytes = format!(r#"{{"schema_version":"psyche.{schema}.v1"}}"#); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!( + !matches!( + error, + ContractError::UnknownSchema | ContractError::UnsupportedMajor { .. } + ), + "{schema} fell through the registry: {error:?}" + ); + } +} + +#[test] +fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { + let small = RejectedDocument::from_bytes( + b"abc", + RejectionReason::InvalidShape { + schema: SchemaKind::Error, + field: "json", + }, + ); + assert_eq!(small.bounded_payload, b"abc"); + assert_eq!( + small.payload_digest.as_str(), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + + let mut left = vec![b'a'; 64 * 1024 + 1]; + let mut right = left.clone(); + left[64 * 1024] = b'x'; + right[64 * 1024] = b'y'; + + let left = RejectedDocument::from_bytes( + &left, + RejectionReason::InvalidShape { + schema: SchemaKind::Intent, + field: "document", + }, + ); + let right = RejectedDocument::from_bytes( + &right, + RejectionReason::InvalidShape { + schema: SchemaKind::Intent, + field: "document", + }, + ); + + assert_eq!(left.bounded_payload.len(), 64 * 1024); + assert_eq!(right.bounded_payload.len(), 64 * 1024); + assert_eq!(left.bounded_payload, right.bounded_payload); + assert_ne!(left.payload_digest, right.payload_digest); +} + +#[test] +fn rejected_document_debug_never_prints_payload_or_schema_values() { + let sentinel = "SECRET_DEBUG_SENTINEL"; + let bytes = format!(r#"{{"schema_version":"psyche.intent.v2","payload":"{sentinel}"}}"#); + let rejected = RejectedDocument::from_bytes( + bytes.as_bytes(), + RejectionReason::UnsupportedMajor { + found: 2, + supported: 1, + }, + ); + assert_eq!(rejected.schema_version.as_deref(), Some("psyche.intent.v2")); + let debug = format!("{rejected:?}"); + assert!(!debug.contains(sentinel)); + assert!(!debug.contains("psyche.intent.v2")); + assert!(!debug.contains("\"payload\"")); + assert!(debug.contains("bounded_payload_bytes")); +} + +#[test] +fn rejected_document_handles_oversized_and_invalid_utf8_input() { + let oversized = vec![0xff; MAX_DOCUMENT_BYTES + 1]; + let rejected = RejectedDocument::from_bytes(&oversized, RejectionReason::TooLarge); + assert_eq!(rejected.bounded_payload.len(), 64 * 1024); + assert_eq!(rejected.bounded_payload, vec![0xff; 64 * 1024]); + assert!(rejected.schema_version.is_none()); + assert!(rejected.payload_digest.as_str().starts_with("sha256:")); + assert_eq!(rejected.payload_digest.as_str().len(), 71); + assert!(!format!("{rejected:?}").contains('\u{fffd}')); +} diff --git a/crates/psyche-core/tests/fixtures/error-storage-unavailable.json b/crates/psyche-core/tests/fixtures/error-storage-unavailable.json new file mode 100644 index 0000000..9a76130 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/error-storage-unavailable.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_unavailable", + "message": "Storage is temporarily unavailable.", + "retryable": true, + "correlation_id": "corr-storage-1", + "details": { + "component": "sqlite", + "operation": "write" + } + } +} From 7abcd2701a13f1ba46b2917055e8290a6c87d244 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:31:21 -0500 Subject: [PATCH 11/66] fix(core): reject ambiguous canonical JSON Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 5 +- crates/psyche-core/src/contracts/mod.rs | 386 +++--------------- crates/psyche-core/src/digest.rs | 198 +++++++-- crates/psyche-core/src/lib.rs | 2 + crates/psyche-core/src/serde_json_number.rs | 33 ++ crates/psyche-core/tests/contracts.rs | 211 +++++++++- crates/psyche-core/tests/decode.rs | 365 ----------------- .../fixtures/error-storage-unavailable.json | 13 - 8 files changed, 449 insertions(+), 764 deletions(-) create mode 100644 crates/psyche-core/src/serde_json_number.rs delete mode 100644 crates/psyche-core/tests/decode.rs delete mode 100644 crates/psyche-core/tests/fixtures/error-storage-unavailable.json diff --git a/Cargo.toml b/Cargo.toml index 331a9f1..bfe37d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,10 @@ clap = { version = "4", features = ["derive", "env"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } -serde_json = "1" +# Keep arbitrary-precision enabled workspace-wide: Cargo feature unification can +# enable serde_json's private number representation through any dependency, so +# psyche-core's canonicalization tests must always exercise that representation. +serde_json = { version = "1", features = ["arbitrary_precision"] } assert_cmd = "2" predicates = "3" tempfile = "3" diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 4903eb5..b5a5f7d 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -6,7 +6,6 @@ //! Task 2 stops at these primitives: no `records`, no `CanonicalDocument`, no //! store validation, and no `QuarantineId` — those are store-owned and land //! in later tasks (`QuarantineId` explicitly in Task 7). -use std::collections::HashSet; use std::fmt; use std::str::FromStr; @@ -14,8 +13,8 @@ use serde::Serialize; use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde_json::Value; -use crate::digest::Sha256Digest; use crate::id::RecordId; +use crate::serde_json_number; macro_rules! validated_struct { ( @@ -68,6 +67,7 @@ pub use surface::{Delivery, SurfaceEffect, SurfaceEvent}; /// Maximum accepted encoded or embedded canonical document size. pub const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const MAX_JSON_DEPTH: usize = 128; /// Reasons a contract primitive failed to validate. /// @@ -85,13 +85,8 @@ pub enum ContractError { /// The kind is known but this build does not accept the declared major. /// The rejected string is intentionally not retained: attacker-controlled /// schema text must not propagate into error payloads or logs. - #[error("schema version declares unsupported major {found}; supported major is {supported}")] - UnsupportedMajor { - /// Parsed unsupported major. Malformed major syntax is reported as zero. - found: u16, - /// Major accepted by this build. - supported: u16, - }, + #[error("schema version declares an unsupported major")] + UnsupportedMajor, /// A record identifier did not carry the exact prefix its requested /// `RecordKind` requires. The required prefix is [`RecordKind::prefix`], /// not stored redundantly on this error. @@ -356,9 +351,6 @@ impl SchemaKind { /// presently at major 1, and a kind reaching major 2 is a deliberate, /// reviewed change to this file rather than a silent range extension. const SUPPORTED_MAJOR: u16 = 1; -const MAX_JSON_DEPTH: usize = 64; -const MAX_REJECTED_PAYLOAD_BYTES: usize = 64 * 1024; -const MAX_RETAINED_SCHEMA_VERSION_BYTES: usize = 128; /// A validated `psyche..v` contract schema version. /// @@ -399,13 +391,10 @@ impl SchemaVersion { let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; - let unsupported_major = |found| ContractError::UnsupportedMajor { - found, - supported: SUPPORTED_MAJOR, - }; + let unsupported_major = || ContractError::UnsupportedMajor; let digits = major_segment .strip_prefix('v') - .ok_or_else(|| unsupported_major(0))?; + .ok_or_else(unsupported_major)?; // Reject a leading zero on a multi-digit major ("v01"): it parses to // the same integer as "v1" but is not the canonical string, and the // registry only accepts the canonical form. @@ -413,11 +402,11 @@ impl SchemaVersion { || (digits.len() > 1 && digits.starts_with('0')) || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(unsupported_major(0)); + return Err(unsupported_major()); } - let major: u16 = digits.parse().map_err(|_| unsupported_major(0))?; + let major: u16 = digits.parse().map_err(|_| unsupported_major())?; if major != SUPPORTED_MAJOR { - return Err(unsupported_major(major)); + return Err(unsupported_major()); } Ok(SchemaVersion { kind, major }) } @@ -459,78 +448,6 @@ pub trait VersionedRecord: Serialize { fn record_id(&self) -> &RecordId; } -/// Payload-light classification attached to bytes retained for quarantine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RejectionReason { - /// The raw input exceeded [`MAX_DOCUMENT_BYTES`]. - TooLarge, - /// The schema kind is not in this build's registry. - UnknownSchema, - /// The schema kind is known but its major is unsupported. - UnsupportedMajor { - /// Parsed unsupported major. - found: u16, - /// Major accepted by this build. - supported: u16, - }, - /// A typed enum field used an unknown spelling. - UnknownEnumValue { - /// Schema containing the enum. - schema: SchemaKind, - /// Static field path. - field: &'static str, - }, - /// JSON or typed document shape was invalid. - InvalidShape { - /// Schema associated with the failure. - schema: SchemaKind, - /// Static field or validation category. - field: &'static str, - }, -} - -/// Bounded raw input and metadata suitable for a later quarantine store. -#[derive(Clone, PartialEq, Eq)] -pub struct RejectedDocument { - /// Safely bounded schema text, when a strict JSON parse can extract it. - pub schema_version: Option, - /// SHA-256 over the complete raw input, including bytes not retained. - pub payload_digest: Sha256Digest, - /// At most 64 KiB from the beginning of the raw input. - pub bounded_payload: Vec, - /// Payload-light rejection classification. - pub reason: RejectionReason, -} - -impl RejectedDocument { - /// Builds quarantine input without requiring valid UTF-8 or valid JSON. - pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { - let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { - strict_json(bytes) - .ok() - .and_then(|value| retained_schema_version(&value)) - } else { - None - }; - Self { - schema_version, - payload_digest: Sha256Digest::from_raw_bytes(bytes), - bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), - reason, - } - } -} - -impl fmt::Debug for RejectedDocument { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RejectedDocument") - .field("bounded_payload_bytes", &self.bounded_payload.len()) - .field("payload_digest", &self.payload_digest) - .field("reason", &self.reason) - .finish() - } -} - /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] @@ -649,10 +566,12 @@ pub fn decode_document(bytes: &[u8]) -> Result } let value = strict_json(bytes)?; crate::digest::validate_json_domain(&value)?; - let probe: VersionProbe = serde_json::from_value(value.clone()) - .map_err(|_| invalid(SchemaKind::Error, "schema_version"))?; - let schema = SchemaVersion::parse(&probe.schema_version)?; - inspect_typed_enums(&value, schema.kind)?; + let schema_text = value + .as_object() + .and_then(|v| v.get("schema_version")) + .and_then(Value::as_str) + .ok_or_else(|| invalid(SchemaKind::Error, "schema_version"))?; + let schema = SchemaVersion::parse(schema_text)?; let document = match schema.kind { SchemaKind::IdentitySnapshot => { decode(value, CanonicalDocument::IdentitySnapshot, schema.kind)? @@ -673,17 +592,14 @@ pub fn decode_document(bytes: &[u8]) -> Result SchemaKind::Addon => decode(value, CanonicalDocument::Addon, schema.kind)?, SchemaKind::SurfaceEffect => decode(value, CanonicalDocument::SurfaceEffect, schema.kind)?, SchemaKind::Delivery => decode(value, CanonicalDocument::Delivery, schema.kind)?, - SchemaKind::Error => CanonicalDocument::Error(ErrorEnvelope::decode(value)?), + SchemaKind::Error => { + return ErrorEnvelope::decode(value).map(CanonicalDocument::Error); + } }; document.validate()?; Ok(document) } -#[derive(serde::Deserialize)] -struct VersionProbe { - schema_version: String, -} - fn strict_json(bytes: &[u8]) -> Result { let mut deserializer = serde_json::Deserializer::from_slice(bytes); let value = StrictValueSeed { depth: 0 } @@ -733,10 +649,22 @@ impl<'de> Visitor<'de> for StrictValueVisitor { Ok(Value::Number(value.into())) } + fn visit_i128(self, value: i128) -> Result { + serde_json::Number::from_i128(value) + .map(Value::Number) + .ok_or_else(|| serde::de::Error::custom("invalid JSON number")) + } + fn visit_u64(self, value: u64) -> Result { Ok(Value::Number(value.into())) } + fn visit_u128(self, value: u128) -> Result { + serde_json::Number::from_u128(value) + .map(Value::Number) + .ok_or_else(|| serde::de::Error::custom("invalid JSON number")) + } + fn visit_f64(self, value: f64) -> Result { serde_json::Number::from_f64(value) .map(Value::Number) @@ -767,7 +695,7 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } fn visit_seq>(self, mut sequence: A) -> Result { - let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); + let mut values = Vec::new(); while let Some(value) = sequence.next_element_seed(StrictValueSeed { depth: self.depth + 1, })? { @@ -777,11 +705,28 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } fn visit_map>(self, mut object: A) -> Result { - let mut keys = HashSet::with_capacity(object.size_hint().unwrap_or(0)); - let mut values = serde_json::Map::with_capacity(object.size_hint().unwrap_or(0)); + if serde_json_number::is_private_number_map::() { + let key = object.next_key::()?; + if key.as_deref() != Some(serde_json_number::TOKEN) { + return Err(serde::de::Error::custom( + "malformed arbitrary-precision number", + )); + } + let text = object.next_value::()?; + if object.next_key::()?.is_some() { + return Err(serde::de::Error::custom( + "malformed arbitrary-precision number", + )); + } + return serde_json_number::parse_exact(&text) + .map(Value::Number) + .ok_or_else(|| serde::de::Error::custom("malformed arbitrary-precision number")); + } + + let mut values = serde_json::Map::new(); while let Some(key) = object.next_key::()? { - if !keys.insert(key.clone()) { - return Err(serde::de::Error::custom("duplicate JSON object key")); + if key == serde_json_number::TOKEN || values.contains_key(&key) { + return Err(serde::de::Error::custom("invalid JSON object key")); } let value = object.next_value_seed(StrictValueSeed { depth: self.depth + 1, @@ -792,227 +737,6 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } } -fn retained_schema_version(value: &Value) -> Option { - let schema = value.as_object()?.get("schema_version")?.as_str()?; - if schema.len() <= MAX_RETAINED_SCHEMA_VERSION_BYTES - && schema.starts_with("psyche.") - && schema.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_') - }) - { - Some(schema.to_owned()) - } else { - None - } -} - -fn inspect_typed_enums(value: &Value, schema: SchemaKind) -> Result<(), ContractError> { - match schema { - SchemaKind::Graph => inspect_enum( - value, - &["state"], - &[ - "draft", - "admitted", - "rejected", - "running", - "waiting_approval", - "waiting_evidence", - "cancelling", - "completed", - "failed", - "cancelled", - "recovery_required", - ], - schema, - "state", - ), - SchemaKind::GraphNode => inspect_enum( - value, - &["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", - ], - schema, - "state", - ), - SchemaKind::ExecutionBinding => { - inspect_enum( - value, - &["adoption_state"], - &[ - "not_submitted", - "submitting", - "adopted", - "proven_not_adopted", - "adoption_unknown", - "fenced", - ], - schema, - "adoption_state", - )?; - inspect_enum( - value, - &["cancellation_state"], - &[ - "not_requested", - "termination_requested", - "acknowledged_terminated", - "acknowledged_already_terminal", - "termination_unknown", - ], - schema, - "cancellation_state", - )?; - inspect_enum( - value, - &["cancellation_acknowledgement", "kind"], - &["terminated", "already_authoritatively_terminal"], - schema, - "cancellation_acknowledgement.kind", - ) - } - SchemaKind::Delivery => { - inspect_enum( - value, - &["relationship"], - &[ - "reply_same_dm", - "reply_same_group", - "reply_same_topic", - "cross_chat", - "broadcast", - ], - schema, - "relationship", - )?; - inspect_enum( - value, - &["state"], - &[ - "ready", - "sending", - "sent", - "retryable", - "delivery_unknown", - "failed", - "abandoned", - "dead_letter", - "resolving_unknown", - "compensated", - ], - schema, - "state", - )?; - inspect_enum( - value, - &["surface_decision", "state"], - &["reserved", "consumed"], - schema, - "surface_decision.state", - ) - } - SchemaKind::Error => inspect_enum( - value, - &["error", "code"], - &[ - "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", - ], - schema, - "code", - ), - SchemaKind::IdentitySnapshot - | SchemaKind::Intent - | SchemaKind::SurfaceEvent - | SchemaKind::Delegation - | SchemaKind::Budget - | SchemaKind::Approval - | SchemaKind::Evidence - | SchemaKind::Verdict - | SchemaKind::Recovery - | SchemaKind::Addon - | SchemaKind::SurfaceEffect => Ok(()), - } -} - -fn inspect_enum( - value: &Value, - path: &[&str], - accepted: &[&str], - schema: SchemaKind, - field: &'static str, -) -> Result<(), ContractError> { - let mut current = value; - for segment in path { - let Some(next) = current.as_object().and_then(|object| object.get(*segment)) else { - return Ok(()); - }; - current = next; - } - if let Some(spelling) = current.as_str() { - if !accepted.contains(&spelling) { - return Err(ContractError::UnknownEnumValue { schema, field }); - } - } - Ok(()) -} - fn decode( value: Value, wrap: impl FnOnce(T) -> CanonicalDocument, @@ -1179,7 +903,7 @@ mod tests { let attacker = format!("psyche.intent.v{}{}", marker, "9".repeat(900_000)); let err = SchemaVersion::parse(&attacker).unwrap_err(); assert!( - matches!(err, ContractError::UnsupportedMajor { .. }), + matches!(err, ContractError::UnsupportedMajor), "expected UnsupportedMajor, got {err:?}" ); let debug = format!("{err:?}"); @@ -1253,7 +977,7 @@ mod tests { #[test] fn schema_version_rejects_a_known_kind_with_the_wrong_major() { let err = SchemaVersion::parse("psyche.intent.v2").unwrap_err(); - assert!(matches!(err, ContractError::UnsupportedMajor { .. })); + assert!(matches!(err, ContractError::UnsupportedMajor)); } #[test] @@ -1304,7 +1028,7 @@ mod tests { ); } else { assert!( - matches!(err, ContractError::UnsupportedMajor { .. }), + matches!(err, ContractError::UnsupportedMajor), "expected UnsupportedMajor for {near:?}, got {err:?}" ); } diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index 10a04fc..a594041 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -13,10 +13,11 @@ use serde::ser::{ self, Impossible, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant, Serializer, }; -use serde_json::Value; +use serde_json::{Number, Value}; use sha2::{Digest as _, Sha256}; use crate::contracts::{ContractError, MAX_SAFE_INTEGER}; +use crate::serde_json_number; /// Length of the hex-encoded digest after the `sha256:` prefix. const HEX_DIGEST_LEN: usize = 64; @@ -32,7 +33,7 @@ const MIN_SAFE_INTEGER_I128: i128 = -MAX_SAFE_INTEGER_I128; /// serialized exactly once into a validated representation, including map /// keys, before that representation is passed to the canonicalizer. pub fn canonical_bytes(value: &T) -> Result, ContractError> { - let collected = value.serialize(ValueCollector).map_err(validation_failed)?; + let collected = collect(value).map_err(validation_failed)?; let canonicalizer_input = collected.into_json().map_err(validation_failed)?; serde_json_canonicalizer::to_vec(&canonicalizer_input).map_err(canonicalization_failed) } @@ -52,28 +53,30 @@ pub(crate) fn validate_json_domain(value: &Value) -> Result<(), ContractError> { match value { Value::Array(values) => values.iter().try_for_each(validate_json_domain), Value::Object(values) => values.values().try_for_each(validate_json_domain), - Value::Number(number) => { - let interoperable = if let Some(value) = number.as_i64() { - value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER as i64 - } else if let Some(value) = number.as_u64() { - value <= MAX_SAFE_INTEGER - } else if let Some(value) = number.as_f64() { - value.is_finite() - && (value.fract() != 0.0 - || (value >= MIN_SAFE_INTEGER as f64 && value <= MAX_SAFE_INTEGER as f64)) - } else { - false - }; - if interoperable { - Ok(()) - } else { - Err(ContractError::NonInteroperableNumber) - } - } + Value::Number(number) => validate_json_number(number), Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), } } +fn validate_json_number(number: &Number) -> Result<(), ContractError> { + let interoperable = if let Some(value) = number.as_i64() { + value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER as i64 + } else if let Some(value) = number.as_u64() { + value <= MAX_SAFE_INTEGER + } else if let Some(value) = number.as_f64() { + value.is_finite() + && (value.fract() != 0.0 + || (value >= MIN_SAFE_INTEGER as f64 && value <= MAX_SAFE_INTEGER as f64)) + } else { + false + }; + if interoperable { + Ok(()) + } else { + Err(ContractError::NonInteroperableNumber) + } +} + fn validate_float(value: f64) -> Result<(), ContractError> { if value.is_finite() && (value.fract() != 0.0 @@ -109,7 +112,9 @@ impl ser::Error for ValidationError { } #[derive(Clone, Copy)] -struct ValueCollector; +struct ValueCollector { + allow_private_number: bool, +} enum CollectedValue { Null, @@ -117,6 +122,7 @@ enum CollectedValue { Signed(i64), Unsigned(u64), Float(f64), + Number(Number), String(String), Array(Vec), Object(BTreeMap), @@ -129,9 +135,10 @@ impl CollectedValue { Self::Bool(value) => Ok(Value::Bool(value)), Self::Signed(value) => Ok(Value::Number(value.into())), Self::Unsigned(value) => Ok(Value::Number(value.into())), - Self::Float(value) => serde_json::Number::from_f64(value) + Self::Float(value) => Number::from_f64(value) .map(Value::Number) .ok_or(ValidationError::NonInteroperableNumber), + Self::Number(value) => Ok(Value::Number(value)), Self::String(value) => Ok(Value::String(value)), Self::Array(values) => values .into_iter() @@ -147,6 +154,12 @@ impl CollectedValue { } } +fn collect(value: &T) -> Result { + value.serialize(ValueCollector { + allow_private_number: serde_json_number::source_may_emit_private_number::(), + }) +} + fn validate_signed(value: i128) -> Result<(), ValidationError> { if (MIN_SAFE_INTEGER_I128..=MAX_SAFE_INTEGER_I128).contains(&value) { Ok(()) @@ -254,7 +267,7 @@ impl Serializer for ValueCollector { } fn serialize_some(self, value: &T) -> Result { - value.serialize(self) + collect(value) } fn serialize_unit(self) -> Result { @@ -279,7 +292,7 @@ impl Serializer for ValueCollector { _name: &'static str, value: &T, ) -> Result { - value.serialize(self) + collect(value) } fn serialize_newtype_variant( @@ -289,7 +302,7 @@ impl Serializer for ValueCollector { variant: &'static str, value: &T, ) -> Result { - singleton_object(variant, value.serialize(self)?) + singleton_object(variant, collect(value)?) } fn serialize_seq(self, length: Option) -> Result { @@ -324,10 +337,18 @@ impl Serializer for ValueCollector { fn serialize_struct( self, - _name: &'static str, - _length: usize, + name: &'static str, + length: usize, ) -> Result { - Ok(ObjectCollector::new(None)) + if name == serde_json_number::TOKEN { + if self.allow_private_number && length == 1 { + Ok(ObjectCollector::private_number()) + } else { + Err(ValidationError::SerializationFailed) + } + } else { + Ok(ObjectCollector::new(None)) + } } fn serialize_struct_variant( @@ -363,6 +384,9 @@ fn collect_float(value: f64) -> Result { } fn singleton_object(key: &str, value: CollectedValue) -> Result { + if key == serde_json_number::TOKEN { + return Err(ValidationError::SerializationFailed); + } Ok(CollectedValue::Object(BTreeMap::from([( key.to_owned(), value, @@ -383,7 +407,7 @@ impl SequenceCollector { } fn push(&mut self, value: &T) -> Result<(), ValidationError> { - self.values.push(value.serialize(ValueCollector)?); + self.values.push(collect(value)?); Ok(()) } @@ -452,6 +476,7 @@ struct ObjectCollector { values: BTreeMap, next_key: Option, variant: Option<&'static str>, + private_number: Option>, } impl ObjectCollector { @@ -460,11 +485,24 @@ impl ObjectCollector { values: BTreeMap::new(), next_key: None, variant, + private_number: None, + } + } + + fn private_number() -> Self { + Self { + values: BTreeMap::new(), + next_key: None, + variant: None, + private_number: Some(None), } } fn insert(&mut self, key: String, value: CollectedValue) -> Result<(), ValidationError> { - if self.values.insert(key, value).is_some() { + if self.private_number.is_some() + || key == serde_json_number::TOKEN + || self.values.insert(key, value).is_some() + { Err(ValidationError::SerializationFailed) } else { Ok(()) @@ -475,6 +513,11 @@ impl ObjectCollector { if self.next_key.is_some() { return Err(ValidationError::SerializationFailed); } + if let Some(number) = self.private_number { + return number + .map(CollectedValue::Number) + .ok_or(ValidationError::SerializationFailed); + } let value = CollectedValue::Object(self.values); match self.variant { Some(variant) => singleton_object(variant, value), @@ -488,7 +531,7 @@ impl SerializeMap for ObjectCollector { type Error = ValidationError; fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { - if self.next_key.is_some() { + if self.private_number.is_some() || self.next_key.is_some() { return Err(ValidationError::SerializationFailed); } self.next_key = Some(key.serialize(MapKeyCollector)?); @@ -496,11 +539,14 @@ impl SerializeMap for ObjectCollector { } fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + if self.private_number.is_some() { + return Err(ValidationError::SerializationFailed); + } let key = self .next_key .take() .ok_or(ValidationError::SerializationFailed)?; - self.insert(key, value.serialize(ValueCollector)?) + self.insert(key, collect(value)?) } fn end(self) -> Result { @@ -517,7 +563,21 @@ impl SerializeStruct for ObjectCollector { key: &'static str, value: &T, ) -> Result<(), Self::Error> { - self.insert(key.to_owned(), value.serialize(ValueCollector)?) + if let Some(number) = &mut self.private_number { + if key != serde_json_number::TOKEN || number.is_some() { + return Err(ValidationError::SerializationFailed); + } + let CollectedValue::String(text) = collect(value)? else { + return Err(ValidationError::SerializationFailed); + }; + let parsed = serde_json_number::parse_exact(&text) + .ok_or(ValidationError::SerializationFailed)?; + validate_json_number(&parsed).map_err(|_| ValidationError::NonInteroperableNumber)?; + *number = Some(parsed); + Ok(()) + } else { + self.insert(key.to_owned(), collect(value)?) + } } fn end(self) -> Result { @@ -534,7 +594,7 @@ impl SerializeStructVariant for ObjectCollector { key: &'static str, value: &T, ) -> Result<(), Self::Error> { - self.insert(key.to_owned(), value.serialize(ValueCollector)?) + self.insert(key.to_owned(), collect(value)?) } fn end(self) -> Result { @@ -779,16 +839,6 @@ impl Sha256Digest { Ok(Sha256Digest(value.to_string())) } - pub(crate) fn from_raw_bytes(bytes: &[u8]) -> Self { - let mut hasher = Sha256::new(); - hasher.update(bytes); - Self(format!( - "{}{}", - Self::PREFIX, - to_lower_hex(hasher.finalize().as_slice()) - )) - } - /// The full digest string, e.g. `"sha256:<64 lowercase hex chars>"`. pub fn as_str(&self) -> &str { &self.0 @@ -866,6 +916,66 @@ mod tests { digest(&value).unwrap(); } + #[test] + fn arbitrary_precision_values_keep_numeric_canonical_representation() { + let value: serde_json::Value = serde_json::from_str( + r#"{"safe":9007199254740991,"negative":-9007199254740991,"fraction":1.2300}"#, + ) + .unwrap(); + + let canonical = String::from_utf8(canonical_bytes(&value).unwrap()).unwrap(); + assert_eq!( + canonical, + r#"{"fraction":1.23,"negative":-9007199254740991,"safe":9007199254740991}"# + ); + assert!(!canonical.contains("$serde_json::private::Number")); + } + + #[test] + fn arbitrary_precision_values_reject_non_interoperable_number_text() { + for source in [ + "9007199254740992", + "-9007199254740992", + "18446744073709551616", + "1e400", + ] { + let value: serde_json::Value = serde_json::from_str(source).unwrap(); + assert_eq!( + canonical_bytes(&value), + Err(ContractError::NonInteroperableNumber), + "{source}" + ); + } + } + + #[test] + fn private_number_marker_lookalikes_cannot_smuggle_numbers_or_objects() { + const TOKEN: &str = "$serde_json::private::Number"; + + #[derive(Serialize)] + #[serde(rename = "$serde_json::private::Number")] + struct SpoofedNumber<'a> { + #[serde(rename = "$serde_json::private::Number")] + text: &'a str, + } + + assert_eq!( + canonical_bytes(&SpoofedNumber { text: "1.5" }), + Err(ContractError::CanonicalizationFailed) + ); + + for lookalike in [ + json!({TOKEN: "1.5"}), + json!({TOKEN: 1.5}), + json!({TOKEN: "1.5", "extra": true}), + ] { + assert_eq!( + canonical_bytes(&lookalike), + Err(ContractError::CanonicalizationFailed) + ); + } + } + #[test] fn canonicalization_rejects_unsafe_integers_anywhere_in_the_json_domain() { for value in [ diff --git a/crates/psyche-core/src/lib.rs b/crates/psyche-core/src/lib.rs index b9be4d8..12fb49f 100644 --- a/crates/psyche-core/src/lib.rs +++ b/crates/psyche-core/src/lib.rs @@ -9,3 +9,5 @@ pub mod digest; pub mod id; pub mod schema; pub mod secret; + +mod serde_json_number; diff --git a/crates/psyche-core/src/serde_json_number.rs b/crates/psyche-core/src/serde_json_number.rs new file mode 100644 index 0000000..6e2477c --- /dev/null +++ b/crates/psyche-core/src/serde_json_number.rs @@ -0,0 +1,33 @@ +//! Isolated handling for serde_json's `arbitrary_precision` wire protocol. +//! +//! serde_json represents number text to third-party Serde implementations as a +//! private one-field struct/map. Both sides authenticate the concrete source or +//! map-access type before accepting that marker, so user data cannot spoof it. + +use std::any::type_name; +use std::str::FromStr; + +use serde_json::{Number, Value}; + +pub(crate) const TOKEN: &str = "$serde_json::private::Number"; + +const NUMBER_MAP_ACCESS_TYPE: &str = "serde_json::number::NumberDeserializer"; + +pub(crate) fn source_may_emit_private_number() -> bool { + let mut source = type_name::(); + while let Some(referenced) = source.strip_prefix('&') { + source = referenced.strip_prefix("mut ").unwrap_or(referenced); + } + source == type_name::() || source == type_name::() +} + +pub(crate) fn is_private_number_map() -> bool { + // An upstream representation change fails closed as an ordinary object + // containing the reserved marker, while the canonical tests expose drift. + type_name::() == NUMBER_MAP_ACCESS_TYPE +} + +pub(crate) fn parse_exact(text: &str) -> Option { + let number = Number::from_str(text).ok()?; + (number.to_string() == text).then_some(number) +} diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index f2d028a..c7eb8c5 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -42,6 +42,29 @@ fn mutate(name: &str, f: impl FnOnce(&mut serde_json::Map)) -> Ve serde_json::to_vec(&value).unwrap() } +fn replace_fixture_once(name: &str, from: &str, to: &str) -> Vec { + let source = String::from_utf8(fixture(name)).unwrap(); + assert_eq!(source.matches(from).count(), 1, "{name}: {from}"); + source.replacen(from, to, 1).into_bytes() +} + +fn duplicate_fixture_fragment(name: &str, fragment: &str) -> Vec { + replace_fixture_once(name, fragment, &format!("{fragment}, {fragment}")) +} + +fn assert_duplicate_json_rejected(bytes: &[u8], location: &str) { + assert!( + matches!( + decode_document(bytes), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + field: "json", + }) + ), + "{location}" + ); +} + fn timestamp(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap() } @@ -965,6 +988,9 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ("acknowledgement timestamp", |value: &mut Value| { value["cancellation_acknowledgement"]["acknowledged_at"] = json!("tomorrow"); }), + ("acknowledgement kind", |value: &mut Value| { + value["cancellation_acknowledgement"]["kind"] = json!("future_kind"); + }), ("termination request id", |value: &mut Value| { value["termination_request"]["termination_request_id"] = json!("not-a-request"); }), @@ -996,16 +1022,6 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ); } - let mut unknown_kind = binding_value(CancellationState::AcknowledgedTerminated); - unknown_kind["cancellation_acknowledgement"]["kind"] = json!("future_kind"); - assert!(matches!( - decode_document(&serde_json::to_vec(&unknown_kind).unwrap()), - Err(ContractError::UnknownEnumValue { - schema: SchemaKind::ExecutionBinding, - field: "cancellation_acknowledgement.kind", - }) - )); - let mut unknown = binding_value(CancellationState::TerminationRequested); unknown["cancellation_state"] = json!("cancelled"); assert!(decode_document(&serde_json::to_vec(&unknown).unwrap()).is_err()); @@ -1025,9 +1041,145 @@ fn strict_probe_and_document_limit_fail_closed() { decode_document(br#"{"schema_version":"psyche.unknown.v1"}"#), Err(ContractError::UnknownSchema) )); + assert!(matches!( + decode_document(br#"{"schema_version":"psyche.intent.v2"}"#), + Err(ContractError::UnsupportedMajor { .. }) + )); assert!(decode_document(&vec![b' '; 1_048_577]).is_err()); } +#[test] +fn duplicate_schema_versions_are_rejected_before_version_dispatch() { + for (order, bytes) in [ + ( + "unsupported then supported", + replace_fixture_once( + "intent-local.json", + r#""schema_version": "psyche.intent.v1""#, + r#""schema_version": "psyche.intent.v2", "schema_version": "psyche.intent.v1""#, + ), + ), + ( + "supported then unsupported", + replace_fixture_once( + "intent-local.json", + r#""schema_version": "psyche.intent.v1""#, + r#""schema_version": "psyche.intent.v1", "schema_version": "psyche.intent.v2""#, + ), + ), + ] { + assert_duplicate_json_rejected(&bytes, order); + } +} + +#[test] +fn duplicate_top_level_contract_fields_are_rejected() { + for (field, bytes) in [ + ( + "intent_id", + duplicate_fixture_fragment( + "intent-local.json", + r#""intent_id": "int_01ARZ3NDEKTSV4RRFFQ69G5FAV""#, + ), + ), + ( + "digest", + duplicate_fixture_fragment( + "intent-local.json", + r#""digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef""#, + ), + ), + ] { + assert_duplicate_json_rejected(&bytes, field); + } +} + +#[test] +fn duplicate_nested_contract_fields_are_rejected_recursively() { + for (location, bytes) in [ + ( + "intent constraints", + replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + r#""constraints": {"outer": {"mode": "strict", "mode": "strict"}}"#, + ), + ), + ( + "surface actor", + duplicate_fixture_fragment("surface-event.json", r#""type": "user""#), + ), + ( + "surface locator", + duplicate_fixture_fragment("surface-event.json", r#""message_id": "42""#), + ), + ( + "surface content", + duplicate_fixture_fragment("surface-event.json", r#""text": "Please review this.""#), + ), + ( + "surface effect locator", + duplicate_fixture_fragment("surface-effect.json", r#""chat_id": "-100123""#), + ), + ( + "surface effect", + duplicate_fixture_fragment("surface-effect.json", r#""text": "Review complete.""#), + ), + ( + "delivery topic", + duplicate_fixture_fragment("delivery-ready.json", r#""kind": "forum""#), + ), + ( + "delivery effect", + duplicate_fixture_fragment("delivery-ready.json", r#""format": "html""#), + ), + ( + "delivery surface_decision", + duplicate_fixture_fragment( + "delivery-ready.json", + r#""policy_revision": "policy:sha256:0123456789abcdef""#, + ), + ), + ( + "error details", + br#"{ + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_unavailable", + "message": "temporarily unavailable", + "retryable": true, + "correlation_id": "corr-1", + "details": {"scope": "public", "scope": "public"} + } + }"# + .to_vec(), + ), + ] { + assert_duplicate_json_rejected(&bytes, location); + } +} + +#[test] +fn duplicate_key_errors_do_not_expose_attacker_controlled_text() { + let marker = "SENTINEL_DUPLICATE_KEY_XYZ"; + let bytes = replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + &format!(r#""constraints": {{"{marker}": 1, "{marker}": 2}}"#), + ); + + let error = decode_document(&bytes).unwrap_err(); + assert_eq!( + error, + ContractError::InvalidShape { + schema: SchemaKind::Error, + field: "json", + } + ); + assert!(!format!("{error:?}").contains(marker)); + assert!(!format!("{error}").contains(marker)); +} + #[test] fn directly_constructed_document_rejects_oversized_canonical_bytes() { let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { @@ -1225,6 +1377,45 @@ fn decoded_document_rejects_nested_unsafe_integers() { ); } +#[test] +fn decoded_document_preserves_interoperable_arbitrary_precision_numbers() { + let bytes = replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + r#""constraints": {"safe": 9007199254740991, "fraction": 1.2300}"#, + ); + + let CanonicalDocument::Intent(intent) = decode_document(&bytes).unwrap() else { + panic!("expected intent"); + }; + assert_eq!( + canonical_bytes(&intent.constraints).unwrap(), + br#"{"fraction":1.23,"safe":9007199254740991}"# + ); +} + +#[test] +fn decoded_document_rejects_private_number_marker_lookalikes() { + for constraints in [ + r#"{"$serde_json::private::Number": "1.5"}"#, + r#"{"$serde_json::private::Number": 1.5}"#, + r#"{"$serde_json::private::Number": "1.5", "extra": true}"#, + ] { + let bytes = replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + &format!(r#""constraints": {constraints}"#), + ); + assert!(matches!( + decode_document(&bytes), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + field: "json", + }) + )); + } +} + fn assert_invalid_numeric_field( result: Result<(), ContractError>, schema: SchemaKind, diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs deleted file mode 100644 index 32f70f8..0000000 --- a/crates/psyche-core/tests/decode.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! Fail-closed canonical document decoding and quarantine-input tests. -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use psyche_core::contracts::{ - CanonicalDocument, ContractError, MAX_DOCUMENT_BYTES, RejectedDocument, RejectionReason, - SchemaKind, decode_document, -}; -use serde_json::{Value, json}; - -const ULID_A: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const ULID_B: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; -const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -fn fixture(name: &str) -> Vec { - std::fs::read(format!( - "{}/tests/fixtures/{name}", - env!("CARGO_MANIFEST_DIR") - )) - .unwrap() -} - -fn assert_redacted(error: &ContractError, rejected_value: &str) { - assert!(!format!("{error:?}").contains(rejected_value)); - assert!(!error.to_string().contains(rejected_value)); -} - -fn graph() -> Value { - json!({ - "schema_version": "psyche.graph.v1", - "graph_id": format!("grf_{ULID_A}"), - "root_intent_id": format!("int_{ULID_A}"), - "owner_principal_id": "principal:one", - "policy_revision": "policy:one", - "state": "draft", - "version": 1 - }) -} - -fn execution_binding() -> Value { - json!({ - "schema_version": "psyche.execution_binding.v1", - "attempt_id": format!("att_{ULID_A}"), - "revision": 1, - "previous_revision_digest": null, - "revision_created_at": "2026-08-01T00:00:00Z", - "familiar_snapshot_id": format!("ids_{ULID_B}"), - "project_id": "project:one", - "request_id": format!("req_{ULID_A}"), - "request_digest": DIGEST, - "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": 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 - }) -} - -#[test] -fn unknown_major_never_decodes_as_a_known_record() { - let secret = "intent-secret-sentinel"; - let bytes = format!( - r#"{{"schema_version":"psyche.intent.v2","intent_id":"{secret}","raw":"{secret}"}}"# - ); - let error = decode_document(bytes.as_bytes()).unwrap_err(); - assert_eq!( - error, - ContractError::UnsupportedMajor { - found: 2, - supported: 1, - } - ); - assert_redacted(&error, secret); -} - -#[test] -fn malformed_payload_is_bounded_before_quarantine() { - let bytes = vec![b'x'; MAX_DOCUMENT_BYTES + 1]; - assert_eq!( - decode_document(&bytes), - Err(ContractError::DocumentTooLarge) - ); - let rejected = RejectedDocument::from_bytes(&bytes, RejectionReason::TooLarge); - assert_eq!(rejected.bounded_payload.len(), 64 * 1024); -} - -#[test] -fn recognized_error_envelope_decodes_exhaustively() { - let document = - decode_document(&fixture("error-storage-unavailable.json")).expect("error fixture"); - assert!(matches!(document, CanonicalDocument::Error(_))); - assert_eq!(document.schema_version().kind, SchemaKind::Error); -} - -#[test] -fn error_non_string_details_is_invalid_shape() { - let mut value: Value = - serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); - value["error"]["details"]["attempt"] = json!(3); - assert!(matches!( - decode_document(&serde_json::to_vec(&value).unwrap()), - Err(ContractError::InvalidShape { - schema: SchemaKind::Error, - .. - }) - )); -} - -#[test] -fn error_unknown_field_is_invalid_shape() { - let mut value: Value = - serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); - value["error"]["secret"] = json!("must-not-leak"); - assert!(matches!( - decode_document(&serde_json::to_vec(&value).unwrap()), - Err(ContractError::InvalidShape { - schema: SchemaKind::Error, - .. - }) - )); -} - -#[test] -fn unknown_typed_enum_is_a_quarantinable_decode_failure() { - let rejected_value = "future_state_secret"; - let mut value = graph(); - value["state"] = json!(rejected_value); - let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); - assert_eq!( - error, - ContractError::UnknownEnumValue { - schema: SchemaKind::Graph, - field: "state", - } - ); - assert_redacted(&error, rejected_value); -} - -#[test] -fn every_typed_enum_reports_its_static_field_without_the_rejected_value() { - let rejected = "future_enum_secret"; - let mut cases = Vec::new(); - - let mut node: Value = serde_json::from_slice(&fixture("node-root.json")).unwrap(); - node["state"] = json!(rejected); - cases.push((node, SchemaKind::GraphNode, "state")); - - let mut adoption = execution_binding(); - adoption["adoption_state"] = json!(rejected); - cases.push((adoption, SchemaKind::ExecutionBinding, "adoption_state")); - - let mut cancellation = execution_binding(); - cancellation["cancellation_state"] = json!(rejected); - cases.push(( - cancellation, - SchemaKind::ExecutionBinding, - "cancellation_state", - )); - - let mut acknowledgement = execution_binding(); - acknowledgement["cancellation_acknowledgement"] = json!({"kind": rejected}); - cases.push(( - acknowledgement, - SchemaKind::ExecutionBinding, - "cancellation_acknowledgement.kind", - )); - - let delivery: Value = serde_json::from_slice(&fixture("delivery-ready.json")).unwrap(); - for (field, expected) in [ - ("relationship", "relationship"), - ("state", "state"), - ("surface_decision.state", "surface_decision.state"), - ] { - let mut value = delivery.clone(); - if field == "surface_decision.state" { - value["surface_decision"]["state"] = json!(rejected); - } else { - value[field] = json!(rejected); - } - cases.push((value, SchemaKind::Delivery, expected)); - } - - let mut error: Value = - serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); - error["error"]["code"] = json!(rejected); - cases.push((error, SchemaKind::Error, "code")); - - for (value, schema, field) in cases { - let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); - assert_eq!( - error, - ContractError::UnknownEnumValue { schema, field }, - "{schema:?}.{field}" - ); - assert_redacted(&error, rejected); - } -} - -#[test] -fn duplicate_keys_at_every_recursive_location_fail_closed() { - let sentinel = "duplicate-secret-sentinel"; - let cases = [ - format!( - r#"{{"schema_version":"psyche.intent.v1","schema_version":"psyche.intent.v2","raw":"{sentinel}"}}"# - ), - format!( - r#"{{"schema_version":"psyche.intent.v2","schema_version":"psyche.intent.v1","raw":"{sentinel}"}}"# - ), - format!(r#"{{"schema_version":"psyche.intent.v1","raw":"{sentinel}","raw":"second"}}"#), - format!( - r#"{{"schema_version":"psyche.intent.v1","constraints":{{"nested":{{"key":"{sentinel}","key":"second"}}}}}}"# - ), - format!( - r#"{{"schema_version":"psyche.surface_event.v1","actor":{{"key":"{sentinel}","key":"second"}}}}"# - ), - format!( - r#"{{"schema_version":"psyche.surface_event.v1","locator":{{"key":"{sentinel}","key":"second"}}}}"# - ), - format!( - r#"{{"schema_version":"psyche.surface_event.v1","content":{{"key":"{sentinel}","key":"second"}}}}"# - ), - format!( - r#"{{"schema_version":"psyche.surface_effect.v1","effect":{{"key":"{sentinel}","key":"second"}}}}"# - ), - format!( - r#"{{"schema_version":"psyche.surface_event.v1","content":{{"items":[{{"key":"{sentinel}","key":"second"}}]}}}}"# - ), - ]; - - for bytes in cases { - let error = decode_document(bytes.as_bytes()).unwrap_err(); - assert!(matches!( - error, - ContractError::InvalidShape { - schema: SchemaKind::Error, - .. - } - )); - assert_redacted(&error, sentinel); - } -} - -#[test] -fn excessive_json_nesting_is_rejected_without_echoing_payload() { - let sentinel = "depth-secret-sentinel"; - let depth = 80; - let bytes = format!( - r#"{{"schema_version":"psyche.intent.v1","constraints":{}"{sentinel}"{}}}}}"#, - "[".repeat(depth), - "]".repeat(depth) - ); - let error = decode_document(bytes.as_bytes()).unwrap_err(); - assert!(matches!(error, ContractError::InvalidShape { .. })); - assert_redacted(&error, sentinel); -} - -#[test] -fn recognized_registry_entries_never_report_unknown_or_unsupported() { - for schema in [ - "identity_snapshot", - "intent", - "surface_event", - "graph", - "graph_node", - "delegation", - "budget", - "approval", - "execution_binding", - "evidence", - "verdict", - "recovery", - "addon", - "surface_effect", - "delivery", - "error", - ] { - let bytes = format!(r#"{{"schema_version":"psyche.{schema}.v1"}}"#); - let error = decode_document(bytes.as_bytes()).unwrap_err(); - assert!( - !matches!( - error, - ContractError::UnknownSchema | ContractError::UnsupportedMajor { .. } - ), - "{schema} fell through the registry: {error:?}" - ); - } -} - -#[test] -fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { - let small = RejectedDocument::from_bytes( - b"abc", - RejectionReason::InvalidShape { - schema: SchemaKind::Error, - field: "json", - }, - ); - assert_eq!(small.bounded_payload, b"abc"); - assert_eq!( - small.payload_digest.as_str(), - "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ); - - let mut left = vec![b'a'; 64 * 1024 + 1]; - let mut right = left.clone(); - left[64 * 1024] = b'x'; - right[64 * 1024] = b'y'; - - let left = RejectedDocument::from_bytes( - &left, - RejectionReason::InvalidShape { - schema: SchemaKind::Intent, - field: "document", - }, - ); - let right = RejectedDocument::from_bytes( - &right, - RejectionReason::InvalidShape { - schema: SchemaKind::Intent, - field: "document", - }, - ); - - assert_eq!(left.bounded_payload.len(), 64 * 1024); - assert_eq!(right.bounded_payload.len(), 64 * 1024); - assert_eq!(left.bounded_payload, right.bounded_payload); - assert_ne!(left.payload_digest, right.payload_digest); -} - -#[test] -fn rejected_document_debug_never_prints_payload_or_schema_values() { - let sentinel = "SECRET_DEBUG_SENTINEL"; - let bytes = format!(r#"{{"schema_version":"psyche.intent.v2","payload":"{sentinel}"}}"#); - let rejected = RejectedDocument::from_bytes( - bytes.as_bytes(), - RejectionReason::UnsupportedMajor { - found: 2, - supported: 1, - }, - ); - assert_eq!(rejected.schema_version.as_deref(), Some("psyche.intent.v2")); - let debug = format!("{rejected:?}"); - assert!(!debug.contains(sentinel)); - assert!(!debug.contains("psyche.intent.v2")); - assert!(!debug.contains("\"payload\"")); - assert!(debug.contains("bounded_payload_bytes")); -} - -#[test] -fn rejected_document_handles_oversized_and_invalid_utf8_input() { - let oversized = vec![0xff; MAX_DOCUMENT_BYTES + 1]; - let rejected = RejectedDocument::from_bytes(&oversized, RejectionReason::TooLarge); - assert_eq!(rejected.bounded_payload.len(), 64 * 1024); - assert_eq!(rejected.bounded_payload, vec![0xff; 64 * 1024]); - assert!(rejected.schema_version.is_none()); - assert!(rejected.payload_digest.as_str().starts_with("sha256:")); - assert_eq!(rejected.payload_digest.as_str().len(), 71); - assert!(!format!("{rejected:?}").contains('\u{fffd}')); -} diff --git a/crates/psyche-core/tests/fixtures/error-storage-unavailable.json b/crates/psyche-core/tests/fixtures/error-storage-unavailable.json deleted file mode 100644 index 9a76130..0000000 --- a/crates/psyche-core/tests/fixtures/error-storage-unavailable.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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" - } - } -} From a6c43f7c994e561f1e23bf71f350c9bc5337c523 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:40:21 -0500 Subject: [PATCH 12/66] fix(core): support arbitrary precision safely Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/mod.rs | 355 +++++++++++- crates/psyche-core/src/digest.rs | 503 +++++++++++++++--- crates/psyche-core/tests/contracts.rs | 81 ++- crates/psyche-core/tests/decode.rs | 365 +++++++++++++ .../fixtures/error-storage-unavailable.json | 13 + 5 files changed, 1203 insertions(+), 114 deletions(-) create mode 100644 crates/psyche-core/tests/decode.rs create mode 100644 crates/psyche-core/tests/fixtures/error-storage-unavailable.json diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index b5a5f7d..11cf1cf 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -6,6 +6,7 @@ //! Task 2 stops at these primitives: no `records`, no `CanonicalDocument`, no //! store validation, and no `QuarantineId` — those are store-owned and land //! in later tasks (`QuarantineId` explicitly in Task 7). +use std::collections::HashSet; use std::fmt; use std::str::FromStr; @@ -13,6 +14,7 @@ use serde::Serialize; use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde_json::Value; +use crate::digest::Sha256Digest; use crate::id::RecordId; use crate::serde_json_number; @@ -67,7 +69,6 @@ pub use surface::{Delivery, SurfaceEffect, SurfaceEvent}; /// Maximum accepted encoded or embedded canonical document size. pub const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; -const MAX_JSON_DEPTH: usize = 128; /// Reasons a contract primitive failed to validate. /// @@ -85,8 +86,13 @@ pub enum ContractError { /// The kind is known but this build does not accept the declared major. /// The rejected string is intentionally not retained: attacker-controlled /// schema text must not propagate into error payloads or logs. - #[error("schema version declares an unsupported major")] - UnsupportedMajor, + #[error("schema version declares unsupported major {found}; supported major is {supported}")] + UnsupportedMajor { + /// Parsed unsupported major. Malformed major syntax is reported as zero. + found: u16, + /// Major accepted by this build. + supported: u16, + }, /// A record identifier did not carry the exact prefix its requested /// `RecordKind` requires. The required prefix is [`RecordKind::prefix`], /// not stored redundantly on this error. @@ -351,6 +357,9 @@ impl SchemaKind { /// presently at major 1, and a kind reaching major 2 is a deliberate, /// reviewed change to this file rather than a silent range extension. const SUPPORTED_MAJOR: u16 = 1; +const MAX_JSON_DEPTH: usize = 64; +const MAX_REJECTED_PAYLOAD_BYTES: usize = 64 * 1024; +const MAX_RETAINED_SCHEMA_VERSION_BYTES: usize = 128; /// A validated `psyche..v` contract schema version. /// @@ -391,10 +400,13 @@ impl SchemaVersion { let kind = SchemaKind::from_name(kind_segment).ok_or_else(unknown)?; - let unsupported_major = || ContractError::UnsupportedMajor; + let unsupported_major = |found| ContractError::UnsupportedMajor { + found, + supported: SUPPORTED_MAJOR, + }; let digits = major_segment .strip_prefix('v') - .ok_or_else(unsupported_major)?; + .ok_or_else(|| unsupported_major(0))?; // Reject a leading zero on a multi-digit major ("v01"): it parses to // the same integer as "v1" but is not the canonical string, and the // registry only accepts the canonical form. @@ -402,11 +414,11 @@ impl SchemaVersion { || (digits.len() > 1 && digits.starts_with('0')) || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(unsupported_major()); + return Err(unsupported_major(0)); } - let major: u16 = digits.parse().map_err(|_| unsupported_major())?; + let major: u16 = digits.parse().map_err(|_| unsupported_major(0))?; if major != SUPPORTED_MAJOR { - return Err(unsupported_major()); + return Err(unsupported_major(major)); } Ok(SchemaVersion { kind, major }) } @@ -448,6 +460,78 @@ pub trait VersionedRecord: Serialize { fn record_id(&self) -> &RecordId; } +/// Payload-light classification attached to bytes retained for quarantine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectionReason { + /// The raw input exceeded [`MAX_DOCUMENT_BYTES`]. + TooLarge, + /// The schema kind is not in this build's registry. + UnknownSchema, + /// The schema kind is known but its major is unsupported. + UnsupportedMajor { + /// Parsed unsupported major. + found: u16, + /// Major accepted by this build. + supported: u16, + }, + /// A typed enum field used an unknown spelling. + UnknownEnumValue { + /// Schema containing the enum. + schema: SchemaKind, + /// Static field path. + field: &'static str, + }, + /// JSON or typed document shape was invalid. + InvalidShape { + /// Schema associated with the failure. + schema: SchemaKind, + /// Static field or validation category. + field: &'static str, + }, +} + +/// Bounded raw input and metadata suitable for a later quarantine store. +#[derive(Clone, PartialEq, Eq)] +pub struct RejectedDocument { + /// Safely bounded schema text, when a strict JSON parse can extract it. + pub schema_version: Option, + /// SHA-256 over the complete raw input, including bytes not retained. + pub payload_digest: Sha256Digest, + /// At most 64 KiB from the beginning of the raw input. + pub bounded_payload: Vec, + /// Payload-light rejection classification. + pub reason: RejectionReason, +} + +impl RejectedDocument { + /// Builds quarantine input without requiring valid UTF-8 or valid JSON. + pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { + let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { + strict_json(bytes) + .ok() + .and_then(|value| retained_schema_version(&value)) + } else { + None + }; + Self { + schema_version, + payload_digest: Sha256Digest::from_raw_bytes(bytes), + bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), + reason, + } + } +} + +impl fmt::Debug for RejectedDocument { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RejectedDocument") + .field("bounded_payload_bytes", &self.bounded_payload.len()) + .field("payload_digest", &self.payload_digest) + .field("reason", &self.reason) + .finish() + } +} + /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] @@ -566,12 +650,10 @@ pub fn decode_document(bytes: &[u8]) -> Result } let value = strict_json(bytes)?; crate::digest::validate_json_domain(&value)?; - let schema_text = value - .as_object() - .and_then(|v| v.get("schema_version")) - .and_then(Value::as_str) - .ok_or_else(|| invalid(SchemaKind::Error, "schema_version"))?; - let schema = SchemaVersion::parse(schema_text)?; + let probe: VersionProbe = serde_json::from_value(value.clone()) + .map_err(|_| invalid(SchemaKind::Error, "schema_version"))?; + let schema = SchemaVersion::parse(&probe.schema_version)?; + inspect_typed_enums(&value, schema.kind)?; let document = match schema.kind { SchemaKind::IdentitySnapshot => { decode(value, CanonicalDocument::IdentitySnapshot, schema.kind)? @@ -592,14 +674,17 @@ pub fn decode_document(bytes: &[u8]) -> Result SchemaKind::Addon => decode(value, CanonicalDocument::Addon, schema.kind)?, SchemaKind::SurfaceEffect => decode(value, CanonicalDocument::SurfaceEffect, schema.kind)?, SchemaKind::Delivery => decode(value, CanonicalDocument::Delivery, schema.kind)?, - SchemaKind::Error => { - return ErrorEnvelope::decode(value).map(CanonicalDocument::Error); - } + SchemaKind::Error => CanonicalDocument::Error(ErrorEnvelope::decode(value)?), }; document.validate()?; Ok(document) } +#[derive(serde::Deserialize)] +struct VersionProbe { + schema_version: String, +} + fn strict_json(bytes: &[u8]) -> Result { let mut deserializer = serde_json::Deserializer::from_slice(bytes); let value = StrictValueSeed { depth: 0 } @@ -695,7 +780,7 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } fn visit_seq>(self, mut sequence: A) -> Result { - let mut values = Vec::new(); + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); while let Some(value) = sequence.next_element_seed(StrictValueSeed { depth: self.depth + 1, })? { @@ -723,10 +808,11 @@ impl<'de> Visitor<'de> for StrictValueVisitor { .ok_or_else(|| serde::de::Error::custom("malformed arbitrary-precision number")); } - let mut values = serde_json::Map::new(); + let mut keys = HashSet::with_capacity(object.size_hint().unwrap_or(0)); + let mut values = serde_json::Map::with_capacity(object.size_hint().unwrap_or(0)); while let Some(key) = object.next_key::()? { - if key == serde_json_number::TOKEN || values.contains_key(&key) { - return Err(serde::de::Error::custom("invalid JSON object key")); + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom("duplicate JSON object key")); } let value = object.next_value_seed(StrictValueSeed { depth: self.depth + 1, @@ -737,6 +823,227 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } } +fn retained_schema_version(value: &Value) -> Option { + let schema = value.as_object()?.get("schema_version")?.as_str()?; + if schema.len() <= MAX_RETAINED_SCHEMA_VERSION_BYTES + && schema.starts_with("psyche.") + && schema.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_') + }) + { + Some(schema.to_owned()) + } else { + None + } +} + +fn inspect_typed_enums(value: &Value, schema: SchemaKind) -> Result<(), ContractError> { + match schema { + SchemaKind::Graph => inspect_enum( + value, + &["state"], + &[ + "draft", + "admitted", + "rejected", + "running", + "waiting_approval", + "waiting_evidence", + "cancelling", + "completed", + "failed", + "cancelled", + "recovery_required", + ], + schema, + "state", + ), + SchemaKind::GraphNode => inspect_enum( + value, + &["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", + ], + schema, + "state", + ), + SchemaKind::ExecutionBinding => { + inspect_enum( + value, + &["adoption_state"], + &[ + "not_submitted", + "submitting", + "adopted", + "proven_not_adopted", + "adoption_unknown", + "fenced", + ], + schema, + "adoption_state", + )?; + inspect_enum( + value, + &["cancellation_state"], + &[ + "not_requested", + "termination_requested", + "acknowledged_terminated", + "acknowledged_already_terminal", + "termination_unknown", + ], + schema, + "cancellation_state", + )?; + inspect_enum( + value, + &["cancellation_acknowledgement", "kind"], + &["terminated", "already_authoritatively_terminal"], + schema, + "cancellation_acknowledgement.kind", + ) + } + SchemaKind::Delivery => { + inspect_enum( + value, + &["relationship"], + &[ + "reply_same_dm", + "reply_same_group", + "reply_same_topic", + "cross_chat", + "broadcast", + ], + schema, + "relationship", + )?; + inspect_enum( + value, + &["state"], + &[ + "ready", + "sending", + "sent", + "retryable", + "delivery_unknown", + "failed", + "abandoned", + "dead_letter", + "resolving_unknown", + "compensated", + ], + schema, + "state", + )?; + inspect_enum( + value, + &["surface_decision", "state"], + &["reserved", "consumed"], + schema, + "surface_decision.state", + ) + } + SchemaKind::Error => inspect_enum( + value, + &["error", "code"], + &[ + "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", + ], + schema, + "code", + ), + SchemaKind::IdentitySnapshot + | SchemaKind::Intent + | SchemaKind::SurfaceEvent + | SchemaKind::Delegation + | SchemaKind::Budget + | SchemaKind::Approval + | SchemaKind::Evidence + | SchemaKind::Verdict + | SchemaKind::Recovery + | SchemaKind::Addon + | SchemaKind::SurfaceEffect => Ok(()), + } +} + +fn inspect_enum( + value: &Value, + path: &[&str], + accepted: &[&str], + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + let mut current = value; + for segment in path { + let Some(next) = current.as_object().and_then(|object| object.get(*segment)) else { + return Ok(()); + }; + current = next; + } + if let Some(spelling) = current.as_str() { + if !accepted.contains(&spelling) { + return Err(ContractError::UnknownEnumValue { schema, field }); + } + } + Ok(()) +} + fn decode( value: Value, wrap: impl FnOnce(T) -> CanonicalDocument, @@ -903,7 +1210,7 @@ mod tests { let attacker = format!("psyche.intent.v{}{}", marker, "9".repeat(900_000)); let err = SchemaVersion::parse(&attacker).unwrap_err(); assert!( - matches!(err, ContractError::UnsupportedMajor), + matches!(err, ContractError::UnsupportedMajor { .. }), "expected UnsupportedMajor, got {err:?}" ); let debug = format!("{err:?}"); @@ -977,7 +1284,7 @@ mod tests { #[test] fn schema_version_rejects_a_known_kind_with_the_wrong_major() { let err = SchemaVersion::parse("psyche.intent.v2").unwrap_err(); - assert!(matches!(err, ContractError::UnsupportedMajor)); + assert!(matches!(err, ContractError::UnsupportedMajor { .. })); } #[test] @@ -1028,7 +1335,7 @@ mod tests { ); } else { assert!( - matches!(err, ContractError::UnsupportedMajor), + matches!(err, ContractError::UnsupportedMajor { .. }), "expected UnsupportedMajor for {near:?}, got {err:?}" ); } diff --git a/crates/psyche-core/src/digest.rs b/crates/psyche-core/src/digest.rs index a594041..d94741c 100644 --- a/crates/psyche-core/src/digest.rs +++ b/crates/psyche-core/src/digest.rs @@ -34,8 +34,7 @@ const MIN_SAFE_INTEGER_I128: i128 = -MAX_SAFE_INTEGER_I128; /// keys, before that representation is passed to the canonicalizer. pub fn canonical_bytes(value: &T) -> Result, ContractError> { let collected = collect(value).map_err(validation_failed)?; - let canonicalizer_input = collected.into_json().map_err(validation_failed)?; - serde_json_canonicalizer::to_vec(&canonicalizer_input).map_err(canonicalization_failed) + serde_json_canonicalizer::to_vec(&collected).map_err(canonicalization_failed) } fn canonicalization_failed(_error: impl fmt::Display) -> ContractError { @@ -59,22 +58,9 @@ pub(crate) fn validate_json_domain(value: &Value) -> Result<(), ContractError> { } fn validate_json_number(number: &Number) -> Result<(), ContractError> { - let interoperable = if let Some(value) = number.as_i64() { - value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER as i64 - } else if let Some(value) = number.as_u64() { - value <= MAX_SAFE_INTEGER - } else if let Some(value) = number.as_f64() { - value.is_finite() - && (value.fract() != 0.0 - || (value >= MIN_SAFE_INTEGER as f64 && value <= MAX_SAFE_INTEGER as f64)) - } else { - false - }; - if interoperable { - Ok(()) - } else { - Err(ContractError::NonInteroperableNumber) - } + collect_number_text(number.as_str()) + .map(|_| ()) + .map_err(|_| ContractError::NonInteroperableNumber) } fn validate_float(value: f64) -> Result<(), ContractError> { @@ -122,34 +108,22 @@ enum CollectedValue { Signed(i64), Unsigned(u64), Float(f64), - Number(Number), String(String), Array(Vec), Object(BTreeMap), } -impl CollectedValue { - fn into_json(self) -> Result { +impl Serialize for CollectedValue { + fn serialize(&self, serializer: S) -> Result { match self { - Self::Null => Ok(Value::Null), - Self::Bool(value) => Ok(Value::Bool(value)), - Self::Signed(value) => Ok(Value::Number(value.into())), - Self::Unsigned(value) => Ok(Value::Number(value.into())), - Self::Float(value) => Number::from_f64(value) - .map(Value::Number) - .ok_or(ValidationError::NonInteroperableNumber), - Self::Number(value) => Ok(Value::Number(value)), - Self::String(value) => Ok(Value::String(value)), - Self::Array(values) => values - .into_iter() - .map(Self::into_json) - .collect::, _>>() - .map(Value::Array), - Self::Object(values) => values - .into_iter() - .map(|(key, value)| Ok((key, value.into_json()?))) - .collect::, _>>() - .map(Value::Object), + Self::Null => serializer.serialize_unit(), + Self::Bool(value) => serializer.serialize_bool(*value), + Self::Signed(value) => serializer.serialize_i64(*value), + Self::Unsigned(value) => serializer.serialize_u64(*value), + Self::Float(value) => serializer.serialize_f64(*value), + Self::String(value) => serializer.serialize_str(value), + Self::Array(values) => values.serialize(serializer), + Self::Object(values) => values.serialize(serializer), } } } @@ -289,10 +263,14 @@ impl Serializer for ValueCollector { fn serialize_newtype_struct( self, - _name: &'static str, + name: &'static str, value: &T, ) -> Result { - collect(value) + if name == serde_json_number::TOKEN { + collect_private_number(value) + } else { + collect(value) + } } fn serialize_newtype_variant( @@ -344,7 +322,7 @@ impl Serializer for ValueCollector { if self.allow_private_number && length == 1 { Ok(ObjectCollector::private_number()) } else { - Err(ValidationError::SerializationFailed) + Ok(ObjectCollector::new(None)) } } else { Ok(ObjectCollector::new(None)) @@ -383,10 +361,172 @@ fn collect_float(value: f64) -> Result { Ok(CollectedValue::Float(value)) } -fn singleton_object(key: &str, value: CollectedValue) -> Result { - if key == serde_json_number::TOKEN { - return Err(ValidationError::SerializationFailed); +fn collect_private_number( + value: &T, +) -> Result { + let text = value.serialize(PrivateNumberTextCollector)?; + collect_number_text(&text) +} + +fn collect_number_text(text: &str) -> Result { + let bytes = text.as_bytes(); + let mut index = 0; + let negative = if bytes.first() == Some(&b'-') { + index += 1; + true + } else { + false + }; + let mut digits = Vec::with_capacity(bytes.len()); + + match bytes.get(index).copied() { + Some(b'0') => { + digits.push(b'0'); + index += 1; + if bytes.get(index).is_some_and(u8::is_ascii_digit) { + return Err(ValidationError::NonInteroperableNumber); + } + } + Some(b'1'..=b'9') => { + while let Some(digit @ b'0'..=b'9') = bytes.get(index).copied() { + digits.push(digit); + index += 1; + } + } + _ => return Err(ValidationError::NonInteroperableNumber), + } + + let mut fractional_digits = 0; + if bytes.get(index) == Some(&b'.') { + index += 1; + let fraction_start = index; + while let Some(digit @ b'0'..=b'9') = bytes.get(index).copied() { + digits.push(digit); + index += 1; + } + fractional_digits = index - fraction_start; + if fractional_digits == 0 { + return Err(ValidationError::NonInteroperableNumber); + } + } + + let mut exponent = Some(0_i128); + let mut exponent_is_negative = false; + if matches!(bytes.get(index), Some(b'e' | b'E')) { + index += 1; + match bytes.get(index) { + Some(b'+') => index += 1, + Some(b'-') => { + exponent_is_negative = true; + index += 1; + } + _ => {} + } + let exponent_start = index; + let mut magnitude = Some(0_i128); + while let Some(digit @ b'0'..=b'9') = bytes.get(index).copied() { + magnitude = magnitude.and_then(|value| { + value + .checked_mul(10) + .and_then(|value| value.checked_add(i128::from(digit - b'0'))) + }); + index += 1; + } + if index == exponent_start { + return Err(ValidationError::NonInteroperableNumber); + } + exponent = magnitude.map(|value| if exponent_is_negative { -value } else { value }); + } + + if index != bytes.len() { + return Err(ValidationError::NonInteroperableNumber); + } + if digits.iter().all(|digit| *digit == b'0') { + return Ok(CollectedValue::Unsigned(0)); } + let Some(exponent) = exponent else { + return if exponent_is_negative { + collect_number_float(text) + } else { + Err(ValidationError::NonInteroperableNumber) + }; + }; + let fractional_digits = + i128::try_from(fractional_digits).map_err(|_| ValidationError::NonInteroperableNumber)?; + let Some(scale) = exponent.checked_sub(fractional_digits) else { + return collect_number_float(text); + }; + let trailing_zeros = digits + .iter() + .rev() + .take_while(|digit| **digit == b'0') + .count(); + let trailing_zeros_i128 = + i128::try_from(trailing_zeros).map_err(|_| ValidationError::NonInteroperableNumber)?; + let effective_scale = scale + .checked_add(trailing_zeros_i128) + .ok_or(ValidationError::NonInteroperableNumber)?; + + if effective_scale >= 0 { + collect_integral_number(&digits, trailing_zeros, effective_scale, negative) + } else { + collect_number_float(text) + } +} + +fn collect_integral_number( + digits: &[u8], + trailing_zeros: usize, + effective_scale: i128, + negative: bool, +) -> Result { + let significant_end = digits.len() - trailing_zeros; + let first_nonzero = digits + .iter() + .position(|digit| *digit != b'0') + .ok_or(ValidationError::NonInteroperableNumber)?; + let appended_zeros = + usize::try_from(effective_scale).map_err(|_| ValidationError::NonInteroperableNumber)?; + let total_digits = significant_end + .checked_sub(first_nonzero) + .and_then(|length| length.checked_add(appended_zeros)) + .ok_or(ValidationError::NonInteroperableNumber)?; + if total_digits > 16 { + return Err(ValidationError::NonInteroperableNumber); + } + + let mut magnitude = 0_u64; + for digit in &digits[first_nonzero..significant_end] { + magnitude = magnitude + .checked_mul(10) + .and_then(|value| value.checked_add(u64::from(*digit - b'0'))) + .ok_or(ValidationError::NonInteroperableNumber)?; + } + for _ in 0..appended_zeros { + magnitude = magnitude + .checked_mul(10) + .ok_or(ValidationError::NonInteroperableNumber)?; + } + if magnitude > MAX_SAFE_INTEGER { + return Err(ValidationError::NonInteroperableNumber); + } + if negative { + let magnitude = + i64::try_from(magnitude).map_err(|_| ValidationError::NonInteroperableNumber)?; + Ok(CollectedValue::Signed(-magnitude)) + } else { + Ok(CollectedValue::Unsigned(magnitude)) + } +} + +fn collect_number_float(text: &str) -> Result { + let value = text + .parse::() + .map_err(|_| ValidationError::NonInteroperableNumber)?; + collect_float(value) +} + +fn singleton_object(key: &str, value: CollectedValue) -> Result { Ok(CollectedValue::Object(BTreeMap::from([( key.to_owned(), value, @@ -476,7 +616,7 @@ struct ObjectCollector { values: BTreeMap, next_key: Option, variant: Option<&'static str>, - private_number: Option>, + private_number: Option>, } impl ObjectCollector { @@ -499,10 +639,7 @@ impl ObjectCollector { } fn insert(&mut self, key: String, value: CollectedValue) -> Result<(), ValidationError> { - if self.private_number.is_some() - || key == serde_json_number::TOKEN - || self.values.insert(key, value).is_some() - { + if self.private_number.is_some() || self.values.insert(key, value).is_some() { Err(ValidationError::SerializationFailed) } else { Ok(()) @@ -514,9 +651,7 @@ impl ObjectCollector { return Err(ValidationError::SerializationFailed); } if let Some(number) = self.private_number { - return number - .map(CollectedValue::Number) - .ok_or(ValidationError::SerializationFailed); + return number.ok_or(ValidationError::SerializationFailed); } let value = CollectedValue::Object(self.values); match self.variant { @@ -567,13 +702,7 @@ impl SerializeStruct for ObjectCollector { if key != serde_json_number::TOKEN || number.is_some() { return Err(ValidationError::SerializationFailed); } - let CollectedValue::String(text) = collect(value)? else { - return Err(ValidationError::SerializationFailed); - }; - let parsed = serde_json_number::parse_exact(&text) - .ok_or(ValidationError::SerializationFailed)?; - validate_json_number(&parsed).map_err(|_| ValidationError::NonInteroperableNumber)?; - *number = Some(parsed); + *number = Some(collect_private_number(value)?); Ok(()) } else { self.insert(key.to_owned(), collect(value)?) @@ -602,6 +731,180 @@ impl SerializeStructVariant for ObjectCollector { } } +#[derive(Clone, Copy)] +struct PrivateNumberTextCollector; + +impl Serializer for PrivateNumberTextCollector { + type Ok = String; + type Error = ValidationError; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; + + fn serialize_bool(self, _value: bool) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_i8(self, _value: i8) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_i16(self, _value: i16) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_i32(self, _value: i32) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_i64(self, _value: i64) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_i128(self, _value: i128) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_u8(self, _value: u8) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_u16(self, _value: u16) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_u32(self, _value: u32) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_u64(self, _value: u64) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_u128(self, _value: u128) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_f32(self, _value: f32) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_f64(self, _value: f64) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_char(self, _value: char) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_str(self, value: &str) -> Result { + Ok(value.to_owned()) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_none(self) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_some(self, _value: &T) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_unit(self) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + _value: &T, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_seq(self, _length: Option) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple(self, _length: usize) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_map(self, _length: Option) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_struct( + self, + _name: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _length: usize, + ) -> Result { + Err(ValidationError::SerializationFailed) + } + + fn is_human_readable(&self) -> bool { + true + } +} + #[derive(Clone, Copy)] struct MapKeyCollector; @@ -839,6 +1142,16 @@ impl Sha256Digest { Ok(Sha256Digest(value.to_string())) } + pub(crate) fn from_raw_bytes(bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes); + Self(format!( + "{}{}", + Self::PREFIX, + to_lower_hex(hasher.finalize().as_slice()) + )) + } + /// The full digest string, e.g. `"sha256:<64 lowercase hex chars>"`. pub fn as_str(&self) -> &str { &self.0 @@ -949,29 +1262,77 @@ mod tests { } #[test] - fn private_number_marker_lookalikes_cannot_smuggle_numbers_or_objects() { + fn private_number_marker_lookalikes_remain_ordinary_objects() { const TOKEN: &str = "$serde_json::private::Number"; #[derive(Serialize)] #[serde(rename = "$serde_json::private::Number")] - struct SpoofedNumber<'a> { + struct OrdinaryStruct<'a> { #[serde(rename = "$serde_json::private::Number")] text: &'a str, } assert_eq!( - canonical_bytes(&SpoofedNumber { text: "1.5" }), - Err(ContractError::CanonicalizationFailed) + canonical_bytes(&OrdinaryStruct { text: "1.5" }).unwrap(), + br#"{"$serde_json::private::Number":"1.5"}"# ); - for lookalike in [ - json!({TOKEN: "1.5"}), - json!({TOKEN: 1.5}), - json!({TOKEN: "1.5", "extra": true}), + for (lookalike, expected) in [ + ( + json!({TOKEN: "1.5"}), + r#"{"$serde_json::private::Number":"1.5"}"#, + ), + ( + json!({TOKEN: 1.5}), + r#"{"$serde_json::private::Number":1.5}"#, + ), + ( + json!({TOKEN: "1.5", "extra": true}), + r#"{"$serde_json::private::Number":"1.5","extra":true}"#, + ), + ] { + assert_eq!(canonical_bytes(&lookalike).unwrap(), expected.as_bytes()); + } + } + + #[test] + fn exact_private_number_newtypes_are_validated_and_normalized() { + #[derive(Serialize)] + #[serde(rename = "$serde_json::private::Number")] + struct PrivateNumber<'a>(&'a str); + + for (source, expected) in [ + ("-0", "0"), + ("1.2300", "1.23"), + ("1e3", "1000"), + ("9007199254740991000e-3", "9007199254740991"), + ("-9007199254740991000e-3", "-9007199254740991"), + ] { + assert_eq!( + canonical_bytes(&PrivateNumber(source)).unwrap(), + expected.as_bytes(), + "{source}" + ); + } + + for source in [ + "9007199254740992e0", + "9007199254740992000e-3", + "9.007199254740992e15", + "1e400", + "+1", + "01", + "1.", + ".1", + "NaN", + "Infinity", + "1e", + "1 trailing", ] { assert_eq!( - canonical_bytes(&lookalike), - Err(ContractError::CanonicalizationFailed) + canonical_bytes(&PrivateNumber(source)), + Err(ContractError::NonInteroperableNumber), + "{source}" ); } } diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index c7eb8c5..e4ac25a 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -15,7 +15,7 @@ use psyche_core::contracts::surface::{ Delivery, DeliverySurfaceDecision, SurfaceEffect, SurfaceEvent, }; use psyche_core::contracts::{CanonicalDocument, ContractError, SchemaKind, decode_document}; -use psyche_core::digest::canonical_bytes; +use psyche_core::digest::{canonical_bytes, digest}; use psyche_core::id::{RecordId, RequestId}; use serde_json::{Value, json}; use time::OffsetDateTime; @@ -988,9 +988,6 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ("acknowledgement timestamp", |value: &mut Value| { value["cancellation_acknowledgement"]["acknowledged_at"] = json!("tomorrow"); }), - ("acknowledgement kind", |value: &mut Value| { - value["cancellation_acknowledgement"]["kind"] = json!("future_kind"); - }), ("termination request id", |value: &mut Value| { value["termination_request"]["termination_request_id"] = json!("not-a-request"); }), @@ -1022,6 +1019,16 @@ fn cancellation_binding_decode_maps_nested_wire_failures_to_evidence_mismatch() ); } + let mut unknown_kind = binding_value(CancellationState::AcknowledgedTerminated); + unknown_kind["cancellation_acknowledgement"]["kind"] = json!("future_kind"); + assert!(matches!( + decode_document(&serde_json::to_vec(&unknown_kind).unwrap()), + Err(ContractError::UnknownEnumValue { + schema: SchemaKind::ExecutionBinding, + field: "cancellation_acknowledgement.kind", + }) + )); + let mut unknown = binding_value(CancellationState::TerminationRequested); unknown["cancellation_state"] = json!("cancelled"); assert!(decode_document(&serde_json::to_vec(&unknown).unwrap()).is_err()); @@ -1378,44 +1385,80 @@ fn decoded_document_rejects_nested_unsafe_integers() { } #[test] -fn decoded_document_preserves_interoperable_arbitrary_precision_numbers() { +fn decoded_and_direct_values_preserve_interoperable_arbitrary_precision_numbers() { let bytes = replace_fixture_once( "intent-local.json", r#""constraints": {}"#, - r#""constraints": {"safe": 9007199254740991, "fraction": 1.2300}"#, + r#""constraints": {"safe": 9007199254740991, "fraction": 1.2300, "exponent": 1e3}"#, ); + let direct: Value = + serde_json::from_str(r#"{"safe":9007199254740991,"fraction":1.2300,"exponent":1e3}"#) + .unwrap(); let CanonicalDocument::Intent(intent) = decode_document(&bytes).unwrap() else { panic!("expected intent"); }; assert_eq!( canonical_bytes(&intent.constraints).unwrap(), - br#"{"fraction":1.23,"safe":9007199254740991}"# + br#"{"exponent":1000,"fraction":1.23,"safe":9007199254740991}"# + ); + assert_eq!( + canonical_bytes(&intent.constraints).unwrap(), + canonical_bytes(&direct).unwrap() ); } #[test] -fn decoded_document_rejects_private_number_marker_lookalikes() { - for constraints in [ - r#"{"$serde_json::private::Number": "1.5"}"#, - r#"{"$serde_json::private::Number": 1.5}"#, - r#"{"$serde_json::private::Number": "1.5", "extra": true}"#, +fn decoded_private_number_marker_lookalikes_remain_objects() { + for (constraints, expected) in [ + ( + r#"{"$serde_json::private::Number": "1.5"}"#, + r#"{"$serde_json::private::Number":"1.5"}"#, + ), + ( + r#"{"$serde_json::private::Number": 1.5}"#, + r#"{"$serde_json::private::Number":1.5}"#, + ), + ( + r#"{"$serde_json::private::Number": "1.5", "extra": true}"#, + r#"{"$serde_json::private::Number":"1.5","extra":true}"#, + ), ] { let bytes = replace_fixture_once( "intent-local.json", r#""constraints": {}"#, &format!(r#""constraints": {constraints}"#), ); - assert!(matches!( - decode_document(&bytes), - Err(ContractError::InvalidShape { - schema: SchemaKind::Error, - field: "json", - }) - )); + let CanonicalDocument::Intent(intent) = decode_document(&bytes).unwrap() else { + panic!("expected intent"); + }; + assert_eq!( + canonical_bytes(&intent.constraints).unwrap(), + expected.as_bytes() + ); } } +#[test] +fn arbitrary_precision_effect_digests_are_stable_across_decode() { + let effect: Value = + serde_json::from_str(r#"{"safe":9007199254740991,"fraction":1.2300}"#).unwrap(); + let expected_digest = digest(&effect).unwrap(); + let bytes = mutate("surface-effect.json", |object| { + object.insert("effect".into(), effect.clone()); + object.insert("effect_digest".into(), json!(expected_digest.as_str())); + }); + + let CanonicalDocument::SurfaceEffect(decoded) = decode_document(&bytes).unwrap() else { + panic!("expected surface effect"); + }; + assert_eq!(digest(&decoded.effect).unwrap(), expected_digest); + assert_eq!( + canonical_bytes(&decoded.effect).unwrap(), + canonical_bytes(&effect).unwrap() + ); +} + fn assert_invalid_numeric_field( result: Result<(), ContractError>, schema: SchemaKind, diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs new file mode 100644 index 0000000..32f70f8 --- /dev/null +++ b/crates/psyche-core/tests/decode.rs @@ -0,0 +1,365 @@ +//! Fail-closed canonical document decoding and quarantine-input tests. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use psyche_core::contracts::{ + CanonicalDocument, ContractError, MAX_DOCUMENT_BYTES, RejectedDocument, RejectionReason, + SchemaKind, decode_document, +}; +use serde_json::{Value, json}; + +const ULID_A: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const ULID_B: &str = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; +const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn assert_redacted(error: &ContractError, rejected_value: &str) { + assert!(!format!("{error:?}").contains(rejected_value)); + assert!(!error.to_string().contains(rejected_value)); +} + +fn graph() -> Value { + json!({ + "schema_version": "psyche.graph.v1", + "graph_id": format!("grf_{ULID_A}"), + "root_intent_id": format!("int_{ULID_A}"), + "owner_principal_id": "principal:one", + "policy_revision": "policy:one", + "state": "draft", + "version": 1 + }) +} + +fn execution_binding() -> Value { + json!({ + "schema_version": "psyche.execution_binding.v1", + "attempt_id": format!("att_{ULID_A}"), + "revision": 1, + "previous_revision_digest": null, + "revision_created_at": "2026-08-01T00:00:00Z", + "familiar_snapshot_id": format!("ids_{ULID_B}"), + "project_id": "project:one", + "request_id": format!("req_{ULID_A}"), + "request_digest": DIGEST, + "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": 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 + }) +} + +#[test] +fn unknown_major_never_decodes_as_a_known_record() { + let secret = "intent-secret-sentinel"; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v2","intent_id":"{secret}","raw":"{secret}"}}"# + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert_eq!( + error, + ContractError::UnsupportedMajor { + found: 2, + supported: 1, + } + ); + assert_redacted(&error, secret); +} + +#[test] +fn malformed_payload_is_bounded_before_quarantine() { + let bytes = vec![b'x'; MAX_DOCUMENT_BYTES + 1]; + assert_eq!( + decode_document(&bytes), + Err(ContractError::DocumentTooLarge) + ); + let rejected = RejectedDocument::from_bytes(&bytes, RejectionReason::TooLarge); + assert_eq!(rejected.bounded_payload.len(), 64 * 1024); +} + +#[test] +fn recognized_error_envelope_decodes_exhaustively() { + let document = + decode_document(&fixture("error-storage-unavailable.json")).expect("error fixture"); + assert!(matches!(document, CanonicalDocument::Error(_))); + assert_eq!(document.schema_version().kind, SchemaKind::Error); +} + +#[test] +fn error_non_string_details_is_invalid_shape() { + let mut value: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + value["error"]["details"]["attempt"] = json!(3); + assert!(matches!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + }) + )); +} + +#[test] +fn error_unknown_field_is_invalid_shape() { + let mut value: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + value["error"]["secret"] = json!("must-not-leak"); + assert!(matches!( + decode_document(&serde_json::to_vec(&value).unwrap()), + Err(ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + }) + )); +} + +#[test] +fn unknown_typed_enum_is_a_quarantinable_decode_failure() { + let rejected_value = "future_state_secret"; + let mut value = graph(); + value["state"] = json!(rejected_value); + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!( + error, + ContractError::UnknownEnumValue { + schema: SchemaKind::Graph, + field: "state", + } + ); + assert_redacted(&error, rejected_value); +} + +#[test] +fn every_typed_enum_reports_its_static_field_without_the_rejected_value() { + let rejected = "future_enum_secret"; + let mut cases = Vec::new(); + + let mut node: Value = serde_json::from_slice(&fixture("node-root.json")).unwrap(); + node["state"] = json!(rejected); + cases.push((node, SchemaKind::GraphNode, "state")); + + let mut adoption = execution_binding(); + adoption["adoption_state"] = json!(rejected); + cases.push((adoption, SchemaKind::ExecutionBinding, "adoption_state")); + + let mut cancellation = execution_binding(); + cancellation["cancellation_state"] = json!(rejected); + cases.push(( + cancellation, + SchemaKind::ExecutionBinding, + "cancellation_state", + )); + + let mut acknowledgement = execution_binding(); + acknowledgement["cancellation_acknowledgement"] = json!({"kind": rejected}); + cases.push(( + acknowledgement, + SchemaKind::ExecutionBinding, + "cancellation_acknowledgement.kind", + )); + + let delivery: Value = serde_json::from_slice(&fixture("delivery-ready.json")).unwrap(); + for (field, expected) in [ + ("relationship", "relationship"), + ("state", "state"), + ("surface_decision.state", "surface_decision.state"), + ] { + let mut value = delivery.clone(); + if field == "surface_decision.state" { + value["surface_decision"]["state"] = json!(rejected); + } else { + value[field] = json!(rejected); + } + cases.push((value, SchemaKind::Delivery, expected)); + } + + let mut error: Value = + serde_json::from_slice(&fixture("error-storage-unavailable.json")).unwrap(); + error["error"]["code"] = json!(rejected); + cases.push((error, SchemaKind::Error, "code")); + + for (value, schema, field) in cases { + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!( + error, + ContractError::UnknownEnumValue { schema, field }, + "{schema:?}.{field}" + ); + assert_redacted(&error, rejected); + } +} + +#[test] +fn duplicate_keys_at_every_recursive_location_fail_closed() { + let sentinel = "duplicate-secret-sentinel"; + let cases = [ + format!( + r#"{{"schema_version":"psyche.intent.v1","schema_version":"psyche.intent.v2","raw":"{sentinel}"}}"# + ), + format!( + r#"{{"schema_version":"psyche.intent.v2","schema_version":"psyche.intent.v1","raw":"{sentinel}"}}"# + ), + format!(r#"{{"schema_version":"psyche.intent.v1","raw":"{sentinel}","raw":"second"}}"#), + format!( + r#"{{"schema_version":"psyche.intent.v1","constraints":{{"nested":{{"key":"{sentinel}","key":"second"}}}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","actor":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","locator":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","content":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_effect.v1","effect":{{"key":"{sentinel}","key":"second"}}}}"# + ), + format!( + r#"{{"schema_version":"psyche.surface_event.v1","content":{{"items":[{{"key":"{sentinel}","key":"second"}}]}}}}"# + ), + ]; + + for bytes in cases { + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!(matches!( + error, + ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + } + )); + assert_redacted(&error, sentinel); + } +} + +#[test] +fn excessive_json_nesting_is_rejected_without_echoing_payload() { + let sentinel = "depth-secret-sentinel"; + let depth = 80; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v1","constraints":{}"{sentinel}"{}}}}}"#, + "[".repeat(depth), + "]".repeat(depth) + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!(matches!(error, ContractError::InvalidShape { .. })); + assert_redacted(&error, sentinel); +} + +#[test] +fn recognized_registry_entries_never_report_unknown_or_unsupported() { + for schema in [ + "identity_snapshot", + "intent", + "surface_event", + "graph", + "graph_node", + "delegation", + "budget", + "approval", + "execution_binding", + "evidence", + "verdict", + "recovery", + "addon", + "surface_effect", + "delivery", + "error", + ] { + let bytes = format!(r#"{{"schema_version":"psyche.{schema}.v1"}}"#); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!( + !matches!( + error, + ContractError::UnknownSchema | ContractError::UnsupportedMajor { .. } + ), + "{schema} fell through the registry: {error:?}" + ); + } +} + +#[test] +fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { + let small = RejectedDocument::from_bytes( + b"abc", + RejectionReason::InvalidShape { + schema: SchemaKind::Error, + field: "json", + }, + ); + assert_eq!(small.bounded_payload, b"abc"); + assert_eq!( + small.payload_digest.as_str(), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + + let mut left = vec![b'a'; 64 * 1024 + 1]; + let mut right = left.clone(); + left[64 * 1024] = b'x'; + right[64 * 1024] = b'y'; + + let left = RejectedDocument::from_bytes( + &left, + RejectionReason::InvalidShape { + schema: SchemaKind::Intent, + field: "document", + }, + ); + let right = RejectedDocument::from_bytes( + &right, + RejectionReason::InvalidShape { + schema: SchemaKind::Intent, + field: "document", + }, + ); + + assert_eq!(left.bounded_payload.len(), 64 * 1024); + assert_eq!(right.bounded_payload.len(), 64 * 1024); + assert_eq!(left.bounded_payload, right.bounded_payload); + assert_ne!(left.payload_digest, right.payload_digest); +} + +#[test] +fn rejected_document_debug_never_prints_payload_or_schema_values() { + let sentinel = "SECRET_DEBUG_SENTINEL"; + let bytes = format!(r#"{{"schema_version":"psyche.intent.v2","payload":"{sentinel}"}}"#); + let rejected = RejectedDocument::from_bytes( + bytes.as_bytes(), + RejectionReason::UnsupportedMajor { + found: 2, + supported: 1, + }, + ); + assert_eq!(rejected.schema_version.as_deref(), Some("psyche.intent.v2")); + let debug = format!("{rejected:?}"); + assert!(!debug.contains(sentinel)); + assert!(!debug.contains("psyche.intent.v2")); + assert!(!debug.contains("\"payload\"")); + assert!(debug.contains("bounded_payload_bytes")); +} + +#[test] +fn rejected_document_handles_oversized_and_invalid_utf8_input() { + let oversized = vec![0xff; MAX_DOCUMENT_BYTES + 1]; + let rejected = RejectedDocument::from_bytes(&oversized, RejectionReason::TooLarge); + assert_eq!(rejected.bounded_payload.len(), 64 * 1024); + assert_eq!(rejected.bounded_payload, vec![0xff; 64 * 1024]); + assert!(rejected.schema_version.is_none()); + assert!(rejected.payload_digest.as_str().starts_with("sha256:")); + assert_eq!(rejected.payload_digest.as_str().len(), 71); + assert!(!format!("{rejected:?}").contains('\u{fffd}')); +} diff --git a/crates/psyche-core/tests/fixtures/error-storage-unavailable.json b/crates/psyche-core/tests/fixtures/error-storage-unavailable.json new file mode 100644 index 0000000..9a76130 --- /dev/null +++ b/crates/psyche-core/tests/fixtures/error-storage-unavailable.json @@ -0,0 +1,13 @@ +{ + "schema_version": "psyche.error.v1", + "error": { + "code": "storage_unavailable", + "message": "Storage is temporarily unavailable.", + "retryable": true, + "correlation_id": "corr-storage-1", + "details": { + "component": "sqlite", + "operation": "write" + } + } +} From a570bb822e681ed40620af87fb0e56c177b628b3 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:51:57 -0500 Subject: [PATCH 13/66] fix(core): preserve decode rejection precedence decode_document ran numeric interoperable-domain validation (validate_json_domain) before the schema_version probe and typed enum prevalidation. An unsupported-major or unknown-enum document that also contained an unsafe number was misclassified as NonInteroperableNumber instead of surfacing UnsupportedMajor, UnknownSchema, or UnknownEnumValue as Task 4 requires. Reorder decode_document so classification runs strictly in the required precedence: recursive syntactic parse (duplicate-key and nesting-depth rejection) -> schema_version probe / SchemaVersion classification -> recognized schema's typed enum prevalidation -> numeric domain validation -> typed deserialization / CanonicalDocument validation. Add mixed-error regression tests covering unsupported major, unknown schema, and unknown enum value each paired with a nested unsafe integer, confirm known-good documents still reject unsafe nested integers, and confirm duplicate keys are rejected ahead of an unsupported major. All errors stay redacted with no raw value retained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/mod.rs | 2 +- crates/psyche-core/tests/decode.rs | 71 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 11cf1cf..faca575 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -649,11 +649,11 @@ pub fn decode_document(bytes: &[u8]) -> Result return Err(ContractError::DocumentTooLarge); } let value = strict_json(bytes)?; - crate::digest::validate_json_domain(&value)?; let probe: VersionProbe = serde_json::from_value(value.clone()) .map_err(|_| invalid(SchemaKind::Error, "schema_version"))?; let schema = SchemaVersion::parse(&probe.schema_version)?; inspect_typed_enums(&value, schema.kind)?; + crate::digest::validate_json_domain(&value)?; let document = match schema.kind { SchemaKind::IdentitySnapshot => { decode(value, CanonicalDocument::IdentitySnapshot, schema.kind)? diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs index 32f70f8..0648100 100644 --- a/crates/psyche-core/tests/decode.rs +++ b/crates/psyche-core/tests/decode.rs @@ -352,6 +352,77 @@ fn rejected_document_debug_never_prints_payload_or_schema_values() { assert!(debug.contains("bounded_payload_bytes")); } +#[test] +fn unsupported_major_takes_precedence_over_a_nested_unsafe_integer() { + let sentinel = "unsafe-major-secret"; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v2","raw":"{sentinel}","constraints":{{"nested":9007199254740992}}}}"# + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert_eq!( + error, + ContractError::UnsupportedMajor { + found: 2, + supported: 1, + } + ); + assert_redacted(&error, sentinel); +} + +#[test] +fn unknown_schema_takes_precedence_over_a_nested_unsafe_integer() { + let sentinel = "unsafe-unknown-schema-secret"; + let bytes = format!( + r#"{{"schema_version":"psyche.not_a_real_schema.v1","raw":"{sentinel}","constraints":{{"nested":9007199254740992}}}}"# + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert_eq!(error, ContractError::UnknownSchema); + assert_redacted(&error, sentinel); +} + +#[test] +fn unknown_enum_value_takes_precedence_over_a_nested_unsafe_integer() { + let sentinel = "unsafe-enum-secret"; + let mut value = graph(); + value["state"] = json!("future_state"); + value["policy_revision"] = json!(sentinel); + value["nested"] = json!({"unsafe": 9_007_199_254_740_992_u64}); + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!( + error, + ContractError::UnknownEnumValue { + schema: SchemaKind::Graph, + field: "state", + } + ); + assert_redacted(&error, sentinel); +} + +#[test] +fn recognized_schema_and_known_enums_still_reject_a_nested_unsafe_integer() { + let mut value = graph(); + value["nested"] = json!({"unsafe": 9_007_199_254_740_992_u64}); + let error = decode_document(&serde_json::to_vec(&value).unwrap()).unwrap_err(); + assert_eq!(error, ContractError::NonInteroperableNumber); +} + +#[test] +fn duplicate_key_takes_precedence_over_unsupported_major() { + let sentinel = "duplicate-major-secret"; + let bytes = format!( + r#"{{"schema_version":"psyche.intent.v1","schema_version":"psyche.intent.v2","raw":"{sentinel}"}}"# + ); + let error = decode_document(bytes.as_bytes()).unwrap_err(); + assert!(matches!( + error, + ContractError::InvalidShape { + schema: SchemaKind::Error, + .. + } + )); + assert_redacted(&error, sentinel); +} + #[test] fn rejected_document_handles_oversized_and_invalid_utf8_input() { let oversized = vec![0xff; MAX_DOCUMENT_BYTES + 1]; From bb393426ad87652e58f5551894a746c7541d6595 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:54:15 -0500 Subject: [PATCH 14/66] fix(core): reject nested duplicate keys Route embedded JSON values through the strict duplicate-aware deserializer so direct typed decoding cannot erase conflicting object fields before contract validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/execution.rs | 4 + crates/psyche-core/src/contracts/intent.rs | 1 + crates/psyche-core/src/contracts/mod.rs | 77 +++++++++++++++++++ crates/psyche-core/src/contracts/surface.rs | 6 ++ crates/psyche-core/tests/contracts.rs | 17 ++++ 5 files changed, 105 insertions(+) diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs index e2c823e..e3ee62a 100644 --- a/crates/psyche-core/src/contracts/execution.rs +++ b/crates/psyche-core/src/contracts/execution.rs @@ -261,9 +261,13 @@ struct ExecutionWire { adoption_state: AdoptionState, event_cursor: Option, cancellation_state: CancellationState, + #[serde(deserialize_with = "super::strict_json_optional_value::deserialize")] termination_request: Option, + #[serde(deserialize_with = "super::strict_json_optional_value::deserialize")] termination_reason_code: Option, + #[serde(deserialize_with = "super::strict_json_optional_value::deserialize")] cancellation_acknowledgement: Option, + #[serde(deserialize_with = "super::strict_json_optional_value::deserialize")] cancellation_unresolved: Option, terminal_state: Option, } diff --git a/crates/psyche-core/src/contracts/intent.rs b/crates/psyche-core/src/contracts/intent.rs index 1b2a219..71a9db7 100644 --- a/crates/psyche-core/src/contracts/intent.rs +++ b/crates/psyche-core/src/contracts/intent.rs @@ -18,6 +18,7 @@ validated_struct! { pub familiar_snapshot_id: RecordId, pub project_id: String, pub requested_outcome: String, + #[serde(with = "super::strict_json_object")] pub constraints: Map, pub required_evidence: Vec, pub surface_event_id: Option, diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index faca575..e26917e 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -50,6 +50,83 @@ macro_rules! validated_struct { }; } +pub(crate) mod strict_json_value { + use serde::Serialize as _; + use serde_json::Value; + + pub(crate) fn serialize( + value: &Value, + serializer: S, + ) -> Result { + value.serialize(serializer) + } + + pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result { + serde::de::DeserializeSeed::deserialize(super::StrictValueSeed { depth: 0 }, deserializer) + } +} + +pub(crate) mod strict_json_object { + use serde::Serialize as _; + use serde_json::{Map, Value}; + + pub(crate) fn serialize( + value: &Map, + serializer: S, + ) -> Result { + value.serialize(serializer) + } + + pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + match super::strict_json_value::deserialize(deserializer)? { + Value::Object(value) => Ok(value), + _ => Err(serde::de::Error::custom("expected a JSON object")), + } + } +} + +pub(crate) mod strict_json_optional_value { + use std::fmt; + + use serde::de::Visitor; + use serde_json::Value; + + pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + deserializer.deserialize_option(OptionalValueVisitor) + } + + struct OptionalValueVisitor; + + impl<'de> Visitor<'de> for OptionalValueVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an optional JSON value") + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D, + ) -> Result { + super::strict_json_value::deserialize(deserializer).map(Some) + } + } +} + pub mod error; pub mod execution; pub mod foundation; diff --git a/crates/psyche-core/src/contracts/surface.rs b/crates/psyche-core/src/contracts/surface.rs index 47d0c9a..47ff6a6 100644 --- a/crates/psyche-core/src/contracts/surface.rs +++ b/crates/psyche-core/src/contracts/surface.rs @@ -17,11 +17,14 @@ validated_struct! { pub surface_event_id: RecordId, pub adapter_id: String, pub account_id: String, + #[serde(with = "super::strict_json_value")] pub actor: Value, + #[serde(with = "super::strict_json_value")] pub locator: Value, pub adapter_event_digest: Sha256Digest, #[serde(with = "time::serde::rfc3339")] pub received_at: time::OffsetDateTime, + #[serde(with = "super::strict_json_value")] pub content: Value, } } @@ -66,7 +69,9 @@ validated_struct! { pub project_id: String, pub action_class: String, pub account_id: String, + #[serde(with = "super::strict_json_value")] pub locator: Value, + #[serde(with = "super::strict_json_value")] pub effect: Value, pub effect_digest: Sha256Digest, #[serde(with = "time::serde::rfc3339")] @@ -181,6 +186,7 @@ validated_struct! { pub chat_id: String, pub topic: DeliveryTopic, pub relationship: DeliveryRelationship, + #[serde(with = "super::strict_json_value")] pub effect: Value, pub effect_digest: Sha256Digest, pub surface_decision: DeliverySurfaceDecision, diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index e4ac25a..a08d746 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -1187,6 +1187,23 @@ fn duplicate_key_errors_do_not_expose_attacker_controlled_text() { assert!(!format!("{error}").contains(marker)); } +#[test] +fn typed_deserialization_rejects_duplicate_keys_in_embedded_json_values() { + let intent = replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + r#""constraints": {"scope": "public", "scope": "public"}"#, + ); + assert!(serde_json::from_slice::(&intent).is_err()); + + let binding = + serde_json::to_string(&valid_binding(CancellationState::AcknowledgedTerminated)).unwrap(); + let session = r#""session_id":"session-1""#; + assert_eq!(binding.matches(session).count(), 1); + let binding = binding.replacen(session, &format!("{session},{session}"), 1); + assert!(serde_json::from_str::(&binding).is_err()); +} + #[test] fn directly_constructed_document_rejects_oversized_canonical_bytes() { let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { From 5dc3b693293d503c108745045b5effed9d0b2bb7 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:07:56 -0500 Subject: [PATCH 15/66] feat(store): add forward-only foundation migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 108 +++++++- crates/psyche-store/Cargo.toml | 7 + .../migrations/001_foundation.sql | 66 +++++ crates/psyche-store/src/connection.rs | 94 +++++++ crates/psyche-store/src/error.rs | 45 ++++ crates/psyche-store/src/lib.rs | 29 +++ crates/psyche-store/src/migrations.rs | 49 ++++ crates/psyche-store/tests/migrations.rs | 129 ++++++++++ crates/psyche-store/tests/support/mod.rs | 240 ++++++++++++++++++ 9 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 crates/psyche-store/migrations/001_foundation.sql create mode 100644 crates/psyche-store/src/connection.rs create mode 100644 crates/psyche-store/src/error.rs create mode 100644 crates/psyche-store/src/migrations.rs create mode 100644 crates/psyche-store/tests/migrations.rs create mode 100644 crates/psyche-store/tests/support/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 58ee57d..1472de7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -123,6 +135,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -236,12 +258,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + [[package]] name = "float-cmp" version = "0.10.0" @@ -290,12 +330,30 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -309,7 +367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -336,6 +394,17 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -422,6 +491,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "powerfmt" version = "0.2.0" @@ -552,6 +627,11 @@ dependencies = [ [[package]] name = "psyche-store" version = "0.0.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror", +] [[package]] name = "psyche-surfaces" @@ -655,6 +735,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.4" @@ -769,6 +863,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1060,6 +1160,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index d2d3966..f09709b 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -7,5 +7,12 @@ license.workspace = true repository.workspace = true publish.workspace = true +[dependencies] +rusqlite = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + [lints] workspace = true diff --git a/crates/psyche-store/migrations/001_foundation.sql b/crates/psyche-store/migrations/001_foundation.sql new file mode 100644 index 0000000..b500bc1 --- /dev/null +++ b/crates/psyche-store/migrations/001_foundation.sql @@ -0,0 +1,66 @@ +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +) STRICT; +CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) +) STRICT; +CREATE TABLE execution_binding_revisions ( + attempt_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + previous_revision_digest TEXT, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (attempt_id, revision), + UNIQUE (attempt_id, digest), + CHECK ( + (revision = 1 AND previous_revision_digest IS NULL) + OR + (revision > 1 AND previous_revision_digest IS NOT NULL) + ), + FOREIGN KEY (attempt_id, previous_revision_digest) + REFERENCES execution_binding_revisions(attempt_id, digest) +) STRICT; +CREATE TABLE transitions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + from_state TEXT, + to_state TEXT NOT NULL, + record_version INTEGER NOT NULL, + transition_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (kind, record_id, record_version) +) STRICT; +CREATE TABLE quarantine_records ( + quarantine_id TEXT PRIMARY KEY, + schema_version TEXT, + payload_digest TEXT NOT NULL, + bounded_payload BLOB NOT NULL, + reason TEXT NOT NULL, + discovered_at TEXT NOT NULL, + resolved_at TEXT, + resolution_code TEXT, + resolution_digest TEXT, + CHECK ( + (resolved_at IS NULL AND resolution_code IS NULL AND resolution_digest IS NULL) + OR + (resolved_at IS NOT NULL AND resolution_code IS NOT NULL AND resolution_digest IS NOT NULL) + ) +) STRICT; +CREATE TABLE audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_code TEXT NOT NULL, + correlation_id TEXT NOT NULL, + public_details_json BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs new file mode 100644 index 0000000..b95706e --- /dev/null +++ b/crates/psyche-store/src/connection.rs @@ -0,0 +1,94 @@ +use std::path::Path; + +use rusqlite::Connection; + +use crate::StoreError; + +pub(crate) fn open(path: &Path) -> Result { + create_parent_directory(path)?; + let connection = Connection::open(path)?; + configure(&connection)?; + Ok(connection) +} + +pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { + connection.execute_batch( + " + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA secure_delete = ON; + PRAGMA busy_timeout = 5000; + ", + )?; + Ok(()) +} + +fn create_parent_directory(path: &Path) -> Result<(), StoreError> { + let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + else { + return Ok(()); + }; + + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder + .create(parent) + .map_err(StoreError::directory_operation)?; + } + + #[cfg(not(unix))] + std::fs::create_dir_all(parent).map_err(StoreError::directory_operation)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::open; + + #[test] + fn open_configures_every_required_pragma() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + + let connection = open(&path).unwrap(); + + assert_eq!( + connection + .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u32>(0)) + .unwrap(), + 1 + ); + assert_eq!( + connection + .pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0)) + .unwrap(), + "wal" + ); + assert_eq!( + connection + .pragma_query_value(None, "synchronous", |row| row.get::<_, u32>(0)) + .unwrap(), + 2 + ); + assert_eq!( + connection + .pragma_query_value(None, "secure_delete", |row| row.get::<_, u32>(0)) + .unwrap(), + 1 + ); + assert_eq!( + connection + .pragma_query_value(None, "busy_timeout", |row| row.get::<_, u32>(0)) + .unwrap(), + 5_000 + ); + } +} diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs new file mode 100644 index 0000000..9e6dde2 --- /dev/null +++ b/crates/psyche-store/src/error.rs @@ -0,0 +1,45 @@ +/// A stable, payload-free store failure. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + /// The database's schema is newer than this build understands. + #[error( + "unsupported database version {found}; maximum supported version is {}", + crate::CURRENT_DATABASE_VERSION + )] + UnsupportedDatabaseVersion { + /// Version read from SQLite's `user_version`. + found: u32, + }, + /// The build has no SQL migration for a required version. + #[error("database migration {version} is unavailable")] + MigrationUnavailable { + /// Missing migration version. + version: u32, + }, + /// Creating the store's parent directory failed. + #[error("store directory operation failed")] + DirectoryOperation { + /// Underlying filesystem error, retained without rendering its payload. + #[source] + source: std::io::Error, + }, + /// Opening, configuring, or querying SQLite failed. + #[error("store database operation failed")] + DatabaseOperation { + /// Underlying SQLite error, retained without rendering its payload. + #[source] + source: rusqlite::Error, + }, +} + +impl StoreError { + pub(crate) fn directory_operation(source: std::io::Error) -> Self { + Self::DirectoryOperation { source } + } +} + +impl From for StoreError { + fn from(source: rusqlite::Error) -> Self { + Self::DatabaseOperation { source } + } +} diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 413dd83..3a3aa67 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -1 +1,30 @@ //! Durable SQLite substrate for Psyche contracts. + +mod connection; +mod error; +mod migrations; + +use std::path::Path; + +pub use error::StoreError; +pub use migrations::CURRENT_DATABASE_VERSION; + +/// A configured connection to Psyche's durable SQLite substrate. +#[derive(Debug)] +pub struct Store { + connection: rusqlite::Connection, +} + +impl Store { + /// Opens a store and atomically applies every missing known migration. + pub fn open(path: &Path) -> Result { + let mut connection = connection::open(path)?; + migrations::migrate(&mut connection)?; + Ok(Self { connection }) + } + + /// Returns SQLite's current application schema version. + pub fn schema_version(&self) -> Result { + migrations::schema_version(&self.connection) + } +} diff --git a/crates/psyche-store/src/migrations.rs b/crates/psyche-store/src/migrations.rs new file mode 100644 index 0000000..8d2af58 --- /dev/null +++ b/crates/psyche-store/src/migrations.rs @@ -0,0 +1,49 @@ +use rusqlite::{Connection, Transaction, TransactionBehavior}; + +use crate::StoreError; + +/// Latest SQLite schema version understood by this build. +pub const CURRENT_DATABASE_VERSION: u32 = 1; + +pub(crate) fn migrate(connection: &mut Connection) -> Result<(), StoreError> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; + let found = schema_version(&transaction)?; + + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + + if found < CURRENT_DATABASE_VERSION { + for version in (found + 1)..=CURRENT_DATABASE_VERSION { + apply_migration_sql(&transaction, version)?; + } + transaction.pragma_update(None, "user_version", CURRENT_DATABASE_VERSION)?; + } + + transaction.commit()?; + Ok(()) +} + +pub(crate) fn apply_migration_sql( + transaction: &Transaction<'_>, + version: u32, +) -> Result<(), StoreError> { + let sql = match version { + 1 => include_str!("../migrations/001_foundation.sql"), + _ => return Err(StoreError::MigrationUnavailable { version }), + }; + + transaction.execute_batch(sql)?; + transaction.execute( + " + INSERT INTO schema_migrations (version, applied_at) + VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ", + [version], + )?; + Ok(()) +} + +pub(crate) fn schema_version(connection: &Connection) -> Result { + Ok(connection.pragma_query_value(None, "user_version", |row| row.get(0))?) +} diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs new file mode 100644 index 0000000..5140c80 --- /dev/null +++ b/crates/psyche-store/tests/migrations.rs @@ -0,0 +1,129 @@ +//! Forward-only SQLite foundation migration integration tests. +#![allow(clippy::unwrap_used)] + +mod support; + +use std::path::Path; + +use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; +use support::{ + FOUNDATION_TABLES, Fixture, fixture_db, foundation_tables, journal_mode, scalar_text, + schema_migrations, table_exists, user_version, +}; + +#[test] +fn fresh_store_applies_v1_once_and_reopens() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("private"); + let path = parent.join("psyche.sqlite3"); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + drop(store); + + assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); + assert_eq!(journal_mode(&path), "wal"); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + drop(reopened); + assert_eq!(schema_migrations(&path).len(), 1); + + assert_private_directory(&parent); +} + +#[test] +fn version_zero_fixture_migrates_without_losing_existing_data() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!( + scalar_text(&path, "SELECT value FROM fixture_v0_marker"), + "preserve-me" + ); + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); +} + +#[test] +fn existing_v1_fixture_opens_without_reapplying_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!(schema_migrations(&path), vec![(1, "fixture-v1".to_owned())]); + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); +} + +#[test] +fn future_database_version_fails_before_any_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version99); + + let error = Store::open(&path).unwrap_err(); + assert!(matches!( + error, + StoreError::UnsupportedDatabaseVersion { found: 99 } + )); + assert_eq!( + error.to_string(), + "unsupported database version 99; maximum supported version is 1" + ); + + assert_eq!(user_version(&path), 99); + assert_eq!( + scalar_text(&path, "SELECT value FROM future_owner"), + "future-owned" + ); + assert!(!table_exists(&path, "schema_migrations")); + + let reopened_error = Store::open(&path).unwrap_err(); + assert!(matches!( + reopened_error, + StoreError::UnsupportedDatabaseVersion { found: 99 } + )); + assert_eq!(user_version(&path), 99); + assert!(!table_exists(&path, "schema_migrations")); +} + +#[test] +fn partially_applied_v1_transaction_rolls_back_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); + + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); + assert!(!table_exists(&path, "canonical_records")); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), 1); + drop(reopened); + assert_eq!(schema_migrations(&path).len(), 1); +} + +#[cfg(unix)] +fn assert_private_directory(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} + +#[cfg(not(unix))] +fn assert_private_directory(_path: &Path) {} diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs new file mode 100644 index 0000000..a2c0069 --- /dev/null +++ b/crates/psyche-store/tests/support/mod.rs @@ -0,0 +1,240 @@ +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, TransactionBehavior}; + +pub(super) const FOUNDATION_TABLES: [&str; 6] = [ + "audit_events", + "canonical_records", + "execution_binding_revisions", + "quarantine_records", + "schema_migrations", + "transitions", +]; + +pub(super) enum Fixture { + Version0, + Version1, + Version99, + PartiallyAppliedV1, +} + +pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { + let name = match fixture { + Fixture::Version0 => "version-v0.sqlite3", + Fixture::Version1 => "version-v1.sqlite3", + Fixture::Version99 => "future-v99.sqlite3", + Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", + }; + let path = root.join(name); + let mut connection = Connection::open(&path).unwrap(); + + match fixture { + Fixture::Version0 => connection + .execute_batch( + " + CREATE TABLE fixture_v0_marker ( + value TEXT NOT NULL + ) STRICT; + INSERT INTO fixture_v0_marker (value) VALUES ('preserve-me'); + PRAGMA user_version = 0; + ", + ) + .unwrap(), + Fixture::Version1 => { + connection.execute_batch(FOUNDATION_SCHEMA).unwrap(); + connection + .execute( + " + INSERT INTO schema_migrations (version, applied_at) + VALUES (1, 'fixture-v1') + ", + [], + ) + .unwrap(); + connection.pragma_update(None, "user_version", 1).unwrap(); + } + Fixture::Version99 => connection + .execute_batch( + " + CREATE TABLE future_owner ( + value TEXT NOT NULL + ) STRICT; + INSERT INTO future_owner (value) VALUES ('future-owned'); + PRAGMA user_version = 99; + ", + ) + .unwrap(), + Fixture::PartiallyAppliedV1 => { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Exclusive) + .unwrap(); + transaction + .execute_batch( + " + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) STRICT; + CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) + ) STRICT; + PRAGMA user_version = 1; + ", + ) + .unwrap(); + drop(transaction); + } + } + + drop(connection); + path +} + +pub(super) fn user_version(path: &Path) -> u32 { + let connection = Connection::open(path).unwrap(); + connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap() +} + +pub(super) fn foundation_tables(path: &Path) -> Vec { + let connection = Connection::open(path).unwrap(); + let mut statement = connection + .prepare( + " + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ( + 'audit_events', + 'canonical_records', + 'execution_binding_revisions', + 'quarantine_records', + 'schema_migrations', + 'transitions' + ) + ORDER BY name + ", + ) + .unwrap(); + statement + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>>() + .unwrap() +} + +pub(super) fn schema_migrations(path: &Path) -> Vec<(u32, String)> { + let connection = Connection::open(path).unwrap(); + let mut statement = connection + .prepare("SELECT version, applied_at FROM schema_migrations ORDER BY version") + .unwrap(); + statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>>() + .unwrap() +} + +pub(super) fn table_exists(path: &Path, name: &str) -> bool { + let connection = Connection::open(path).unwrap(); + connection + .query_row( + " + SELECT EXISTS( + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 + ) + ", + [name], + |row| row.get(0), + ) + .unwrap() +} + +pub(super) fn scalar_text(path: &Path, sql: &str) -> String { + let connection = Connection::open(path).unwrap(); + connection.query_row(sql, [], |row| row.get(0)).unwrap() +} + +pub(super) fn journal_mode(path: &Path) -> String { + let connection = Connection::open(path).unwrap(); + connection + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap() +} + +const FOUNDATION_SCHEMA: &str = r#" +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +) STRICT; +CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) +) STRICT; +CREATE TABLE execution_binding_revisions ( + attempt_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + previous_revision_digest TEXT, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (attempt_id, revision), + UNIQUE (attempt_id, digest), + CHECK ( + (revision = 1 AND previous_revision_digest IS NULL) + OR + (revision > 1 AND previous_revision_digest IS NOT NULL) + ), + FOREIGN KEY (attempt_id, previous_revision_digest) + REFERENCES execution_binding_revisions(attempt_id, digest) +) STRICT; +CREATE TABLE transitions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + from_state TEXT, + to_state TEXT NOT NULL, + record_version INTEGER NOT NULL, + transition_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (kind, record_id, record_version) +) STRICT; +CREATE TABLE quarantine_records ( + quarantine_id TEXT PRIMARY KEY, + schema_version TEXT, + payload_digest TEXT NOT NULL, + bounded_payload BLOB NOT NULL, + reason TEXT NOT NULL, + discovered_at TEXT NOT NULL, + resolved_at TEXT, + resolution_code TEXT, + resolution_digest TEXT, + CHECK ( + (resolved_at IS NULL AND resolution_code IS NULL AND resolution_digest IS NULL) + OR + (resolved_at IS NOT NULL AND resolution_code IS NOT NULL AND resolution_digest IS NOT NULL) + ) +) STRICT; +CREATE TABLE audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_code TEXT NOT NULL, + correlation_id TEXT NOT NULL, + public_details_json BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; +"#; From 26c22073b86c152b0f8a3fb00a38bcea8abda436 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:14:27 -0500 Subject: [PATCH 16/66] fix(core): enforce strict direct JSON validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/error.rs | 37 +++ crates/psyche-core/src/contracts/execution.rs | 18 ++ crates/psyche-core/src/contracts/intent.rs | 3 +- crates/psyche-core/src/contracts/mod.rs | 80 +++++- crates/psyche-core/tests/contracts.rs | 249 +++++++++++++++++- 5 files changed, 374 insertions(+), 13 deletions(-) diff --git a/crates/psyche-core/src/contracts/error.rs b/crates/psyche-core/src/contracts/error.rs index c7732fc..f349bd0 100644 --- a/crates/psyche-core/src/contracts/error.rs +++ b/crates/psyche-core/src/contracts/error.rs @@ -189,9 +189,46 @@ struct ErrorBodyWire { message: String, retryable: bool, correlation_id: String, + #[serde(deserialize_with = "super::strict_string_map::deserialize")] details: BTreeMap, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TypedErrorEnvelopeWire { + schema_version: SchemaVersion, + error: TypedErrorBodyWire, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TypedErrorBodyWire { + code: ErrorCode, + message: String, + retryable: bool, + correlation_id: String, + #[serde(deserialize_with = "super::strict_string_map::deserialize")] + details: BTreeMap, +} + +impl<'de> Deserialize<'de> for ErrorEnvelope { + fn deserialize>(deserializer: D) -> Result { + let wire = TypedErrorEnvelopeWire::deserialize(deserializer)?; + let envelope = Self { + schema_version: wire.schema_version, + error: ErrorBody { + code: wire.error.code, + message: wire.error.message, + retryable: wire.error.retryable, + correlation_id: wire.error.correlation_id, + details: wire.error.details, + }, + }; + envelope.validate().map_err(serde::de::Error::custom)?; + Ok(envelope) + } +} + impl ErrorEnvelope { pub(crate) fn decode(value: Value) -> Result { let wire: ErrorEnvelopeWire = diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs index e3ee62a..47b1916 100644 --- a/crates/psyche-core/src/contracts/execution.rs +++ b/crates/psyche-core/src/contracts/execution.rs @@ -7,6 +7,7 @@ use serde_json::Value; use crate::contracts::{ ContractError, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, bounded, optional_bounded, reason_code, require_id, require_schema, safe_integer, + validate_json_value_depth, }; use crate::digest::Sha256Digest; use crate::id::{RecordId, RequestId}; @@ -276,6 +277,23 @@ impl TryFrom for ExecutionBinding { type Error = ContractError; fn try_from(w: ExecutionWire) -> Result { + for (value, field) in [ + (&w.termination_request, "termination_request"), + (&w.termination_reason_code, "termination_reason_code"), + ( + &w.cancellation_acknowledgement, + "cancellation_acknowledgement", + ), + (&w.cancellation_unresolved, "cancellation_unresolved"), + ] { + if let Some(value) = value { + cancellation_result(validate_json_value_depth( + value, + SchemaKind::ExecutionBinding, + field, + ))?; + } + } let termination_request = cancellation_value(w.termination_request)?; let termination_reason_code = match w.termination_reason_code { Some(Value::String(reason)) => Some(reason), diff --git a/crates/psyche-core/src/contracts/intent.rs b/crates/psyche-core/src/contracts/intent.rs index 71a9db7..dc5f72a 100644 --- a/crates/psyche-core/src/contracts/intent.rs +++ b/crates/psyche-core/src/contracts/intent.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value}; use crate::contracts::{ ContractError, MAX_DOCUMENT_BYTES, RecordKind, SchemaKind, SchemaVersion, VersionedRecord, - bounded, require_id, require_schema, string_list, + bounded, require_id, require_schema, string_list, validate_json_object_depth, }; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -49,6 +49,7 @@ impl Intent { for key in self.constraints.keys() { bounded(key, 256, schema, "constraints")?; } + validate_json_object_depth(&self.constraints, schema, "constraints")?; if crate::digest::canonical_bytes(&self.constraints)?.len() > MAX_DOCUMENT_BYTES { return Err(super::invalid(schema, "constraints")); } diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index e26917e..8d5d276 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -12,7 +12,7 @@ use std::str::FromStr; use serde::Serialize; use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; -use serde_json::Value; +use serde_json::{Map, Value}; use crate::digest::Sha256Digest; use crate::id::RecordId; @@ -127,6 +127,41 @@ pub(crate) mod strict_json_optional_value { } } +pub(crate) mod strict_string_map { + use std::collections::{BTreeMap, HashSet}; + use std::fmt; + + use serde::de::{MapAccess, Visitor}; + + pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + deserializer.deserialize_map(StringMapVisitor) + } + + struct StringMapVisitor; + + impl<'de> Visitor<'de> for StringMapVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object with string values") + } + + fn visit_map>(self, mut object: A) -> Result { + let mut keys = HashSet::with_capacity(object.size_hint().unwrap_or(0)); + let mut values = BTreeMap::new(); + while let Some(key) = object.next_key::()? { + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom("duplicate JSON object key")); + } + values.insert(key, object.next_value()?); + } + Ok(values) + } + } +} + pub mod error; pub mod execution; pub mod foundation; @@ -886,7 +921,7 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } let mut keys = HashSet::with_capacity(object.size_hint().unwrap_or(0)); - let mut values = serde_json::Map::with_capacity(object.size_hint().unwrap_or(0)); + let mut values = Map::with_capacity(object.size_hint().unwrap_or(0)); while let Some(key) = object.next_key::()? { if !keys.insert(key.clone()) { return Err(serde::de::Error::custom("duplicate JSON object key")); @@ -1217,12 +1252,53 @@ pub(crate) fn object( if nonempty && map.is_empty() { return Err(invalid(schema, field)); } + validate_json_value_depth(value, schema, field)?; if crate::digest::canonical_bytes(value)?.len() > MAX_DOCUMENT_BYTES { return Err(invalid(schema, field)); } Ok(()) } +pub(crate) fn validate_json_value_depth( + value: &Value, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + validate_json_depth([(value, 1)], schema, field) +} + +pub(crate) fn validate_json_object_depth( + value: &Map, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + validate_json_depth(value.values().map(|value| (value, 2)), schema, field) +} + +fn validate_json_depth<'a>( + roots: impl IntoIterator, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + let mut pending: Vec<_> = roots.into_iter().collect(); + while let Some((value, depth)) = pending.pop() { + if depth > MAX_JSON_DEPTH { + return Err(invalid(schema, field)); + } + let child_depth = depth + 1; + match value { + Value::Array(values) => { + pending.extend(values.iter().map(|value| (value, child_depth))); + } + Value::Object(values) => { + pending.extend(values.values().map(|value| (value, child_depth))); + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } + Ok(()) +} + pub(crate) fn reason_code( value: &str, schema: SchemaKind, diff --git a/crates/psyche-core/tests/contracts.rs b/crates/psyche-core/tests/contracts.rs index a08d746..9321daf 100644 --- a/crates/psyche-core/tests/contracts.rs +++ b/crates/psyche-core/tests/contracts.rs @@ -1,7 +1,7 @@ //! Psyche v1 contract fixtures and strict decoding integration tests. #![allow(clippy::expect_used, clippy::unwrap_used)] -use psyche_core::contracts::error::ErrorCode; +use psyche_core::contracts::error::{ErrorCode, ErrorEnvelope}; use psyche_core::contracts::execution::{ AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, @@ -65,6 +65,10 @@ fn assert_duplicate_json_rejected(bytes: &[u8], location: &str) { ); } +fn assert_typed_duplicate_rejected(bytes: &[u8], location: &str) { + assert!(serde_json::from_slice::(bytes).is_err(), "{location}"); +} + fn timestamp(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap() } @@ -1189,19 +1193,244 @@ fn duplicate_key_errors_do_not_expose_attacker_controlled_text() { #[test] fn typed_deserialization_rejects_duplicate_keys_in_embedded_json_values() { + assert_typed_duplicate_rejected::( + &replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + r#""constraints": {"scope": "public", "scope": "public"}"#, + ), + "intent constraints", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("surface-event.json", r#""type": "user""#), + "surface event actor", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("surface-event.json", r#""message_id": "42""#), + "surface event locator", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("surface-event.json", r#""text": "Please review this.""#), + "surface event content", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("surface-effect.json", r#""chat_id": "-100123""#), + "surface effect locator", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("surface-effect.json", r#""text": "Review complete.""#), + "surface effect effect", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("delivery-ready.json", r#""format": "html""#), + "delivery effect", + ); + assert_typed_duplicate_rejected::( + &duplicate_fixture_fragment("error-storage-unavailable.json", r#""component": "sqlite""#), + "error details", + ); + + let marker = "DIRECT_DUPLICATE_SECRET"; let intent = replace_fixture_once( "intent-local.json", r#""constraints": {}"#, - r#""constraints": {"scope": "public", "scope": "public"}"#, + &format!(r#""constraints": {{"{marker}": "secret", "{marker}": "secret"}}"#), + ); + let error = serde_json::from_slice::(&intent).unwrap_err(); + assert!(!error.to_string().contains(marker)); + assert!(!error.to_string().contains("secret")); + + for (location, state, fragment) in [ + ( + "termination request", + CancellationState::TerminationRequested, + r#""termination_request_id":"req_01BX5ZZKBKACTAV9WEVGEMMVRZ""#, + ), + ( + "cancellation acknowledgement", + CancellationState::AcknowledgedTerminated, + r#""acknowledgement_id":"ack-1""#, + ), + ( + "cancellation unresolved", + CancellationState::TerminationUnknown, + r#""disposition_id":"disp-1""#, + ), + ] { + let binding = serde_json::to_string(&valid_binding(state)).unwrap(); + assert_eq!(binding.matches(fragment).count(), 1, "{location}"); + let binding = binding.replacen(fragment, &format!("{fragment},{fragment}"), 1); + assert!( + serde_json::from_str::(&binding).is_err(), + "{location}" + ); + } +} + +#[test] +fn direct_strict_json_deserialization_preserves_private_number_marker_lookalikes() { + for (constraints, expected) in [ + ( + r#"{"$serde_json::private::Number": "1.5"}"#, + r#"{"$serde_json::private::Number":"1.5"}"#, + ), + ( + r#"{"$serde_json::private::Number": 1.5}"#, + r#"{"$serde_json::private::Number":1.5}"#, + ), + ( + r#"{"$serde_json::private::Number": "1.5", "extra": true}"#, + r#"{"$serde_json::private::Number":"1.5","extra":true}"#, + ), + ] { + let bytes = replace_fixture_once( + "intent-local.json", + r#""constraints": {}"#, + &format!(r#""constraints": {constraints}"#), + ); + let intent: Intent = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + canonical_bytes(&intent.constraints).unwrap(), + expected.as_bytes() + ); + } +} + +fn nested_arrays(count: usize) -> Value { + (0..count).fold(Value::Null, |value, _| Value::Array(vec![value])) +} + +fn nested_arbitrary_object(array_count: usize) -> Value { + json!({"nested": nested_arrays(array_count)}) +} + +#[test] +fn direct_arbitrary_json_depth_matches_the_decoder_boundary() { + const MAX_JSON_DEPTH: usize = 64; + let accepted_arrays = MAX_JSON_DEPTH - 2; + let rejected_arrays = accepted_arrays + 1; + + for (array_count, accepted) in [(accepted_arrays, true), (rejected_arrays, false)] { + let bytes = mutate("intent-local.json", |object| { + object.insert("constraints".into(), nested_arbitrary_object(array_count)); + }); + assert_eq!( + decode_document(&bytes).is_ok(), + accepted, + "decoder boundary at {array_count} nested arrays" + ); + + let intent: Intent = serde_json::from_slice(&fixture("intent-local.json")).unwrap(); + let mut intent = intent; + intent.constraints = nested_arbitrary_object(array_count) + .as_object() + .unwrap() + .clone(); + assert_eq!( + intent.validate().is_ok(), + accepted, + "record boundary at {array_count} nested arrays" + ); + } + + let CanonicalDocument::SurfaceEffect(mut effect) = decode("surface-effect.json") else { + panic!("expected surface effect"); + }; + effect.effect = nested_arbitrary_object(accepted_arrays); + effect.effect_digest = digest(&effect.effect).unwrap(); + effect.validate().unwrap(); + + let CanonicalDocument::Delivery(mut delivery) = decode("delivery-ready.json") else { + panic!("expected delivery"); + }; + delivery.effect = nested_arbitrary_object(accepted_arrays); + delivery.effect_digest = digest(&delivery.effect).unwrap(); + delivery.validate().unwrap(); +} + +#[test] +fn every_direct_arbitrary_json_entry_point_rejects_excessive_depth() { + const EXCESSIVE_ARRAYS: usize = 80; + let deep = nested_arbitrary_object(EXCESSIVE_ARRAYS); + + let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { + panic!("expected intent"); + }; + intent.constraints = deep.as_object().unwrap().clone(); + assert_eq!( + intent.validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::Intent, + field: "constraints", + }) + ); + + for field in ["actor", "locator", "content"] { + let CanonicalDocument::SurfaceEvent(mut event) = decode("surface-event.json") else { + panic!("expected surface event"); + }; + match field { + "actor" => event.actor = deep.clone(), + "locator" => event.locator = deep.clone(), + "content" => event.content = deep.clone(), + _ => unreachable!(), + } + assert_eq!( + event.validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::SurfaceEvent, + field, + }) + ); + } + + for field in ["locator", "effect"] { + let CanonicalDocument::SurfaceEffect(mut effect) = decode("surface-effect.json") else { + panic!("expected surface effect"); + }; + match field { + "locator" => effect.locator = deep.clone(), + "effect" => effect.effect = deep.clone(), + _ => unreachable!(), + } + assert_eq!( + effect.validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::SurfaceEffect, + field, + }) + ); + } + + let CanonicalDocument::Delivery(mut delivery) = decode("delivery-ready.json") else { + panic!("expected delivery"); + }; + delivery.effect = deep; + assert_eq!( + delivery.validate(), + Err(ContractError::InvalidShape { + schema: SchemaKind::Delivery, + field: "effect", + }) + ); +} + +#[test] +fn canonical_document_rejects_deep_direct_intent_without_panicking() { + let CanonicalDocument::Intent(mut intent) = decode("intent-local.json") else { + panic!("expected intent"); + }; + intent.constraints = nested_arbitrary_object(80).as_object().unwrap().clone(); + + let result = std::panic::catch_unwind(|| CanonicalDocument::Intent(intent).validate()); + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + Err(ContractError::InvalidShape { + schema: SchemaKind::Intent, + field: "constraints", + }) ); - assert!(serde_json::from_slice::(&intent).is_err()); - - let binding = - serde_json::to_string(&valid_binding(CancellationState::AcknowledgedTerminated)).unwrap(); - let session = r#""session_id":"session-1""#; - assert_eq!(binding.matches(session).count(), 1); - let binding = binding.replacen(session, &format!("{session},{session}"), 1); - assert!(serde_json::from_str::(&binding).is_err()); } #[test] From 0695e24c509d7c8916d402968ab90a9665e7f616 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:28:50 -0500 Subject: [PATCH 17/66] fix(store): harden database open authority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 184 +++++++++++++++++++++--- crates/psyche-store/src/error.rs | 17 +++ crates/psyche-store/src/lib.rs | 31 +++- crates/psyche-store/src/migrations.rs | 25 +--- crates/psyche-store/tests/migrations.rs | 104 +++++++++++++- 5 files changed, 318 insertions(+), 43 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index b95706e..0719811 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,14 +1,24 @@ -use std::path::Path; +use std::{ + fs::{self, OpenOptions}, + io::ErrorKind, + path::{Path, PathBuf}, +}; -use rusqlite::Connection; +use rusqlite::{Connection, OpenFlags}; use crate::StoreError; pub(crate) fn open(path: &Path) -> Result { - create_parent_directory(path)?; - let connection = Connection::open(path)?; - configure(&connection)?; - Ok(connection) + validate_path(path)?; + prepare_parent_directory(path)?; + prepare_database_file(path)?; + let open_path = database_open_path(path)?; + + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + Ok(Connection::open_with_flags(open_path, flags)?) } pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { @@ -21,44 +31,174 @@ pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { PRAGMA busy_timeout = 5000; ", )?; + + let foreign_keys = + connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, u32>(0))?; + let journal_mode = + connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?; + let synchronous = + connection.pragma_query_value(None, "synchronous", |row| row.get::<_, u32>(0))?; + let secure_delete = + connection.pragma_query_value(None, "secure_delete", |row| row.get::<_, u32>(0))?; + let busy_timeout = + connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, u32>(0))?; + + if foreign_keys == 1 + && journal_mode == "wal" + && synchronous == 2 + && secure_delete == 1 + && busy_timeout == 5_000 + { + Ok(()) + } else { + Err(StoreError::ConfigurationUnavailable) + } +} + +fn validate_path(path: &Path) -> Result<(), StoreError> { + let is_special = path.as_os_str().is_empty() + || path == Path::new(":memory:") + || path.to_str().is_some_and(|path| path.starts_with("file:")); + if is_special || path.file_name().is_none() { + return Err(StoreError::InvalidDatabasePath); + } Ok(()) } -fn create_parent_directory(path: &Path) -> Result<(), StoreError> { - let Some(parent) = path +fn prepare_parent_directory(path: &Path) -> Result<(), StoreError> { + let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) - else { - return Ok(()); - }; + .unwrap_or_else(|| Path::new(".")); + if parent.has_root() && parent.parent().is_none() { + return Err(StoreError::InvalidDatabasePath); + } + + match fs::symlink_metadata(parent) { + Ok(metadata) => validate_parent_metadata(&metadata)?, + Err(error) if error.kind() == ErrorKind::NotFound => create_parent_directory(parent)?, + Err(error) => return Err(StoreError::directory_operation(error)), + } + + let metadata = fs::symlink_metadata(parent).map_err(StoreError::directory_operation)?; + validate_parent_metadata(&metadata)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(StoreError::directory_operation)?; + let metadata = fs::symlink_metadata(parent).map_err(StoreError::directory_operation)?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(StoreError::InvalidDatabasePath); + } + } + + Ok(()) +} + +fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(StoreError::InvalidDatabasePath); + } + Ok(()) +} + +fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; - let mut builder = std::fs::DirBuilder::new(); + let mut builder = fs::DirBuilder::new(); builder.recursive(true).mode(0o700); builder .create(parent) .map_err(StoreError::directory_operation)?; - } + }; #[cfg(not(unix))] - std::fs::create_dir_all(parent).map_err(StoreError::directory_operation)?; + fs::create_dir_all(parent).map_err(StoreError::directory_operation)?; + + Ok(()) +} + +fn prepare_database_file(path: &Path) -> Result<(), StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => validate_database_metadata(&metadata)?, + Err(error) if error.kind() == ErrorKind::NotFound => create_database_file(path)?, + Err(error) => return Err(StoreError::file_operation(error)), + } + + let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; + validate_database_metadata(&metadata)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(StoreError::file_operation)?; + let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(StoreError::InvalidDatabasePath); + } + } + + Ok(()) +} + +fn database_open_path(path: &Path) -> Result { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent = fs::canonicalize(parent).map_err(StoreError::directory_operation)?; + let file_name = path.file_name().ok_or(StoreError::InvalidDatabasePath)?; + Ok(parent.join(file_name)) +} + +fn validate_database_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(StoreError::InvalidDatabasePath); + } + Ok(()) +} + +fn create_database_file(path: &Path) -> Result<(), StoreError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); + } + drop(options.open(path).map_err(StoreError::file_operation)?); Ok(()) } #[cfg(test)] mod tests { - use super::open; + use rusqlite::Connection; + + use super::{configure, open}; #[test] - fn open_configures_every_required_pragma() { + fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); let connection = open(&path).unwrap(); + configure(&connection).unwrap(); assert_eq!( connection @@ -91,4 +231,16 @@ mod tests { 5_000 ); } + + #[test] + fn configure_rejects_pragma_fallback_with_a_stable_error() { + let connection = Connection::open_in_memory().unwrap(); + + let error = configure(&connection).unwrap_err(); + + assert_eq!( + error.to_string(), + "required store database configuration is unavailable" + ); + } } diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index 9e6dde2..243318b 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -1,6 +1,12 @@ /// A stable, payload-free store failure. #[derive(Debug, thiserror::Error)] pub enum StoreError { + /// The supplied path cannot safely name a persistent database. + #[error("store database path is invalid")] + InvalidDatabasePath, + /// SQLite did not retain every required durability setting. + #[error("required store database configuration is unavailable")] + ConfigurationUnavailable, /// The database's schema is newer than this build understands. #[error( "unsupported database version {found}; maximum supported version is {}", @@ -23,6 +29,13 @@ pub enum StoreError { #[source] source: std::io::Error, }, + /// Preparing the database file failed. + #[error("store file operation failed")] + FileOperation { + /// Underlying filesystem error, retained without rendering its payload. + #[source] + source: std::io::Error, + }, /// Opening, configuring, or querying SQLite failed. #[error("store database operation failed")] DatabaseOperation { @@ -36,6 +49,10 @@ impl StoreError { pub(crate) fn directory_operation(source: std::io::Error) -> Self { Self::DirectoryOperation { source } } + + pub(crate) fn file_operation(source: std::io::Error) -> Self { + Self::FileOperation { source } + } } impl From for StoreError { diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 3a3aa67..6bf2374 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -6,6 +6,8 @@ mod migrations; use std::path::Path; +use rusqlite::TransactionBehavior; + pub use error::StoreError; pub use migrations::CURRENT_DATABASE_VERSION; @@ -19,12 +21,37 @@ impl Store { /// Opens a store and atomically applies every missing known migration. pub fn open(path: &Path) -> Result { let mut connection = connection::open(path)?; - migrations::migrate(&mut connection)?; + + let found = + connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?; + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + + connection::configure(&connection)?; + + let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; + let found = + transaction.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?; + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + + if found < CURRENT_DATABASE_VERSION { + for version in (found + 1)..=CURRENT_DATABASE_VERSION { + migrations::apply_migration_sql(&transaction, version)?; + } + transaction.pragma_update(None, "user_version", CURRENT_DATABASE_VERSION)?; + } + transaction.commit()?; + Ok(Self { connection }) } /// Returns SQLite's current application schema version. pub fn schema_version(&self) -> Result { - migrations::schema_version(&self.connection) + Ok(self + .connection + .pragma_query_value(None, "user_version", |row| row.get(0))?) } } diff --git a/crates/psyche-store/src/migrations.rs b/crates/psyche-store/src/migrations.rs index 8d2af58..ab60faa 100644 --- a/crates/psyche-store/src/migrations.rs +++ b/crates/psyche-store/src/migrations.rs @@ -1,29 +1,10 @@ -use rusqlite::{Connection, Transaction, TransactionBehavior}; +use rusqlite::Transaction; use crate::StoreError; /// Latest SQLite schema version understood by this build. pub const CURRENT_DATABASE_VERSION: u32 = 1; -pub(crate) fn migrate(connection: &mut Connection) -> Result<(), StoreError> { - let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; - let found = schema_version(&transaction)?; - - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); - } - - if found < CURRENT_DATABASE_VERSION { - for version in (found + 1)..=CURRENT_DATABASE_VERSION { - apply_migration_sql(&transaction, version)?; - } - transaction.pragma_update(None, "user_version", CURRENT_DATABASE_VERSION)?; - } - - transaction.commit()?; - Ok(()) -} - pub(crate) fn apply_migration_sql( transaction: &Transaction<'_>, version: u32, @@ -43,7 +24,3 @@ pub(crate) fn apply_migration_sql( )?; Ok(()) } - -pub(crate) fn schema_version(connection: &Connection) -> Result { - Ok(connection.pragma_query_value(None, "user_version", |row| row.get(0))?) -} diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 5140c80..071bf2b 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -3,7 +3,7 @@ mod support; -use std::path::Path; +use std::path::{Path, PathBuf}; use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; use support::{ @@ -32,6 +32,7 @@ fn fresh_store_applies_v1_once_and_reopens() { assert_eq!(schema_migrations(&path).len(), 1); assert_private_directory(&parent); + assert_private_file(&path); } #[test] @@ -68,6 +69,8 @@ fn existing_v1_fixture_opens_without_reapplying_migration() { fn future_database_version_fails_before_any_migration() { let dir = tempfile::tempdir().unwrap(); let path = fixture_db(dir.path(), Fixture::Version99); + let original_journal_mode = journal_mode(&path); + let original_sidecars = sqlite_sidecar_state(&path); let error = Store::open(&path).unwrap_err(); assert!(matches!( @@ -85,6 +88,8 @@ fn future_database_version_fails_before_any_migration() { "future-owned" ); assert!(!table_exists(&path, "schema_migrations")); + assert_eq!(journal_mode(&path), original_journal_mode); + assert_eq!(sqlite_sidecar_state(&path), original_sidecars); let reopened_error = Store::open(&path).unwrap_err(); assert!(matches!( @@ -93,6 +98,8 @@ fn future_database_version_fails_before_any_migration() { )); assert_eq!(user_version(&path), 99); assert!(!table_exists(&path, "schema_migrations")); + assert_eq!(journal_mode(&path), original_journal_mode); + assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } #[test] @@ -117,6 +124,90 @@ fn partially_applied_v1_transaction_rolls_back_and_recovers() { assert_eq!(schema_migrations(&path).len(), 1); } +#[test] +fn empty_path_is_rejected_with_a_stable_error() { + assert_invalid_database_path(Path::new("")); +} + +#[test] +fn memory_path_is_rejected_with_a_stable_error() { + assert_invalid_database_path(Path::new(":memory:")); +} + +#[cfg(unix)] +#[test] +fn existing_storage_permissions_are_tightened() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("existing"); + let path = parent.join("psyche.sqlite3"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(&path, []).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + drop(Store::open(&path).unwrap()); + + assert_private_directory(&parent); + assert_private_file(&path); +} + +#[cfg(unix)] +#[test] +fn symlink_parent_is_rejected_without_creating_a_database() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let real_parent = dir.path().join("real"); + let linked_parent = dir.path().join("linked"); + std::fs::create_dir(&real_parent).unwrap(); + symlink(&real_parent, &linked_parent).unwrap(); + let path = linked_parent.join("psyche.sqlite3"); + + assert_invalid_database_path(&path); + + assert!(!real_parent.join("psyche.sqlite3").exists()); +} + +#[cfg(unix)] +#[test] +fn symlink_database_is_rejected_without_mutating_its_target() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.sqlite3"); + let path = dir.path().join("linked.sqlite3"); + std::fs::write(&target, []).unwrap(); + symlink(&target, &path).unwrap(); + + assert_invalid_database_path(&path); + + assert_eq!(std::fs::read(&target).unwrap(), Vec::::new()); +} + +fn assert_invalid_database_path(path: &Path) { + let error = Store::open(path).unwrap_err(); + assert_eq!(error.to_string(), "store database path is invalid"); +} + +fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { + ["-journal", "-wal", "-shm"] + .into_iter() + .map(|suffix| { + let mut sidecar = path.as_os_str().to_owned(); + sidecar.push(suffix); + let sidecar = PathBuf::from(sidecar); + let contents = match std::fs::read(sidecar) { + Ok(contents) => Some(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => panic!("failed to read SQLite sidecar: {error}"), + }; + (suffix.to_owned(), contents) + }) + .collect() +} + #[cfg(unix)] fn assert_private_directory(path: &Path) { use std::os::unix::fs::PermissionsExt; @@ -127,3 +218,14 @@ fn assert_private_directory(path: &Path) { #[cfg(not(unix))] fn assert_private_directory(_path: &Path) {} + +#[cfg(unix)] +fn assert_private_file(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} + +#[cfg(not(unix))] +fn assert_private_file(_path: &Path) {} From b0e39e49dca7e3f42bcf5e4a23ca3c915681e5f5 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:33:33 -0500 Subject: [PATCH 18/66] fix(store): harden foundation database open Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 75 +++++------ crates/psyche-store/src/error.rs | 31 ++++- crates/psyche-store/src/lib.rs | 3 +- crates/psyche-store/tests/migrations.rs | 152 ++++++++++++++++++++--- crates/psyche-store/tests/support/mod.rs | 50 +++----- 5 files changed, 217 insertions(+), 94 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 0719811..9c69106 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -8,27 +8,49 @@ use rusqlite::{Connection, OpenFlags}; use crate::StoreError; -pub(crate) fn open(path: &Path) -> Result { +pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; prepare_database_file(path)?; let open_path = database_open_path(path)?; let flags = OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - Ok(Connection::open_with_flags(open_path, flags)?) + let connection = Connection::open_with_flags(&open_path, flags)?; + Ok((connection, open_path)) +} + +pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { + let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; + validate_database_metadata(&metadata)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(StoreError::file_operation)?; + let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(StoreError::InvalidDatabasePath); + } + } + + Ok(()) } pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { connection.execute_batch( " + PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON; - PRAGMA busy_timeout = 5000; ", )?; @@ -70,9 +92,6 @@ fn prepare_parent_directory(path: &Path) -> Result<(), StoreError> { .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - if parent.has_root() && parent.parent().is_none() { - return Err(StoreError::InvalidDatabasePath); - } match fs::symlink_metadata(parent) { Ok(metadata) => validate_parent_metadata(&metadata)?, @@ -83,21 +102,6 @@ fn prepare_parent_directory(path: &Path) -> Result<(), StoreError> { let metadata = fs::symlink_metadata(parent).map_err(StoreError::directory_operation)?; validate_parent_metadata(&metadata)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) - .map_err(StoreError::directory_operation)?; - let metadata = fs::symlink_metadata(parent).map_err(StoreError::directory_operation)?; - if metadata.file_type().is_symlink() - || !metadata.is_dir() - || metadata.permissions().mode() & 0o777 != 0o700 - { - return Err(StoreError::InvalidDatabasePath); - } - } - Ok(()) } @@ -129,28 +133,17 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { fn prepare_database_file(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, - Err(error) if error.kind() == ErrorKind::NotFound => create_database_file(path)?, + Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(StoreError::file_operation(error)), + }, Err(error) => return Err(StoreError::file_operation(error)), } let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .map_err(StoreError::file_operation)?; - let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.permissions().mode() & 0o777 != 0o600 - { - return Err(StoreError::InvalidDatabasePath); - } - } - Ok(()) } @@ -171,7 +164,7 @@ fn validate_database_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> Ok(()) } -fn create_database_file(path: &Path) -> Result<(), StoreError> { +fn create_database_file(path: &Path) -> std::io::Result<()> { let mut options = OpenOptions::new(); options.write(true).create_new(true); @@ -182,7 +175,7 @@ fn create_database_file(path: &Path) -> Result<(), StoreError> { options.mode(0o600); } - drop(options.open(path).map_err(StoreError::file_operation)?); + drop(options.open(path)?); Ok(()) } @@ -197,7 +190,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let connection = open(&path).unwrap(); + let (connection, _) = open(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index 243318b..deaae2a 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -1,5 +1,8 @@ +use std::fmt; + /// A stable, payload-free store failure. -#[derive(Debug, thiserror::Error)] +#[derive(thiserror::Error)] +#[non_exhaustive] pub enum StoreError { /// The supplied path cannot safely name a persistent database. #[error("store database path is invalid")] @@ -45,6 +48,12 @@ pub enum StoreError { }, } +impl fmt::Debug for StoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "StoreError({self})") + } +} + impl StoreError { pub(crate) fn directory_operation(source: std::io::Error) -> Self { Self::DirectoryOperation { source } @@ -60,3 +69,23 @@ impl From for StoreError { Self::DatabaseOperation { source } } } + +#[cfg(test)] +mod tests { + use super::StoreError; + + #[test] + fn debug_redacts_source_payloads() { + let marker = "sensitive-path-or-sql"; + let errors = [ + StoreError::directory_operation(std::io::Error::other(marker)), + StoreError::file_operation(std::io::Error::other(marker)), + StoreError::from(rusqlite::Error::InvalidParameterName(marker.to_owned())), + ]; + + for error in errors { + assert!(!error.to_string().contains(marker)); + assert!(!format!("{error:?}").contains(marker)); + } + } +} diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 6bf2374..1f6455a 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -20,7 +20,7 @@ pub struct Store { impl Store { /// Opens a store and atomically applies every missing known migration. pub fn open(path: &Path) -> Result { - let mut connection = connection::open(path)?; + let (mut connection, database_path) = connection::open(path)?; let found = connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?; @@ -28,6 +28,7 @@ impl Store { return Err(StoreError::UnsupportedDatabaseVersion { found }); } + connection::enforce_database_permissions(&database_path)?; connection::configure(&connection)?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 071bf2b..7023104 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -3,12 +3,16 @@ mod support; -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Barrier}, + thread, +}; use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; use support::{ - FOUNDATION_TABLES, Fixture, fixture_db, foundation_tables, journal_mode, scalar_text, - schema_migrations, table_exists, user_version, + FOUNDATION_TABLES, Fixture, execute_batch, fixture_db, foundation_tables, journal_mode, + scalar_text, schema_migrations, table_exists, user_version, }; #[test] @@ -69,13 +73,17 @@ fn existing_v1_fixture_opens_without_reapplying_migration() { fn future_database_version_fails_before_any_migration() { let dir = tempfile::tempdir().unwrap(); let path = fixture_db(dir.path(), Fixture::Version99); + #[cfg(unix)] + set_mode(&path, 0o644); + let original_contents = std::fs::read(&path).unwrap(); + let original_metadata = std::fs::metadata(&path).unwrap(); let original_journal_mode = journal_mode(&path); let original_sidecars = sqlite_sidecar_state(&path); let error = Store::open(&path).unwrap_err(); assert!(matches!( error, - StoreError::UnsupportedDatabaseVersion { found: 99 } + StoreError::UnsupportedDatabaseVersion { found: 99, .. } )); assert_eq!( error.to_string(), @@ -90,11 +98,20 @@ fn future_database_version_fails_before_any_migration() { assert!(!table_exists(&path, "schema_migrations")); assert_eq!(journal_mode(&path), original_journal_mode); assert_eq!(sqlite_sidecar_state(&path), original_sidecars); + assert_eq!(std::fs::read(&path).unwrap(), original_contents); + let metadata = std::fs::metadata(&path).unwrap(); + assert_eq!(metadata.len(), original_metadata.len()); + assert_eq!( + metadata.modified().unwrap(), + original_metadata.modified().unwrap() + ); + #[cfg(unix)] + assert_eq!(mode(&path), 0o644); let reopened_error = Store::open(&path).unwrap_err(); assert!(matches!( reopened_error, - StoreError::UnsupportedDatabaseVersion { found: 99 } + StoreError::UnsupportedDatabaseVersion { found: 99, .. } )); assert_eq!(user_version(&path), 99); assert!(!table_exists(&path, "schema_migrations")); @@ -103,13 +120,33 @@ fn future_database_version_fails_before_any_migration() { } #[test] -fn partially_applied_v1_transaction_rolls_back_and_recovers() { +fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); + let path = fixture_db(dir.path(), Fixture::MigrationConflictV1); assert_eq!(user_version(&path), 0); assert!(!table_exists(&path, "schema_migrations")); - assert!(!table_exists(&path, "canonical_records")); + assert_eq!( + scalar_text(&path, "SELECT marker FROM canonical_records"), + "preserve-conflict" + ); + + let error = Store::open(&path).unwrap_err(); + assert_eq!(error.to_string(), "store database operation failed"); + + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); + assert_eq!( + scalar_text(&path, "SELECT marker FROM canonical_records"), + "preserve-conflict" + ); + assert_eq!(foundation_tables(&path), ["canonical_records"]); + assert!(!table_exists(&path, "execution_binding_revisions")); + assert!(!table_exists(&path, "transitions")); + assert!(!table_exists(&path, "quarantine_records")); + assert!(!table_exists(&path, "audit_events")); + + execute_batch(&path, "DROP TABLE canonical_records;"); let store = Store::open(&path).unwrap(); assert_eq!(store.schema_version().unwrap(), 1); @@ -134,25 +171,92 @@ fn memory_path_is_rejected_with_a_stable_error() { assert_invalid_database_path(Path::new(":memory:")); } -#[cfg(unix)] #[test] -fn existing_storage_permissions_are_tightened() { - use std::os::unix::fs::PermissionsExt; +fn uri_path_is_rejected_with_a_stable_error() { + assert_invalid_database_path(Path::new("file:psyche.sqlite3")); +} + +#[test] +fn root_path_is_rejected_with_a_stable_error() { + assert_invalid_database_path(Path::new("/")); +} + +#[test] +fn concurrent_first_open_applies_migration_once() { + const THREADS: usize = 8; + + let dir = tempfile::tempdir().unwrap(); + let path = Arc::new(dir.path().join("psyche.sqlite3")); + let barrier = Arc::new(Barrier::new(THREADS)); + let handles = (0..THREADS) + .map(|_| { + let path = Arc::clone(&path); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + let store = Store::open(&path)?; + store.schema_version() + }) + }) + .collect::>(); + + for handle in handles { + assert_eq!(handle.join().unwrap().unwrap(), CURRENT_DATABASE_VERSION); + } + assert_eq!(schema_migrations(&path).len(), 1); +} +#[cfg(unix)] +#[test] +fn existing_shared_parent_permissions_are_preserved() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); std::fs::write(&path, []).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + set_mode(&parent, 0o755); + set_mode(&path, 0o755); drop(Store::open(&path).unwrap()); - assert_private_directory(&parent); + assert_eq!(mode(&parent), 0o755); assert_private_file(&path); } +#[cfg(unix)] +#[test] +fn relative_filename_preserves_current_directory_permissions() { + use std::process::Command; + + let dir = tempfile::tempdir().unwrap(); + set_mode(dir.path(), 0o755); + + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "relative_filename_open_helper", "--nocapture"]) + .env("PSYCHE_STORE_RELATIVE_OPEN_HELPER", "1") + .current_dir(dir.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "relative open helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(mode(dir.path()), 0o755); + assert_private_file(&dir.path().join("psyche.sqlite3")); +} + +#[cfg(unix)] +#[test] +fn relative_filename_open_helper() { + if std::env::var_os("PSYCHE_STORE_RELATIVE_OPEN_HELPER").is_none() { + return; + } + + drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); +} + #[cfg(unix)] #[test] fn symlink_parent_is_rejected_without_creating_a_database() { @@ -210,10 +314,7 @@ fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { #[cfg(unix)] fn assert_private_directory(path: &Path) { - use std::os::unix::fs::PermissionsExt; - - let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700); + assert_eq!(mode(path), 0o700); } #[cfg(not(unix))] @@ -221,10 +322,21 @@ fn assert_private_directory(_path: &Path) {} #[cfg(unix)] fn assert_private_file(path: &Path) { + assert_eq!(mode(path), 0o600); +} + +#[cfg(unix)] +fn mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) { use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); } #[cfg(not(unix))] diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index a2c0069..aba1ff2 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::{Connection, TransactionBehavior}; +use rusqlite::Connection; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,7 +15,7 @@ pub(super) enum Fixture { Version0, Version1, Version99, - PartiallyAppliedV1, + MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { @@ -23,10 +23,10 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", - Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", + Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let mut connection = Connection::open(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -64,39 +64,27 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), - Fixture::PartiallyAppliedV1 => { - let transaction = connection - .transaction_with_behavior(TransactionBehavior::Exclusive) - .unwrap(); - transaction - .execute_batch( - " - CREATE TABLE schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL - ) STRICT; - CREATE TABLE canonical_records ( - kind TEXT NOT NULL, - record_id TEXT NOT NULL, - schema_version TEXT NOT NULL, - digest TEXT NOT NULL, - canonical_json BLOB NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (kind, record_id), - UNIQUE (kind, record_id, digest) - ) STRICT; - PRAGMA user_version = 1; - ", - ) - .unwrap(); - drop(transaction); - } + Fixture::MigrationConflictV1 => connection + .execute_batch( + " + CREATE TABLE canonical_records ( + marker TEXT NOT NULL + ) STRICT; + INSERT INTO canonical_records (marker) VALUES ('preserve-conflict'); + PRAGMA user_version = 0; + ", + ) + .unwrap(), } drop(connection); path } +pub(super) fn execute_batch(path: &Path, sql: &str) { + Connection::open(path).unwrap().execute_batch(sql).unwrap(); +} + pub(super) fn user_version(path: &Path) -> u32 { let connection = Connection::open(path).unwrap(); connection From 7a469f52546035248e10525c6b9d05471d9ba8b0 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:47:48 -0500 Subject: [PATCH 19/66] fix(store): serialize database initialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 124 ++++++++++++++++-- crates/psyche-store/src/error.rs | 3 + crates/psyche-store/src/lib.rs | 53 +++++++- crates/psyche-store/tests/migrations.rs | 165 +++++++++++++++++++++--- 4 files changed, 313 insertions(+), 32 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 9c69106..6c98d86 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -2,12 +2,18 @@ use std::{ fs::{self, OpenOptions}, io::ErrorKind, path::{Path, PathBuf}, + thread, + time::{Duration, Instant}, }; -use rusqlite::{Connection, OpenFlags}; +use rusqlite::{Connection, ErrorCode, OpenFlags}; use crate::StoreError; +const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); +const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); +const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); + pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; @@ -18,6 +24,7 @@ pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; let connection = Connection::open_with_flags(&open_path, flags)?; + connection.busy_timeout(BUSY_TIMEOUT)?; Ok((connection, open_path)) } @@ -43,16 +50,51 @@ pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError Ok(()) } +pub(crate) fn validate_sidecars(path: &Path) -> Result<(), StoreError> { + for sidecar in sqlite_sidecar_paths(path) { + match fs::symlink_metadata(sidecar) { + Ok(metadata) => validate_database_metadata(&metadata)?, + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(StoreError::file_operation(error)), + } + } + Ok(()) +} + +pub(crate) fn enforce_sidecar_permissions(path: &Path) -> Result<(), StoreError> { + for sidecar in sqlite_sidecar_paths(path) { + enforce_existing_sidecar_permissions(&sidecar)?; + } + Ok(()) +} + pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { - connection.execute_batch( - " - PRAGMA busy_timeout = 5000; - PRAGMA foreign_keys = ON; - PRAGMA journal_mode = WAL; - PRAGMA synchronous = FULL; - PRAGMA secure_delete = ON; - ", - )?; + let deadline = Instant::now() + BUSY_TIMEOUT; + let mut last_contention: Option = None; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return match last_contention { + Some(error) => Err(error.into()), + None => Err(StoreError::ConfigurationUnavailable), + }; + } + connection.busy_timeout(remaining.min(CONFIGURATION_ATTEMPT_TIMEOUT))?; + + match configure_once(connection) { + Ok(()) => break, + Err(error) if is_lock_contention(&error) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(error.into()); + } + last_contention = Some(error); + thread::sleep(remaining.min(CONFIGURATION_RETRY_DELAY)); + } + Err(error) => return Err(error.into()), + } + } + connection.busy_timeout(BUSY_TIMEOUT)?; let foreign_keys = connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, u32>(0))?; @@ -77,6 +119,68 @@ pub(crate) fn configure(connection: &Connection) -> Result<(), StoreError> { } } +fn configure_once(connection: &Connection) -> rusqlite::Result<()> { + connection.execute_batch( + " + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA secure_delete = ON; + ", + ) +} + +fn is_lock_contention(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(sqlite_error, _) + if matches!( + sqlite_error.code, + ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked + ) + ) +} + +fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { + ["-journal", "-wal", "-shm"].map(|suffix| { + let mut sidecar = path.as_os_str().to_owned(); + sidecar.push(suffix); + PathBuf::from(sidecar) + }) +} + +fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => validate_database_metadata(&metadata)?, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(StoreError::file_operation(error)), + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + match fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(StoreError::file_operation(error)), + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(StoreError::file_operation(error)), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(StoreError::InvalidDatabasePath); + } + } + + Ok(()) +} + fn validate_path(path: &Path) -> Result<(), StoreError> { let is_special = path.as_os_str().is_empty() || path == Path::new(":memory:") diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index deaae2a..b9c3c3a 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -10,6 +10,9 @@ pub enum StoreError { /// SQLite did not retain every required durability setting. #[error("required store database configuration is unavailable")] ConfigurationUnavailable, + /// Another initialization attempt panicked while holding the process lock. + #[error("store database initialization is unavailable")] + InitializationUnavailable, /// The database's schema is newer than this build understands. #[error( "unsupported database version {found}; maximum supported version is {}", diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 1f6455a..3ce06e0 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -4,7 +4,10 @@ mod connection; mod error; mod migrations; -use std::path::Path; +use std::{ + path::Path, + sync::{Mutex, MutexGuard, OnceLock}, +}; use rusqlite::TransactionBehavior; @@ -17,19 +20,31 @@ pub struct Store { connection: rusqlite::Connection, } +static INITIALIZATION_LOCK: OnceLock> = OnceLock::new(); + impl Store { /// Opens a store and atomically applies every missing known migration. pub fn open(path: &Path) -> Result { + let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); + let _initialization_guard = initialization_guard(initialization_lock)?; let (mut connection, database_path) = connection::open(path)?; let found = - connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?; + match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { + Ok(found) => found, + Err(error) => { + connection::validate_sidecars(&database_path)?; + return Err(error.into()); + } + }; if found > CURRENT_DATABASE_VERSION { return Err(StoreError::UnsupportedDatabaseVersion { found }); } connection::enforce_database_permissions(&database_path)?; + connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; + connection::enforce_sidecar_permissions(&database_path)?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; let found = @@ -56,3 +71,37 @@ impl Store { .pragma_query_value(None, "user_version", |row| row.get(0))?) } } + +fn initialization_guard(lock: &Mutex<()>) -> Result, StoreError> { + lock.lock() + .map_err(|_| StoreError::InitializationUnavailable) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::initialization_guard; + + #[test] + fn poisoned_initialization_lock_returns_a_stable_error() { + let lock = Arc::new(Mutex::new(())); + let poisoner = Arc::clone(&lock); + let _ = std::thread::spawn(move || { + let _guard = poisoner.lock().unwrap(); + panic!("poison initialization lock"); + }) + .join(); + + let error = initialization_guard(&lock).unwrap_err(); + + assert_eq!( + error.to_string(), + "store database initialization is unavailable" + ); + assert_eq!( + format!("{error:?}"), + "StoreError(store database initialization is unavailable)" + ); + } +} diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 7023104..f4a2571 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -10,6 +10,7 @@ use std::{ }; use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; +use rusqlite::Connection; use support::{ FOUNDATION_TABLES, Fixture, execute_batch, fixture_db, foundation_tables, journal_mode, scalar_text, schema_migrations, table_exists, user_version, @@ -183,27 +184,145 @@ fn root_path_is_rejected_with_a_stable_error() { #[test] fn concurrent_first_open_applies_migration_once() { + const ROUNDS: usize = 8; const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); - let path = Arc::new(dir.path().join("psyche.sqlite3")); - let barrier = Arc::new(Barrier::new(THREADS)); - let handles = (0..THREADS) - .map(|_| { - let path = Arc::clone(&path); - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - barrier.wait(); - let store = Store::open(&path)?; - store.schema_version() + for round in 0..ROUNDS { + let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); + let barrier = Arc::new(Barrier::new(THREADS)); + let handles = (0..THREADS) + .map(|_| { + let path = Arc::clone(&path); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + let store = Store::open(&path)?; + store.schema_version() + }) }) - }) - .collect::>(); + .collect::>(); - for handle in handles { - assert_eq!(handle.join().unwrap().unwrap(), CURRENT_DATABASE_VERSION); + for handle in handles { + assert_eq!(handle.join().unwrap().unwrap(), CURRENT_DATABASE_VERSION); + } + assert_eq!(schema_migrations(&path).len(), 1); } - assert_eq!(schema_migrations(&path).len(), 1); +} + +#[cfg(unix)] +#[test] +fn existing_wal_sidecars_are_made_private_before_open_completes() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + let setup_connection = Connection::open(&path).unwrap(); + setup_connection + .execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE sidecar_marker (value TEXT NOT NULL) STRICT; + INSERT INTO sidecar_marker (value) VALUES ('keep-wal-open'); + ", + ) + .unwrap(); + + set_mode(&path, 0o644); + let sidecars = sqlite_sidecar_paths(&path); + assert!(sidecars[1].exists()); + assert!(sidecars[2].exists()); + for sidecar in &sidecars { + if sidecar.exists() { + set_mode(sidecar, 0o644); + } + } + + let store = Store::open(&path).unwrap(); + + assert_private_file(&path); + for sidecar in &sidecars { + if sidecar.exists() { + assert_private_file(sidecar); + } + } + drop(store); + drop(setup_connection); +} + +#[cfg(unix)] +#[test] +fn symlink_wal_sidecar_is_rejected_before_migration_writes() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + let target = dir.path().join("sidecar-target"); + std::fs::write(&target, b"do-not-touch").unwrap(); + let wal_path = sqlite_sidecar_paths(&path)[1].clone(); + symlink(&target, &wal_path).unwrap(); + + assert_invalid_database_path(&path); + + assert_eq!(std::fs::read(&target).unwrap(), b"do-not-touch"); + std::fs::remove_file(wal_path).unwrap(); + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); +} + +#[cfg(unix)] +#[test] +fn non_regular_wal_sidecar_is_rejected_before_migration_writes() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + let wal_path = sqlite_sidecar_paths(&path)[1].clone(); + std::fs::create_dir(&wal_path).unwrap(); + + assert_invalid_database_path(&path); + + std::fs::remove_dir(wal_path).unwrap(); + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); +} + +#[cfg(unix)] +#[test] +fn future_database_sidecar_permissions_are_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version99); + let setup_connection = Connection::open(&path).unwrap(); + setup_connection + .execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + INSERT INTO future_owner (value) VALUES ('keep-sidecars-open'); + ", + ) + .unwrap(); + + set_mode(&path, 0o644); + let sidecars = sqlite_sidecar_paths(&path); + assert!(sidecars[1].exists()); + assert!(sidecars[2].exists()); + for sidecar in &sidecars { + if sidecar.exists() { + set_mode(sidecar, 0o644); + } + } + + let error = Store::open(&path).unwrap_err(); + + assert!(matches!( + error, + StoreError::UnsupportedDatabaseVersion { found: 99, .. } + )); + assert_eq!(mode(&path), 0o644); + for sidecar in &sidecars { + if sidecar.exists() { + assert_eq!(mode(sidecar), 0o644); + } + } + drop(setup_connection); } #[cfg(unix)] @@ -296,12 +415,10 @@ fn assert_invalid_database_path(path: &Path) { } fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { - ["-journal", "-wal", "-shm"] + sqlite_sidecar_paths(path) .into_iter() - .map(|suffix| { - let mut sidecar = path.as_os_str().to_owned(); - sidecar.push(suffix); - let sidecar = PathBuf::from(sidecar); + .zip(["-journal", "-wal", "-shm"]) + .map(|(sidecar, suffix)| { let contents = match std::fs::read(sidecar) { Ok(contents) => Some(contents), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -312,6 +429,14 @@ fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { .collect() } +fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { + ["-journal", "-wal", "-shm"].map(|suffix| { + let mut sidecar = path.as_os_str().to_owned(); + sidecar.push(suffix); + PathBuf::from(sidecar) + }) +} + #[cfg(unix)] fn assert_private_directory(path: &Path) { assert_eq!(mode(path), 0o700); From dd1e1006dcf759e283ee61b00eb7c3b260c98560 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:56:59 -0500 Subject: [PATCH 20/66] fix(store): keep error chains payload-free Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/error.rs | 69 +++++++++++++++++++------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index b9c3c3a..9a0b778 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -30,25 +30,13 @@ pub enum StoreError { }, /// Creating the store's parent directory failed. #[error("store directory operation failed")] - DirectoryOperation { - /// Underlying filesystem error, retained without rendering its payload. - #[source] - source: std::io::Error, - }, + DirectoryOperation, /// Preparing the database file failed. #[error("store file operation failed")] - FileOperation { - /// Underlying filesystem error, retained without rendering its payload. - #[source] - source: std::io::Error, - }, + FileOperation, /// Opening, configuring, or querying SQLite failed. #[error("store database operation failed")] - DatabaseOperation { - /// Underlying SQLite error, retained without rendering its payload. - #[source] - source: rusqlite::Error, - }, + DatabaseOperation, } impl fmt::Debug for StoreError { @@ -58,37 +46,62 @@ impl fmt::Debug for StoreError { } impl StoreError { - pub(crate) fn directory_operation(source: std::io::Error) -> Self { - Self::DirectoryOperation { source } + pub(crate) fn directory_operation(_source: std::io::Error) -> Self { + Self::DirectoryOperation } - pub(crate) fn file_operation(source: std::io::Error) -> Self { - Self::FileOperation { source } + pub(crate) fn file_operation(_source: std::io::Error) -> Self { + Self::FileOperation } } impl From for StoreError { - fn from(source: rusqlite::Error) -> Self { - Self::DatabaseOperation { source } + fn from(_source: rusqlite::Error) -> Self { + Self::DatabaseOperation } } #[cfg(test)] mod tests { + use std::error::Error; + use super::StoreError; #[test] - fn debug_redacts_source_payloads() { + fn display_debug_and_full_source_chain_redact_payloads() { let marker = "sensitive-path-or-sql"; let errors = [ - StoreError::directory_operation(std::io::Error::other(marker)), - StoreError::file_operation(std::io::Error::other(marker)), - StoreError::from(rusqlite::Error::InvalidParameterName(marker.to_owned())), + ( + StoreError::directory_operation(std::io::Error::other(marker)), + "store directory operation failed", + ), + ( + StoreError::file_operation(std::io::Error::other(marker)), + "store file operation failed", + ), + ( + StoreError::from(rusqlite::Error::InvalidParameterName(marker.to_owned())), + "store database operation failed", + ), ]; - for error in errors { - assert!(!error.to_string().contains(marker)); - assert!(!format!("{error:?}").contains(marker)); + for (error, expected_display) in errors { + assert_eq!(error.to_string(), expected_display); + assert_eq!( + format!("{error:?}"), + format!("StoreError({expected_display})") + ); + + let mut current: &dyn Error = &error; + loop { + assert!(!current.to_string().contains(marker)); + assert!(!format!("{current:?}").contains(marker)); + let Some(source) = current.source() else { + break; + }; + current = source; + } + assert!(error.source().is_none()); } } } From a5453f43abb71a1502f9c8f229cc744891da5d9e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:17:39 -0500 Subject: [PATCH 21/66] feat(store): persist records and revisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 5 + crates/psyche-core/src/contracts/mod.rs | 75 +- crates/psyche-store/Cargo.toml | 5 + crates/psyche-store/src/error.rs | 82 + crates/psyche-store/src/execution_bindings.rs | 274 ++++ crates/psyche-store/src/lib.rs | 5 + crates/psyche-store/src/records.rs | 284 ++++ crates/psyche-store/src/transitions.rs | 240 +++ crates/psyche-store/tests/records.rs | 1327 +++++++++++++++++ 9 files changed, 2294 insertions(+), 3 deletions(-) create mode 100644 crates/psyche-store/src/execution_bindings.rs create mode 100644 crates/psyche-store/src/records.rs create mode 100644 crates/psyche-store/src/transitions.rs create mode 100644 crates/psyche-store/tests/records.rs diff --git a/Cargo.lock b/Cargo.lock index 1472de7..d98a76c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,9 +628,14 @@ dependencies = [ name = "psyche-store" version = "0.0.0" dependencies = [ + "proptest", + "psyche-core", "rusqlite", + "serde", + "serde_json", "tempfile", "thiserror", + "time", ] [[package]] diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 8d5d276..0adb979 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -243,6 +243,34 @@ pub enum ContractError { /// Stable field name or validation category. field: &'static str, }, + /// A typed record declared a schema other than the one its Rust type owns. + #[error("document schema does not match {expected:?}")] + SchemaMismatch { + /// Schema required by the typed record. + expected: SchemaKind, + /// Schema declared by the supplied value. + found: SchemaKind, + }, + /// A record identifier belongs to a different persisted record kind. + #[error("{schema:?}.{field} does not identify a {expected:?} record")] + WrongRecordKind { + /// Schema containing the identifier. + schema: SchemaKind, + /// Stable field name. + field: &'static str, + /// Record kind required for the field. + expected: RecordKind, + /// Record kind carried by the identifier. + found: RecordKind, + }, + /// A supplied digest does not match the canonical digest of its input. + #[error("{schema:?}.{field} does not match the canonical digest")] + DigestMismatch { + /// Schema containing the digest. + schema: SchemaKind, + /// Stable digest field name. + field: &'static str, + }, /// A string did not name a member of a frozen enum vocabulary. #[error("unknown enum value for {schema:?}.{field}")] UnknownEnumValue { @@ -347,7 +375,8 @@ impl RecordKind { /// The sixteen record/document shapes this build's schema registry knows /// about, one of which (`Error`) never round-trips through a `RecordKind`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum SchemaKind { /// `psyche.identity_snapshot.vN` IdentitySnapshot, @@ -632,6 +661,38 @@ impl RejectedDocument { reason, } } + + /// Converts a typed decode denial into bounded quarantine input. + pub fn from_decode_error(bytes: &[u8], error: ContractError) -> Self { + let reason = match error { + ContractError::DocumentTooLarge => RejectionReason::TooLarge, + ContractError::UnknownSchema => RejectionReason::UnknownSchema, + ContractError::UnsupportedMajor { found, supported } => { + RejectionReason::UnsupportedMajor { found, supported } + } + ContractError::UnknownEnumValue { schema, field } => { + RejectionReason::UnknownEnumValue { schema, field } + } + ContractError::InvalidShape { schema, field } => { + RejectionReason::InvalidShape { schema, field } + } + ContractError::WrongRecordPrefix { .. } + | ContractError::MalformedIdentifier + | ContractError::InvalidUlid + | ContractError::UnsupportedDigestPrefix + | ContractError::MalformedDigest + | ContractError::CanonicalizationFailed + | ContractError::NonInteroperableNumber + | ContractError::SchemaMismatch { .. } + | ContractError::WrongRecordKind { .. } + | ContractError::DigestMismatch { .. } + | ContractError::CancellationEvidenceMismatch => RejectionReason::InvalidShape { + schema: SchemaKind::Error, + field: "document", + }, + }; + Self::from_bytes(bytes, reason) + } } impl fmt::Debug for RejectedDocument { @@ -1174,7 +1235,10 @@ pub(crate) fn require_schema(value: SchemaVersion, kind: SchemaKind) -> Result<( if value.kind == kind && value.major == 1 { Ok(()) } else { - Err(invalid(kind, "schema_version")) + Err(ContractError::SchemaMismatch { + expected: kind, + found: value.kind, + }) } } @@ -1187,7 +1251,12 @@ pub(crate) fn require_id( if id.kind() == kind { Ok(()) } else { - Err(invalid(schema, field)) + Err(ContractError::WrongRecordKind { + schema, + field, + expected: kind, + found: id.kind(), + }) } } diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index f09709b..944d034 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -8,10 +8,15 @@ repository.workspace = true publish.workspace = true [dependencies] +psyche-core = { workspace = true } rusqlite = { workspace = true } +serde = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } [dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index 9a0b778..15d14fa 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -1,5 +1,8 @@ use std::fmt; +use psyche_core::contracts::{ContractError, SchemaKind}; +use psyche_core::id::RecordId; + /// A stable, payload-free store failure. #[derive(thiserror::Error)] #[non_exhaustive] @@ -28,6 +31,41 @@ pub enum StoreError { /// Missing migration version. version: u32, }, + /// A typed document failed its owned contract validation. + #[error("record contract validation failed")] + Contract(ContractError), + /// A recognized document kind has no durable storage identity. + #[error("document kind is not persistable")] + NonPersistableKind { + /// Recognized non-persistable schema kind. + kind: SchemaKind, + }, + /// A record identity already names different canonical content. + #[error("record identity conflicts with stored canonical content")] + RecordConflict { + /// Schema kind of the conflicting record. + kind: SchemaKind, + /// Durable identity that was reused. + record_id: RecordId, + }, + /// An execution-binding revision would break its immutable linear history. + #[error("execution binding revision conflicts with stored history")] + ExecutionBindingRevisionConflict { + /// Attempt whose revision history would fork. + attempt_id: RecordId, + /// Conflicting one-based revision. + revision: u64, + }, + /// A transition would break its record's immutable linear history. + #[error("transition conflicts with stored history")] + TransitionConflict { + /// Schema kind of the transitioned record. + kind: SchemaKind, + /// Durable identity of the transitioned record. + record_id: RecordId, + /// Conflicting one-based record version. + record_version: u64, + }, /// Creating the store's parent directory failed. #[error("store directory operation failed")] DirectoryOperation, @@ -61,10 +99,19 @@ impl From for StoreError { } } +impl From for StoreError { + fn from(source: ContractError) -> Self { + Self::Contract(source) + } +} + #[cfg(test)] mod tests { use std::error::Error; + use psyche_core::contracts::{ContractError, RecordKind, SchemaKind}; + use psyche_core::id::RecordId; + use super::StoreError; #[test] @@ -104,4 +151,39 @@ mod tests { assert!(error.source().is_none()); } } + + #[test] + fn record_failures_keep_identifiers_and_contract_details_out_of_rendering() { + let id = RecordId::parse(RecordKind::Attempt, "att_01J00000000000000000000000").unwrap(); + let errors = [ + StoreError::Contract(ContractError::WrongRecordKind { + schema: SchemaKind::ExecutionBinding, + field: "record_id", + expected: RecordKind::Attempt, + found: RecordKind::Intent, + }), + StoreError::NonPersistableKind { + kind: SchemaKind::Error, + }, + StoreError::RecordConflict { + kind: SchemaKind::ExecutionBinding, + record_id: id.clone(), + }, + StoreError::ExecutionBindingRevisionConflict { + attempt_id: id.clone(), + revision: 2, + }, + StoreError::TransitionConflict { + kind: SchemaKind::ExecutionBinding, + record_id: id, + record_version: 2, + }, + ]; + + for error in errors { + assert!(!error.to_string().contains("att_")); + assert!(!format!("{error:?}").contains("att_")); + assert!(error.source().is_none()); + } + } } diff --git a/crates/psyche-store/src/execution_bindings.rs b/crates/psyche-store/src/execution_bindings.rs new file mode 100644 index 0000000..d2f06ad --- /dev/null +++ b/crates/psyche-store/src/execution_bindings.rs @@ -0,0 +1,274 @@ +use psyche_core::contracts::{CanonicalDocument, ContractError, ExecutionBinding, SchemaKind}; +use psyche_core::digest::{canonical_bytes, digest}; +use psyche_core::id::RecordId; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; +use time::format_description::well_known::Rfc3339; + +use crate::records::InsertStatus; +use crate::{Store, StoreError, records}; + +struct StoredRevision { + revision: u64, + canonical_json: Vec, +} + +impl Store { + /// Returns every immutable revision for an execution attempt in order. + pub fn execution_binding_revisions( + &self, + attempt_id: &RecordId, + ) -> Result, StoreError> { + revisions(&self.connection, attempt_id) + } +} + +pub(crate) fn insert( + connection: &mut Connection, + binding: &ExecutionBinding, +) -> Result { + let canonical_json = canonical_bytes(binding)?; + let revision_digest = digest(binding)?; + let sql_revision = sql_revision(binding.revision)?; + let created_at = binding + .revision_created_at + .format(&Rfc3339) + .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let existing: Option> = transaction + .query_row( + " + SELECT canonical_json + FROM execution_binding_revisions + WHERE attempt_id = ?1 AND revision = ?2 + ", + params![binding.attempt_id.as_str(), sql_revision], + |row| row.get(0), + ) + .optional()?; + if let Some(existing) = existing { + if existing == canonical_json { + transaction.commit()?; + return Ok(InsertStatus::AlreadyPresent); + } + return Err(revision_conflict(binding)); + } + + let latest = latest_revision(&transaction, &binding.attempt_id)?; + match latest { + None => { + if binding.revision != 1 || binding.previous_revision_digest.is_some() { + return Err(revision_conflict(binding)); + } + } + Some(latest) => { + validate_next_revision(&transaction, binding, &latest)?; + } + } + + transaction.execute( + " + INSERT INTO execution_binding_revisions ( + attempt_id, + revision, + schema_version, + digest, + previous_revision_digest, + canonical_json, + created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + params![ + binding.attempt_id.as_str(), + sql_revision, + binding.schema_version.to_string(), + revision_digest.as_str(), + binding + .previous_revision_digest + .as_ref() + .map(|value| value.as_str()), + canonical_json, + created_at, + ], + )?; + transaction.commit()?; + Ok(InsertStatus::Inserted) +} + +pub(crate) fn revisions( + connection: &Connection, + attempt_id: &RecordId, +) -> Result, StoreError> { + records::validate_kind_id(SchemaKind::ExecutionBinding, attempt_id)?; + let mut statement = connection.prepare( + " + SELECT canonical_json + FROM execution_binding_revisions + WHERE attempt_id = ?1 + ORDER BY revision + ", + )?; + let canonical = statement + .query_map([attempt_id.as_str()], |row| row.get::<_, Vec>(0))? + .collect::>>()?; + canonical + .into_iter() + .map(|bytes| decode_binding(&bytes)) + .collect() +} + +pub(crate) fn latest_canonical_bytes( + connection: &Connection, + attempt_id: &RecordId, +) -> Result>, StoreError> { + records::validate_kind_id(SchemaKind::ExecutionBinding, attempt_id)?; + connection + .query_row( + " + SELECT canonical_json + FROM execution_binding_revisions + WHERE attempt_id = ?1 + ORDER BY revision DESC + LIMIT 1 + ", + [attempt_id.as_str()], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn validate_next_revision( + transaction: &Transaction<'_>, + binding: &ExecutionBinding, + latest: &StoredRevision, +) -> Result<(), StoreError> { + let expected_revision = latest + .revision + .checked_add(1) + .ok_or_else(|| revision_conflict(binding))?; + let latest_binding = decode_binding(&latest.canonical_json)?; + let latest_digest = digest(&latest_binding)?; + if binding.revision != expected_revision + || binding.previous_revision_digest.as_ref() != Some(&latest_digest) + { + return Err(revision_conflict(binding)); + } + + let initial = + first_revision(transaction, &binding.attempt_id)?.ok_or(StoreError::DatabaseOperation)?; + if binding.revision_created_at <= latest_binding.revision_created_at + || !frozen_execution_fields_match(&initial, binding) + || !session_binding_is_append_only(&latest_binding, binding) + || !termination_binding_is_append_only(&latest_binding, binding) + { + return Err(revision_conflict(binding)); + } + Ok(()) +} + +fn frozen_execution_fields_match(initial: &ExecutionBinding, candidate: &ExecutionBinding) -> bool { + initial.attempt_id == candidate.attempt_id + && initial.familiar_snapshot_id == candidate.familiar_snapshot_id + && initial.project_id == candidate.project_id + && initial.request_id == candidate.request_id + && initial.request_digest == candidate.request_digest + && initial.request_created_at == candidate.request_created_at + && initial.request_valid_until == candidate.request_valid_until + && initial.coven_contract_version == candidate.coven_contract_version +} + +fn session_binding_is_append_only(latest: &ExecutionBinding, candidate: &ExecutionBinding) -> bool { + match (&latest.coven_session_id, &candidate.coven_session_id) { + (None, _) => true, + (Some(previous), Some(candidate)) => previous == candidate, + (Some(_), None) => false, + } +} + +fn termination_binding_is_append_only( + latest: &ExecutionBinding, + candidate: &ExecutionBinding, +) -> bool { + match &latest.termination_request { + None => true, + Some(previous) => { + candidate.termination_request.as_ref() == Some(previous) + && candidate.termination_reason_code == latest.termination_reason_code + } + } +} + +fn latest_revision( + transaction: &Transaction<'_>, + attempt_id: &RecordId, +) -> Result, StoreError> { + let stored = transaction + .query_row( + " + SELECT revision, canonical_json + FROM execution_binding_revisions + WHERE attempt_id = ?1 + ORDER BY revision DESC + LIMIT 1 + ", + [attempt_id.as_str()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)), + ) + .optional()?; + stored + .map(|(revision, canonical_json)| { + let revision = u64::try_from(revision).map_err(|_| StoreError::DatabaseOperation)?; + Ok(StoredRevision { + revision, + canonical_json, + }) + }) + .transpose() +} + +fn first_revision( + transaction: &Transaction<'_>, + attempt_id: &RecordId, +) -> Result, StoreError> { + transaction + .query_row( + " + SELECT canonical_json + FROM execution_binding_revisions + WHERE attempt_id = ?1 AND revision = 1 + ", + [attempt_id.as_str()], + |row| row.get::<_, Vec>(0), + ) + .optional()? + .map(|bytes| decode_binding(&bytes)) + .transpose() +} + +fn decode_binding(bytes: &[u8]) -> Result { + match psyche_core::contracts::decode_document(bytes)? { + CanonicalDocument::ExecutionBinding(binding) => Ok(binding), + document => Err(StoreError::Contract(ContractError::SchemaMismatch { + expected: SchemaKind::ExecutionBinding, + found: document.schema_version().kind, + })), + } +} + +fn sql_revision(revision: u64) -> Result { + i64::try_from(revision).map_err(|_| { + StoreError::Contract(ContractError::InvalidShape { + schema: SchemaKind::ExecutionBinding, + field: "revision", + }) + }) +} + +fn revision_conflict(binding: &ExecutionBinding) -> StoreError { + StoreError::ExecutionBindingRevisionConflict { + attempt_id: binding.attempt_id.clone(), + revision: binding.revision, + } +} diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 3ce06e0..e2ae267 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -2,7 +2,10 @@ mod connection; mod error; +mod execution_bindings; mod migrations; +mod records; +mod transitions; use std::{ path::Path, @@ -13,6 +16,8 @@ use rusqlite::TransactionBehavior; pub use error::StoreError; pub use migrations::CURRENT_DATABASE_VERSION; +pub use records::IngestOutcome; +pub use transitions::Transition; /// A configured connection to Psyche's durable SQLite substrate. #[derive(Debug)] diff --git a/crates/psyche-store/src/records.rs b/crates/psyche-store/src/records.rs new file mode 100644 index 0000000..57610a9 --- /dev/null +++ b/crates/psyche-store/src/records.rs @@ -0,0 +1,284 @@ +use psyche_core::contracts::{ + CanonicalDocument, ContractError, RejectedDocument, RejectionReason, SchemaKind, + decode_document, +}; +use psyche_core::digest::{canonical_bytes, digest}; +use psyche_core::id::RecordId; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; + +use crate::{Store, StoreError, execution_bindings}; + +/// Result of ingesting bytes at the store boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestOutcome { + /// A new canonical record was persisted. + Inserted, + /// The exact canonical record was already present. + AlreadyPresent, + /// Unsupported bytes were retained only in quarantine. + Quarantined, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InsertStatus { + Inserted, + AlreadyPresent, +} + +impl Store { + /// Strictly decodes and persists or quarantines one byte document. + pub fn ingest(&mut self, bytes: &[u8]) -> Result { + match decode_document(bytes) { + Ok(document) => match self.insert_with_status(&document)? { + InsertStatus::Inserted => Ok(IngestOutcome::Inserted), + InsertStatus::AlreadyPresent => Ok(IngestOutcome::AlreadyPresent), + }, + Err( + error @ (ContractError::UnknownSchema + | ContractError::UnsupportedMajor { .. } + | ContractError::UnknownEnumValue { .. }), + ) => { + let rejected = RejectedDocument::from_decode_error(bytes, error); + self.quarantine_decode_rejection(&rejected)?; + Ok(IngestOutcome::Quarantined) + } + Err(error) => Err(StoreError::Contract(error)), + } + } + + /// Validates and immutably persists one typed canonical document. + pub fn insert(&mut self, document: &CanonicalDocument) -> Result<(), StoreError> { + self.insert_with_status(document).map(|_| ()) + } + + /// Loads and validates one canonical record by exact kind and identity. + pub fn load( + &self, + kind: SchemaKind, + id: &RecordId, + ) -> Result, StoreError> { + let Some(bytes) = self.load_canonical_bytes(kind, id)? else { + return Ok(None); + }; + let document = decode_document(&bytes)?; + if document.schema_version().kind != kind { + return Err(StoreError::Contract(ContractError::SchemaMismatch { + expected: kind, + found: document.schema_version().kind, + })); + } + if document.persistable_record_id() != Some(id) { + return Err(StoreError::DatabaseOperation); + } + Ok(Some(document)) + } + + /// Loads immutable canonical bytes by exact kind and identity. + pub fn load_canonical_bytes( + &self, + kind: SchemaKind, + id: &RecordId, + ) -> Result>, StoreError> { + validate_kind_id(kind, id)?; + if kind == SchemaKind::ExecutionBinding { + return execution_bindings::latest_canonical_bytes(&self.connection, id); + } + self.connection + .query_row( + " + SELECT canonical_json + FROM canonical_records + WHERE kind = ?1 AND record_id = ?2 + ", + params![kind_key(kind), id.as_str()], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + } + + /// Counts persisted logical records of one schema kind. + pub fn count_records(&self, kind: SchemaKind) -> Result { + if kind.record_kind().is_none() { + return Err(StoreError::NonPersistableKind { kind }); + } + let count: i64 = if kind == SchemaKind::ExecutionBinding { + self.connection.query_row( + "SELECT COUNT(DISTINCT attempt_id) FROM execution_binding_revisions", + [], + |row| row.get(0), + )? + } else { + self.connection.query_row( + "SELECT COUNT(*) FROM canonical_records WHERE kind = ?1", + [kind_key(kind)], + |row| row.get(0), + )? + }; + count.try_into().map_err(|_| StoreError::DatabaseOperation) + } + + /// Counts all persisted logical canonical and execution-binding records. + pub fn total_record_count(&self) -> Result { + let canonical: i64 = + self.connection + .query_row("SELECT COUNT(*) FROM canonical_records", [], |row| { + row.get(0) + })?; + let bindings: i64 = self.connection.query_row( + "SELECT COUNT(DISTINCT attempt_id) FROM execution_binding_revisions", + [], + |row| row.get(0), + )?; + let total = canonical + .checked_add(bindings) + .ok_or(StoreError::DatabaseOperation)?; + total.try_into().map_err(|_| StoreError::DatabaseOperation) + } + + fn insert_with_status( + &mut self, + document: &CanonicalDocument, + ) -> Result { + document.validate()?; + let kind = document.schema_version().kind; + let id = document + .persistable_record_id() + .ok_or(StoreError::NonPersistableKind { kind })?; + if let CanonicalDocument::ExecutionBinding(binding) = document { + return execution_bindings::insert(&mut self.connection, binding); + } + + let bytes = canonical_bytes(document)?; + let record_digest = digest(document)?; + let schema_version = document.schema_version().to_string(); + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + " + INSERT INTO canonical_records ( + kind, + record_id, + schema_version, + digest, + canonical_json, + created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ON CONFLICT DO NOTHING + ", + params![ + kind_key(kind), + id.as_str(), + schema_version, + record_digest.as_str(), + bytes, + ], + )?; + let stored_digest: String = transaction.query_row( + " + SELECT digest + FROM canonical_records + WHERE kind = ?1 AND record_id = ?2 + ", + params![kind_key(kind), id.as_str()], + |row| row.get(0), + )?; + if stored_digest != record_digest.as_str() { + return Err(StoreError::RecordConflict { + kind, + record_id: id.clone(), + }); + } + let status = if transaction.changes() == 1 { + InsertStatus::Inserted + } else { + InsertStatus::AlreadyPresent + }; + transaction.commit()?; + Ok(status) + } + + fn quarantine_decode_rejection( + &mut self, + rejected: &RejectedDocument, + ) -> Result<(), StoreError> { + let reason = match rejected.reason { + RejectionReason::TooLarge => "too_large", + RejectionReason::UnknownSchema => "unknown_schema", + RejectionReason::UnsupportedMajor { .. } => "unsupported_major", + RejectionReason::UnknownEnumValue { .. } => "unknown_enum_value", + RejectionReason::InvalidShape { .. } => "invalid_shape", + }; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + " + INSERT INTO quarantine_records ( + quarantine_id, + schema_version, + payload_digest, + bounded_payload, + reason, + discovered_at + ) + VALUES ( + ?1, + ?2, + ?3, + ?4, + ?5, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ) + ON CONFLICT(quarantine_id) DO NOTHING + ", + params![ + rejected.payload_digest.as_str(), + rejected.schema_version.as_deref(), + rejected.payload_digest.as_str(), + &rejected.bounded_payload, + reason, + ], + )?; + transaction.commit()?; + Ok(()) + } +} + +pub(crate) fn kind_key(kind: SchemaKind) -> &'static str { + match kind { + SchemaKind::IdentitySnapshot => "identity_snapshot", + SchemaKind::Intent => "intent", + SchemaKind::SurfaceEvent => "surface_event", + SchemaKind::Graph => "graph", + SchemaKind::GraphNode => "graph_node", + SchemaKind::Delegation => "delegation", + SchemaKind::Budget => "budget", + SchemaKind::Approval => "approval", + SchemaKind::ExecutionBinding => "execution_binding", + SchemaKind::Evidence => "evidence", + SchemaKind::Verdict => "verdict", + SchemaKind::Recovery => "recovery", + SchemaKind::Addon => "addon", + SchemaKind::SurfaceEffect => "surface_effect", + SchemaKind::Delivery => "delivery", + SchemaKind::Error => "error", + } +} + +pub(crate) fn validate_kind_id(kind: SchemaKind, id: &RecordId) -> Result<(), StoreError> { + let expected = kind + .record_kind() + .ok_or(StoreError::NonPersistableKind { kind })?; + if id.kind() != expected { + return Err(StoreError::Contract(ContractError::WrongRecordKind { + schema: kind, + field: "record_id", + expected, + found: id.kind(), + })); + } + Ok(()) +} diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs new file mode 100644 index 0000000..58b84da --- /dev/null +++ b/crates/psyche-store/src/transitions.rs @@ -0,0 +1,240 @@ +use psyche_core::contracts::{ContractError, SchemaKind}; +use psyche_core::digest::{Sha256Digest, digest}; +use psyche_core::id::RecordId; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use time::format_description::well_known::Rfc3339; + +use crate::{Store, StoreError, records}; + +/// One immutable state transition for a persisted record. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transition { + /// Schema kind of the transitioned record. + pub kind: SchemaKind, + /// Durable identity of the transitioned record. + pub record_id: RecordId, + /// One-based version in this record's transition history. + pub record_version: u64, + /// State immediately before this transition. + pub from_state: Option, + /// State established by this transition. + pub to_state: String, + /// Canonical digest of every other transition field. + pub transition_digest: Sha256Digest, + /// UTC creation time. + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, +} + +#[derive(serde::Serialize)] +struct TransitionDigestInput<'a> { + kind: SchemaKind, + record_id: &'a RecordId, + record_version: u64, + from_state: &'a Option, + to_state: &'a str, + #[serde(with = "time::serde::rfc3339")] + created_at: time::OffsetDateTime, +} + +impl Transition { + /// Builds a transition and binds its canonical digest. + pub fn new( + kind: SchemaKind, + record_id: RecordId, + record_version: u64, + from_state: Option, + to_state: String, + created_at: time::OffsetDateTime, + ) -> Result { + let transition_digest = digest(&TransitionDigestInput { + kind, + record_id: &record_id, + record_version, + from_state: &from_state, + to_state: &to_state, + created_at, + })?; + let transition = Self { + kind, + record_id, + record_version, + from_state, + to_state, + transition_digest, + created_at, + }; + transition.validate_shape()?; + Ok(transition) + } + + /// Revalidates the transition shape, identity, and canonical digest. + pub fn validate(&self) -> Result<(), ContractError> { + self.validate_shape()?; + if self.transition_digest != self.computed_digest()? { + return Err(ContractError::DigestMismatch { + schema: self.kind, + field: "transition_digest", + }); + } + Ok(()) + } + + fn computed_digest(&self) -> Result { + digest(&TransitionDigestInput { + kind: self.kind, + record_id: &self.record_id, + record_version: self.record_version, + from_state: &self.from_state, + to_state: &self.to_state, + created_at: self.created_at, + }) + } + + fn validate_shape(&self) -> Result<(), ContractError> { + let Some(expected) = self.kind.record_kind() else { + return Err(ContractError::InvalidShape { + schema: self.kind, + field: "kind", + }); + }; + if self.record_id.kind() != expected { + return Err(ContractError::WrongRecordKind { + schema: self.kind, + field: "record_id", + expected, + found: self.record_id.kind(), + }); + } + if self.record_version == 0 { + return Err(invalid(self.kind, "record_version")); + } + if self.record_version > 1 && self.from_state.is_none() { + return Err(invalid(self.kind, "from_state")); + } + if let Some(from_state) = &self.from_state { + validate_state(from_state, self.kind, "from_state")?; + if from_state == &self.to_state { + return Err(invalid(self.kind, "to_state")); + } + } + + validate_state(&self.to_state, self.kind, "to_state")?; + if self.created_at.offset() != time::UtcOffset::UTC { + return Err(invalid(self.kind, "created_at")); + } + Ok(()) + } +} + +impl Store { + /// Validates and appends one immutable transition. + pub fn append_transition(&mut self, transition: &Transition) -> Result<(), StoreError> { + transition.validate()?; + let sql_version = i64::try_from(transition.record_version) + .map_err(|_| StoreError::Contract(invalid(transition.kind, "record_version")))?; + let created_at = transition + .created_at + .format(&Rfc3339) + .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let latest: Option<(i64, String)> = transaction + .query_row( + " + SELECT record_version, to_state + FROM transitions + WHERE kind = ?1 AND record_id = ?2 + ORDER BY record_version DESC + LIMIT 1 + ", + params![ + records::kind_key(transition.kind), + transition.record_id.as_str() + ], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let valid_position = match latest { + None => transition.record_version == 1, + Some((previous_version, previous_state)) => { + let previous_version = + u64::try_from(previous_version).map_err(|_| StoreError::DatabaseOperation)?; + previous_version + .checked_add(1) + .is_some_and(|next| next == transition.record_version) + && transition.from_state.as_deref() == Some(previous_state.as_str()) + } + }; + if !valid_position { + return Err(transition_conflict(transition)); + } + + transaction.execute( + " + INSERT INTO transitions ( + kind, + record_id, + from_state, + to_state, + record_version, + transition_digest, + created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + params![ + records::kind_key(transition.kind), + transition.record_id.as_str(), + transition.from_state.as_deref(), + &transition.to_state, + sql_version, + transition.transition_digest.as_str(), + created_at, + ], + )?; + transaction.commit()?; + Ok(()) + } + + /// Counts all immutable transition rows. + pub fn count_transitions(&self) -> Result { + let count: i64 = + self.connection + .query_row("SELECT COUNT(*) FROM transitions", [], |row| row.get(0))?; + count.try_into().map_err(|_| StoreError::DatabaseOperation) + } +} + +fn validate_state( + value: &str, + schema: SchemaKind, + field: &'static str, +) -> Result<(), ContractError> { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= 64 + && bytes[0].is_ascii_lowercase() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'); + if valid { + Ok(()) + } else { + Err(invalid(schema, field)) + } +} + +fn invalid(schema: SchemaKind, field: &'static str) -> ContractError { + ContractError::InvalidShape { schema, field } +} + +fn transition_conflict(transition: &Transition) -> StoreError { + StoreError::TransitionConflict { + kind: transition.kind, + record_id: transition.record_id.clone(), + record_version: transition.record_version, + } +} diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs new file mode 100644 index 0000000..a79cb37 --- /dev/null +++ b/crates/psyche-store/tests/records.rs @@ -0,0 +1,1327 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::collections::BTreeMap; +use std::sync::{Arc, Barrier}; + +use psyche_core::contracts::error::{ErrorBody, ErrorCode, ErrorEnvelope}; +use psyche_core::contracts::execution::{ + AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, + CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, + TerminationRequestCorrelation, +}; +use psyche_core::contracts::surface::{ + DeliveryDecisionState, DeliveryRelationship, DeliveryState, DeliverySurfaceDecision, + DeliveryTopic, +}; +use psyche_core::contracts::{ + CanonicalDocument, ContractError, Delivery, Intent, RecordKind, SchemaKind, SchemaVersion, + VersionedRecord, +}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_store::{IngestOutcome, Store, StoreError, Transition}; +use serde::Serialize; +use serde_json::{Map, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +fn test_store() -> (Store, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("private").join("psyche.sqlite3")).unwrap(); + (store, dir) +} + +fn at(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn fixture_digest(character: char) -> Sha256Digest { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() +} + +fn fixture_other_digest() -> Sha256Digest { + fixture_digest('b') +} + +fn record_id(kind: RecordKind, suffix: &str) -> RecordId { + RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())).unwrap() +} + +fn fixture_attempt_id() -> RecordId { + record_id(RecordKind::Attempt, "01J00000000000000000000000") +} + +fn fixture_other_attempt_id() -> RecordId { + record_id(RecordKind::Attempt, "01J00000000000000000000001") +} + +fn fixture_snapshot_id() -> RecordId { + record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000002") +} + +fn fixture_other_snapshot_id() -> RecordId { + record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000003") +} + +fn fixture_project_id() -> String { + "project-a".to_owned() +} + +fn fixture_other_project_id() -> String { + "project-b".to_owned() +} + +fn fixture_request_id() -> RequestId { + RequestId::parse("req_01J00000000000000000000004").unwrap() +} + +fn fixture_termination_request_id() -> RequestId { + RequestId::parse("req_01J00000000000000000000005").unwrap() +} + +fn fixture_other_request_id() -> RequestId { + RequestId::parse("req_01J00000000000000000000006").unwrap() +} + +fn fixture_other_coven_contract_version() -> String { + "coven.v2".to_owned() +} + +fn fixture_intent(outcome: &str) -> Intent { + Intent { + schema_version: SchemaVersion::parse("psyche.intent.v1").unwrap(), + intent_id: record_id(RecordKind::Intent, "01J00000000000000000000007"), + principal_id: "principal-a".to_owned(), + familiar_snapshot_id: fixture_snapshot_id(), + project_id: fixture_project_id(), + requested_outcome: outcome.to_owned(), + constraints: Map::new(), + required_evidence: vec!["review".to_owned()], + surface_event_id: None, + created_at: at("2026-08-05T12:00:00Z"), + digest: fixture_digest('a'), + } +} + +fn fixture_intent_with_same_id(outcome: &str) -> Intent { + fixture_intent(outcome) +} + +fn fixture_error_envelope() -> ErrorEnvelope { + ErrorEnvelope { + schema_version: SchemaVersion::parse("psyche.error.v1").unwrap(), + error: ErrorBody { + code: ErrorCode::StorageUnavailable, + message: "unavailable".to_owned(), + retryable: true, + correlation_id: "correlation-a".to_owned(), + details: BTreeMap::new(), + }, + } +} + +fn fixture_delivery() -> Delivery { + let effect = json!({"method": "send_message", "text": "hello"}); + Delivery { + schema_version: SchemaVersion::parse("psyche.delivery.v1").unwrap(), + delivery_id: record_id(RecordKind::Delivery, "01J00000000000000000000008"), + intent_id: fixture_intent("deliver").intent_id, + action_class: "send_message".to_owned(), + account_id: "account-a".to_owned(), + chat_id: "-100123".to_owned(), + topic: DeliveryTopic { + kind: "telegram_topic".to_owned(), + id: "42".to_owned(), + }, + relationship: DeliveryRelationship::ReplySameTopic, + effect_digest: digest(&effect).unwrap(), + effect, + surface_decision: DeliverySurfaceDecision { + decision_id: "decision-a".to_owned(), + request_digest: fixture_digest('c'), + policy_revision: "policy-v1".to_owned(), + expires_at: at("2026-08-05T12:10:00Z"), + state: DeliveryDecisionState::Reserved, + }, + logical_response_id: "response-a".to_owned(), + logical_part: 0, + state: DeliveryState::Ready, + attempt_count: 0, + telegram_message_id: None, + } +} + +fn fixture_execution_binding_revision_1() -> ExecutionBinding { + ExecutionBinding { + schema_version: SchemaVersion::parse("psyche.execution_binding.v1").unwrap(), + attempt_id: fixture_attempt_id(), + revision: 1, + previous_revision_digest: None, + revision_created_at: at("2026-08-05T12:00:00Z"), + familiar_snapshot_id: fixture_snapshot_id(), + project_id: fixture_project_id(), + request_id: fixture_request_id(), + request_digest: fixture_digest('a'), + request_created_at: at("2026-08-05T11:59:00Z"), + request_valid_until: at("2026-08-05T12:05:00Z"), + coven_contract_version: "coven.v1".to_owned(), + coven_session_id: None, + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:1".to_owned()), + cancellation_state: CancellationState::NotRequested, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn fixture_execution_binding() -> ExecutionBinding { + fixture_execution_binding_revision_1() +} + +fn next_revision(previous: &ExecutionBinding) -> ExecutionBinding { + let mut next = previous.clone(); + next.revision += 1; + next.previous_revision_digest = Some(digest(previous).unwrap()); + next.revision_created_at += time::Duration::nanoseconds(1); + next +} + +fn fixture_next_not_requested_revision(previous: &ExecutionBinding) -> ExecutionBinding { + next_revision(previous) +} + +fn termination_request() -> TerminationRequestCorrelation { + TerminationRequestCorrelation { + termination_request_id: fixture_termination_request_id(), + created_at: at("2026-08-05T12:06:00Z"), + valid_until: at("2026-08-05T12:08:00Z"), + } +} + +fn fixture_termination_requested_revision(previous: &ExecutionBinding) -> ExecutionBinding { + let mut requested = next_revision(previous); + requested.coven_session_id = Some("session-a".to_owned()); + requested.cancellation_state = CancellationState::TerminationRequested; + requested.termination_request = Some(termination_request()); + requested.termination_reason_code = Some("operator_request".to_owned()); + requested +} + +fn fixture_next_termination_requested_revision(previous: &ExecutionBinding) -> ExecutionBinding { + next_revision(previous) +} + +fn fixture_not_requested_revision_after(previous: &ExecutionBinding) -> ExecutionBinding { + let mut next = next_revision(previous); + next.cancellation_state = CancellationState::NotRequested; + next.termination_request = None; + next.termination_reason_code = None; + next.cancellation_acknowledgement = None; + next.cancellation_unresolved = None; + next +} + +fn fixture_session_bound_revision(previous: &ExecutionBinding, session: &str) -> ExecutionBinding { + let mut next = next_revision(previous); + next.coven_session_id = Some(session.to_owned()); + next +} + +fn acknowledgement(binding: &ExecutionBinding) -> CancellationAcknowledgementEvidence { + let termination = binding.termination_request.as_ref().unwrap(); + CancellationAcknowledgementEvidence { + acknowledgement_id: "ack-a".to_owned(), + termination_request_id: termination.termination_request_id.clone(), + session_id: binding.coven_session_id.clone().unwrap(), + execution_request_id: binding.request_id.clone(), + execution_request_digest: binding.request_digest.clone(), + kind: CancellationAcknowledgementKind::Terminated, + authority_evidence_digest: fixture_digest('d'), + acknowledged_at: termination.created_at + time::Duration::seconds(30), + } +} + +fn unresolved(binding: &ExecutionBinding) -> CancellationUnresolvedEvidence { + let termination = binding.termination_request.as_ref().unwrap(); + CancellationUnresolvedEvidence { + disposition_id: "unresolved-a".to_owned(), + termination_request_id: termination.termination_request_id.clone(), + session_id: binding.coven_session_id.clone().unwrap(), + execution_request_id: binding.request_id.clone(), + execution_request_digest: binding.request_digest.clone(), + reason_code: "timeout".to_owned(), + recorded_at: termination.created_at + time::Duration::seconds(30), + } +} + +fn fixture_acknowledged_revision(previous: &ExecutionBinding) -> ExecutionBinding { + let mut acknowledged = next_revision(previous); + acknowledged.cancellation_state = CancellationState::AcknowledgedTerminated; + acknowledged.cancellation_acknowledgement = Some(acknowledgement(&acknowledged)); + acknowledged +} + +fn fixture_unresolved_revision(previous: &ExecutionBinding) -> ExecutionBinding { + let mut unresolved_binding = next_revision(previous); + unresolved_binding.cancellation_state = CancellationState::TerminationUnknown; + unresolved_binding.cancellation_unresolved = Some(unresolved(&unresolved_binding)); + unresolved_binding +} + +fn fixture_acknowledged_execution_binding() -> ExecutionBinding { + let mut binding = fixture_execution_binding(); + binding.coven_session_id = Some("session-a".to_owned()); + binding.cancellation_state = CancellationState::AcknowledgedTerminated; + binding.termination_request = Some(termination_request()); + binding.termination_reason_code = Some("operator_request".to_owned()); + binding.cancellation_acknowledgement = Some(acknowledgement(&binding)); + binding +} + +fn fixture_unresolved_execution_binding() -> ExecutionBinding { + let mut binding = fixture_execution_binding(); + binding.coven_session_id = Some("session-a".to_owned()); + binding.cancellation_state = CancellationState::TerminationUnknown; + binding.termination_request = Some(termination_request()); + binding.termination_reason_code = Some("operator_request".to_owned()); + binding.cancellation_unresolved = Some(unresolved(&binding)); + binding +} + +fn fixture_acknowledged_binding_after_execution_deadline() -> ExecutionBinding { + let mut binding = fixture_acknowledged_execution_binding(); + let termination = binding.termination_request.as_mut().unwrap(); + termination.created_at = binding.request_valid_until + time::Duration::seconds(1); + termination.valid_until = termination.created_at + time::Duration::minutes(1); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = termination.created_at; + binding +} + +fn transition(record_version: u64, from_state: Option<&str>, to_state: &str) -> Transition { + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + record_version, + from_state.map(str::to_owned), + to_state.to_owned(), + at("2026-08-05T12:00:00Z") + time::Duration::seconds(record_version as i64), + ) + .unwrap() +} + +#[test] +fn delivery_direct_insert_round_trips_canonically() { + let (mut store, _dir) = test_store(); + let delivery = fixture_delivery(); + let id = delivery.record_id().clone(); + let expected = CanonicalDocument::Delivery(delivery); + store.insert(&expected).unwrap(); + assert_eq!( + store.load(SchemaKind::Delivery, &id).unwrap(), + Some(expected.clone()) + ); + assert_eq!( + store + .load_canonical_bytes(SchemaKind::Delivery, &id) + .unwrap(), + Some(canonical_bytes(&expected).unwrap()) + ); +} + +#[test] +fn same_id_same_digest_is_idempotent_but_changed_payload_conflicts() { + let (mut store, _dir) = test_store(); + let intent = fixture_intent("Review A"); + store + .insert(&CanonicalDocument::Intent(intent.clone())) + .unwrap(); + store.insert(&CanonicalDocument::Intent(intent)).unwrap(); + let changed = fixture_intent_with_same_id("Review B"); + assert!(matches!( + store.insert(&CanonicalDocument::Intent(changed)), + Err(StoreError::RecordConflict { .. }) + )); + assert_eq!(store.total_record_count().unwrap(), 1); +} + +#[test] +fn direct_insert_rejects_wrong_field_id_kind_without_writing() { + let (mut store, _dir) = test_store(); + let mut intent = fixture_intent("Review A"); + intent.intent_id = + RecordId::parse(RecordKind::Graph, "grf_01J00000000000000000000000").unwrap(); + assert!(matches!( + store.insert(&CanonicalDocument::Intent(intent)), + Err(StoreError::Contract(ContractError::WrongRecordKind { .. })) + )); + assert_eq!(store.total_record_count().unwrap(), 0); +} + +#[test] +fn direct_insert_rejects_wrong_schema_without_writing() { + let (mut store, _dir) = test_store(); + let mut intent = fixture_intent("Review A"); + intent.schema_version = SchemaVersion::parse("psyche.graph.v1").unwrap(); + assert!(matches!( + store.insert(&CanonicalDocument::Intent(intent)), + Err(StoreError::Contract(ContractError::SchemaMismatch { .. })) + )); + assert_eq!(store.total_record_count().unwrap(), 0); +} + +#[test] +fn direct_insert_rejects_non_persistable_error_envelope() { + let (mut store, _dir) = test_store(); + assert!(matches!( + store.insert(&CanonicalDocument::Error(fixture_error_envelope())), + Err(StoreError::NonPersistableKind { + kind: SchemaKind::Error + }) + )); + assert_eq!(store.total_record_count().unwrap(), 0); +} + +#[test] +fn ingest_rejects_non_persistable_error_envelope() { + let (mut store, _dir) = test_store(); + let bytes = canonical_bytes(&fixture_error_envelope()).unwrap(); + assert!(matches!( + store.ingest(&bytes), + Err(StoreError::NonPersistableKind { + kind: SchemaKind::Error + }) + )); + assert_eq!(store.total_record_count().unwrap(), 0); +} + +#[test] +fn ingest_distinguishes_insert_from_exact_replay() { + let (mut store, _dir) = test_store(); + let bytes = canonical_bytes(&fixture_intent("Review A")).unwrap(); + assert_eq!(store.ingest(&bytes).unwrap(), IngestOutcome::Inserted); + assert_eq!(store.ingest(&bytes).unwrap(), IngestOutcome::AlreadyPresent); +} + +#[test] +fn load_helpers_reject_non_persistable_error_before_querying() { + let (store, _dir) = test_store(); + let id = fixture_attempt_id(); + assert!(matches!( + store.load(SchemaKind::Error, &id), + Err(StoreError::NonPersistableKind { + kind: SchemaKind::Error + }) + )); + assert!(matches!( + store.load_canonical_bytes(SchemaKind::Error, &id), + Err(StoreError::NonPersistableKind { + kind: SchemaKind::Error + }) + )); +} + +#[test] +fn direct_insert_rejects_acknowledged_cancellation_without_evidence() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + binding.cancellation_acknowledgement = None; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_acknowledged_state_without_termination_correlation() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + binding.termination_request = None; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_mismatched_cancellation_evidence() { + let baseline = fixture_acknowledged_execution_binding(); + let mut cases = Vec::new(); + + let mut changed = baseline.clone(); + changed + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_digest = fixture_other_digest(); + cases.push(changed); + + let mut changed = baseline.clone(); + changed + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_id = fixture_other_request_id(); + cases.push(changed); + + let mut changed = baseline.clone(); + changed + .cancellation_acknowledgement + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + cases.push(changed); + + let mut changed = baseline.clone(); + changed + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = "session-b".to_owned(); + cases.push(changed); + + let mut changed = baseline.clone(); + changed.cancellation_acknowledgement.as_mut().unwrap().kind = + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + cases.push(changed); + + let mut changed = baseline.clone(); + changed.cancellation_unresolved = Some(unresolved(&changed)); + cases.push(changed); + + let mut changed = baseline; + changed.cancellation_state = CancellationState::TerminationUnknown; + cases.push(changed); + + for binding in cases { + let (mut store, _dir) = test_store(); + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn direct_insert_rejects_wrong_termination_request_id() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_termination_before_execution_request() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + let before_execution = binding.request_created_at - time::Duration::nanoseconds(1); + binding.termination_request.as_mut().unwrap().created_at = before_execution; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_acknowledgement_outside_termination_window() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + let after_deadline = + binding.termination_request.as_ref().unwrap().valid_until + time::Duration::nanoseconds(1); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = after_deadline; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_acknowledgement_before_termination_window() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + let before_start = + binding.termination_request.as_ref().unwrap().created_at - time::Duration::nanoseconds(1); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = before_start; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_unresolved_outside_termination_window() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_unresolved_execution_binding(); + let after_deadline = + binding.termination_request.as_ref().unwrap().valid_until + time::Duration::nanoseconds(1); + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = after_deadline; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_rejects_unresolved_before_termination_window() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_unresolved_execution_binding(); + let before_start = + binding.termination_request.as_ref().unwrap().created_at - time::Duration::nanoseconds(1); + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = before_start; + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn direct_insert_accepts_acknowledgement_at_termination_window_boundaries() { + for use_deadline in [false, true] { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + let termination = binding.termination_request.as_ref().unwrap(); + let evidence_time = if use_deadline { + termination.valid_until + } else { + termination.created_at + }; + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = evidence_time; + let attempt_id = binding.attempt_id.clone(); + store + .insert(&CanonicalDocument::ExecutionBinding(binding)) + .unwrap(); + assert_eq!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn direct_insert_accepts_unresolved_at_termination_window_boundaries() { + for use_deadline in [false, true] { + let (mut store, _dir) = test_store(); + let mut binding = fixture_unresolved_execution_binding(); + let termination = binding.termination_request.as_ref().unwrap(); + let evidence_time = if use_deadline { + termination.valid_until + } else { + termination.created_at + }; + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = evidence_time; + let attempt_id = binding.attempt_id.clone(); + store + .insert(&CanonicalDocument::ExecutionBinding(binding)) + .unwrap(); + assert_eq!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn direct_insert_accepts_termination_window_after_execution_deadline() { + let (mut store, _dir) = test_store(); + let binding = fixture_acknowledged_binding_after_execution_deadline(); + assert!(binding.termination_request.as_ref().unwrap().created_at > binding.request_valid_until); + let attempt_id = binding.attempt_id.clone(); + store + .insert(&CanonicalDocument::ExecutionBinding(binding)) + .unwrap(); + assert_eq!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn direct_insert_accepts_termination_at_execution_creation_boundary() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_acknowledged_execution_binding(); + let execution_created_at = binding.request_created_at; + binding.termination_request.as_mut().unwrap().created_at = execution_created_at; + let attempt_id = binding.attempt_id.clone(); + store + .insert(&CanonicalDocument::ExecutionBinding(binding)) + .unwrap(); + assert_eq!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn direct_insert_rejects_revision_u64_overflow_without_writing() { + let (mut store, _dir) = test_store(); + let mut binding = fixture_execution_binding_revision_1(); + binding.revision = u64::MAX; + binding.previous_revision_digest = Some(fixture_digest('a')); + let attempt_id = binding.attempt_id.clone(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(binding)), + Err(StoreError::Contract(_)) + )); + assert!( + store + .execution_binding_revisions(&attempt_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn execution_binding_revision_appends_termination_outcomes_without_record_conflict() { + let (mut acknowledged_store, _acknowledged_dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + acknowledged_store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + let requested = fixture_termination_requested_revision(&initial); + acknowledged_store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + let acknowledged = fixture_acknowledged_revision(&requested); + acknowledged_store + .insert(&CanonicalDocument::ExecutionBinding(acknowledged.clone())) + .unwrap(); + assert_eq!( + acknowledged_store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial.clone(), requested.clone(), acknowledged.clone()] + ); + assert_eq!( + acknowledged_store + .load(SchemaKind::ExecutionBinding, &initial.attempt_id) + .unwrap(), + Some(CanonicalDocument::ExecutionBinding(acknowledged)) + ); + + let (mut unresolved_store, _unresolved_dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + unresolved_store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + let requested = fixture_termination_requested_revision(&initial); + unresolved_store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + let unresolved = fixture_unresolved_revision(&requested); + unresolved_store + .insert(&CanonicalDocument::ExecutionBinding(unresolved.clone())) + .unwrap(); + assert_eq!( + unresolved_store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested, unresolved] + ); +} + +#[test] +fn execution_binding_revision_rejects_forks_gaps_and_changed_correlation() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + + let mut gap = fixture_termination_requested_revision(&initial); + gap.revision = 3; + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(gap)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + + let mut wrong_previous = fixture_termination_requested_revision(&initial); + wrong_previous.previous_revision_digest = Some(fixture_other_digest()); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(wrong_previous)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + + let mut changed_correlation = fixture_termination_requested_revision(&initial); + changed_correlation.request_digest = fixture_other_digest(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(changed_correlation)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial] + ); +} + +#[test] +fn execution_binding_revision_replay_is_idempotent() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + for revision in [&initial, &requested] { + store + .insert(&CanonicalDocument::ExecutionBinding((*revision).clone())) + .unwrap(); + } + for revision in [&initial, &requested] { + store + .insert(&CanonicalDocument::ExecutionBinding((*revision).clone())) + .unwrap(); + } + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested] + ); +} + +#[test] +fn execution_binding_revision_rejects_same_revision_changed_bytes() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + for revision in [&initial, &requested] { + let mut changed = (*revision).clone(); + changed.event_cursor = Some("cursor:changed".to_owned()); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(changed)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + } + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested] + ); +} + +#[test] +fn execution_binding_revision_rejects_changed_reason_replay() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + let mut changed_reason = requested.clone(); + changed_reason.termination_reason_code = Some("different_reason".to_owned()); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(changed_reason)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested] + ); +} + +fn assert_next_revision_conflict(mutate: impl FnOnce(&mut ExecutionBinding)) { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + let mut candidate = fixture_next_not_requested_revision(&initial); + mutate(&mut candidate); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(candidate)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial] + ); +} + +#[test] +fn execution_binding_revision_rejects_every_frozen_execution_field_change() { + assert_next_revision_conflict(|revision| { + revision.attempt_id = fixture_other_attempt_id(); + }); + assert_next_revision_conflict(|revision| { + revision.familiar_snapshot_id = fixture_other_snapshot_id(); + }); + assert_next_revision_conflict(|revision| { + revision.project_id = fixture_other_project_id(); + }); + assert_next_revision_conflict(|revision| { + revision.request_id = fixture_other_request_id(); + }); + assert_next_revision_conflict(|revision| { + revision.request_digest = fixture_other_digest(); + }); + assert_next_revision_conflict(|revision| { + revision.request_created_at += time::Duration::nanoseconds(1); + }); + assert_next_revision_conflict(|revision| { + revision.request_valid_until += time::Duration::nanoseconds(1); + }); + assert_next_revision_conflict(|revision| { + revision.coven_contract_version = fixture_other_coven_contract_version(); + }); +} + +#[test] +fn execution_binding_revision_rejects_session_and_termination_rebinding() { + let (mut session_store, _session_dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let bound = fixture_session_bound_revision(&initial, "session-a"); + session_store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + session_store + .insert(&CanonicalDocument::ExecutionBinding(bound.clone())) + .unwrap(); + let rebound = fixture_session_bound_revision(&bound, "session-b"); + assert!(matches!( + session_store.insert(&CanonicalDocument::ExecutionBinding(rebound)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + let mut cleared = fixture_next_not_requested_revision(&bound); + cleared.coven_session_id = None; + assert!(matches!( + session_store.insert(&CanonicalDocument::ExecutionBinding(cleared)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + session_store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, bound] + ); + + let (mut termination_store, _termination_dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + termination_store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + termination_store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + let mut changed_id = fixture_next_termination_requested_revision(&requested); + changed_id + .termination_request + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + assert!(matches!( + termination_store.insert(&CanonicalDocument::ExecutionBinding(changed_id)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + let mut changed_created_at = fixture_next_termination_requested_revision(&requested); + changed_created_at + .termination_request + .as_mut() + .unwrap() + .created_at += time::Duration::nanoseconds(1); + assert!(matches!( + termination_store.insert(&CanonicalDocument::ExecutionBinding(changed_created_at)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + let mut changed_valid_until = fixture_next_termination_requested_revision(&requested); + changed_valid_until + .termination_request + .as_mut() + .unwrap() + .valid_until += time::Duration::nanoseconds(1); + assert!(matches!( + termination_store.insert(&CanonicalDocument::ExecutionBinding(changed_valid_until)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + let mut changed_reason = fixture_next_termination_requested_revision(&requested); + changed_reason.termination_reason_code = Some("different_reason".to_owned()); + assert!(matches!( + termination_store.insert(&CanonicalDocument::ExecutionBinding(changed_reason)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + termination_store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested] + ); +} + +#[test] +fn execution_binding_revision_rejects_termination_correlation_removal() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + let cleared = fixture_not_requested_revision_after(&requested); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(cleared)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial, requested] + ); +} + +#[test] +fn execution_binding_revision_rejects_timestamp_regression() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + for non_increasing in [ + initial.revision_created_at, + initial.revision_created_at - time::Duration::nanoseconds(1), + ] { + let mut regressed = fixture_termination_requested_revision(&initial); + regressed.revision_created_at = non_increasing; + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(regressed)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + } + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial] + ); +} + +#[test] +fn concurrent_execution_binding_forks_have_one_durable_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + let mut first = Store::open(&path).unwrap(); + let initial = fixture_execution_binding_revision_1(); + first + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + let second = Store::open(&path).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + + let left = fixture_termination_requested_revision(&initial); + let mut right = fixture_termination_requested_revision(&initial); + right.event_cursor = Some("cursor:competing".to_owned()); + + let left_barrier = Arc::clone(&barrier); + let left_thread = std::thread::spawn(move || { + let mut store = first; + left_barrier.wait(); + store.insert(&CanonicalDocument::ExecutionBinding(left)) + }); + let right_barrier = Arc::clone(&barrier); + let right_thread = std::thread::spawn(move || { + let mut store = second; + right_barrier.wait(); + store.insert(&CanonicalDocument::ExecutionBinding(right)) + }); + + let results = [left_thread.join().unwrap(), right_thread.join().unwrap()]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )) + .count(), + 1 + ); + let store = Store::open(&path).unwrap(); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn transition_versions_are_monotonic_and_append_only() { + let (mut store, _dir) = test_store(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + assert!(matches!( + store.append_transition(&transition(1, None, "running")), + Err(StoreError::TransitionConflict { .. }) + )); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +#[test] +fn transition_validation_rejects_wrong_id_kind_and_digest_without_writing() { + let (mut store, _dir) = test_store(); + let mut wrong_kind = transition(1, None, "admitted"); + wrong_kind.record_id = + RecordId::parse(RecordKind::Intent, "int_01J00000000000000000000000").unwrap(); + assert!(matches!( + store.append_transition(&wrong_kind), + Err(StoreError::Contract(ContractError::WrongRecordKind { .. })) + )); + + let mut wrong_digest = transition(1, None, "admitted"); + wrong_digest.transition_digest = fixture_digest('f'); + assert!(matches!( + store.append_transition(&wrong_digest), + Err(StoreError::Contract(ContractError::DigestMismatch { .. })) + )); + assert_eq!(store.count_transitions().unwrap(), 0); +} + +#[test] +fn transition_append_requires_exact_version_and_prior_state() { + let (mut store, _dir) = test_store(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + assert!(matches!( + store.append_transition(&transition(3, Some("admitted"), "running")), + Err(StoreError::TransitionConflict { .. }) + )); + assert!(matches!( + store.append_transition(&transition(2, Some("draft"), "running")), + Err(StoreError::TransitionConflict { .. }) + )); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +#[test] +fn transition_contract_rejects_invalid_states_and_version_overflow_without_writing() { + let (store, _dir) = test_store(); + let invalid = [ + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + 1, + Some("prior".to_owned()), + "UPPER".to_owned(), + at("2026-08-05T12:00:00Z"), + ), + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + 2, + None, + "running".to_owned(), + at("2026-08-05T12:00:00Z"), + ), + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + 2, + Some("running".to_owned()), + "running".to_owned(), + at("2026-08-05T12:00:00Z"), + ), + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + u64::MAX, + Some("running".to_owned()), + "done".to_owned(), + at("2026-08-05T12:00:00Z"), + ), + ]; + assert!(invalid.into_iter().all(|result| result.is_err())); + assert_eq!(store.count_transitions().unwrap(), 0); +} + +#[derive(Serialize)] +struct ExpectedTransitionDigestInput<'a> { + kind: SchemaKind, + record_id: &'a RecordId, + record_version: u64, + from_state: &'a Option, + to_state: &'a str, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, +} + +#[test] +fn transition_digest_uses_the_exact_owned_canonical_contract() { + let transition = transition(2, Some("admitted"), "running"); + assert_eq!( + transition.transition_digest, + digest(&ExpectedTransitionDigestInput { + kind: transition.kind, + record_id: &transition.record_id, + record_version: transition.record_version, + from_state: &transition.from_state, + to_state: &transition.to_state, + created_at: transition.created_at, + }) + .unwrap() + ); + transition.validate().unwrap(); +} + +proptest::proptest! { + #[test] + fn reinsertion_never_changes_stored_bytes(outcome in "[a-zA-Z0-9 ]{1,80}") { + let (mut store, _dir) = test_store(); + let intent = fixture_intent(&outcome); + let id = intent.record_id().clone(); + let before = canonical_bytes(&intent).unwrap(); + store.insert(&CanonicalDocument::Intent(intent.clone())).unwrap(); + store.insert(&CanonicalDocument::Intent(intent)).unwrap(); + let after = store.load_canonical_bytes(SchemaKind::Intent, &id).unwrap().unwrap(); + proptest::prop_assert_eq!(before, after); + } +} From 44feeb03f5a06368156afa987f865220409fbbc0 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:21:44 -0500 Subject: [PATCH 22/66] fix(store): fail closed across database recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 246 ++++++++++++++++++++-- crates/psyche-store/src/lib.rs | 21 +- crates/psyche-store/tests/migrations.rs | 249 ++++++++++++++++++++++- crates/psyche-store/tests/support/mod.rs | 40 +++- 4 files changed, 528 insertions(+), 28 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 6c98d86..2968871 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,6 +1,6 @@ use std::{ - fs::{self, OpenOptions}, - io::ErrorKind, + fs::{self, File, OpenOptions}, + io::{BufReader, ErrorKind, Read}, path::{Path, PathBuf}, thread, time::{Duration, Instant}, @@ -13,19 +13,65 @@ use crate::StoreError; const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); +const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0"; +const SQLITE_HEADER_SIZE: usize = 100; +const WAL_HEADER_SIZE: usize = 32; +const WAL_FRAME_HEADER_SIZE: usize = 24; +const WAL_FORMAT_VERSION: u32 = 3_007_000; +const WAL_MAGIC: u32 = 0x377f_0682; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DatabaseFileState { + Existing, + Created, +} -pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { +pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; - prepare_database_file(path)?; + let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; + Ok((open_path, state)) +} +pub(crate) fn open_read_only(path: &Path) -> Result { + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = Connection::open_with_flags(path, flags)?; + connection.busy_timeout(BUSY_TIMEOUT)?; + Ok(connection) +} + +pub(crate) fn open_read_write(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(&open_path, flags)?; + let connection = Connection::open_with_flags(path, flags)?; connection.busy_timeout(BUSY_TIMEOUT)?; - Ok((connection, open_path)) + Ok(connection) +} + +pub(crate) fn file_user_version(path: &Path) -> Result, StoreError> { + // A read-only WAL query may update reader marks in `-shm`. Read committed + // page-one frames first so a future schema can be rejected without that. + let [journal_path, wal_path, _] = sqlite_sidecar_paths(path); + if existing_file_len(&journal_path)?.is_some_and(|len| len > 0) { + return Ok(None); + } + + let Some((main_version, page_size)) = main_file_header(path)? else { + return Ok(None); + }; + if page_size == 0 { + return Ok(Some(main_version)); + } + + match wal_file_user_version(&wal_path, page_size)? { + WalFileVersion::Absent => Ok(Some(main_version)), + WalFileVersion::Invalid => Ok(None), + WalFileVersion::Valid(version) => Ok(Some(version.unwrap_or(main_version))), + } } pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { @@ -149,6 +195,162 @@ fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { }) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WalFileVersion { + Absent, + Invalid, + Valid(Option), +} + +fn existing_file_len(path: &Path) -> Result, StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + Ok(Some(metadata.len())) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn main_file_header(path: &Path) -> Result, StoreError> { + let mut file = File::open(path).map_err(StoreError::file_operation)?; + let len = file.metadata().map_err(StoreError::file_operation)?.len(); + if len == 0 { + return Ok(Some((0, 0))); + } + if len < SQLITE_HEADER_SIZE as u64 { + return Ok(None); + } + + let mut header = [0_u8; SQLITE_HEADER_SIZE]; + file.read_exact(&mut header) + .map_err(StoreError::file_operation)?; + if &header[..SQLITE_HEADER.len()] != SQLITE_HEADER { + return Ok(None); + } + + let encoded_page_size = u16::from_be_bytes([header[16], header[17]]); + let page_size = if encoded_page_size == 1 { + 65_536 + } else { + u32::from(encoded_page_size) + }; + if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { + return Ok(None); + } + + Ok(Some((read_u32_be(&header[60..64]), page_size))) +} + +fn wal_file_user_version( + path: &Path, + expected_page_size: u32, +) -> Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(WalFileVersion::Absent); + } + Err(error) => return Err(StoreError::file_operation(error)), + }; + if file.metadata().map_err(StoreError::file_operation)?.len() < WAL_HEADER_SIZE as u64 { + return Ok(WalFileVersion::Absent); + } + + let mut reader = BufReader::new(file); + let mut header = [0_u8; WAL_HEADER_SIZE]; + reader + .read_exact(&mut header) + .map_err(StoreError::file_operation)?; + let magic = read_u32_be(&header[..4]); + let page_size = read_u32_be(&header[8..12]); + if magic & !1 != WAL_MAGIC + || read_u32_be(&header[4..8]) != WAL_FORMAT_VERSION + || page_size != expected_page_size + { + return Ok(WalFileVersion::Invalid); + } + + let checksum_big_endian = magic & 1 == 1; + let mut checksum = [0_u32; 2]; + extend_wal_checksum(&header[..24], checksum_big_endian, &mut checksum); + if checksum != [read_u32_be(&header[24..28]), read_u32_be(&header[28..])] { + return Ok(WalFileVersion::Invalid); + } + + let salt = &header[16..24]; + let mut frame_header = [0_u8; WAL_FRAME_HEADER_SIZE]; + let mut page = vec![0_u8; page_size as usize]; + let mut pending_page_one = None; + let mut committed_page_one = None; + loop { + if !read_exact_frame_part(&mut reader, &mut frame_header)? + || !read_exact_frame_part(&mut reader, &mut page)? + { + break; + } + if read_u32_be(&frame_header[..4]) == 0 || &frame_header[8..16] != salt { + break; + } + + let mut frame_checksum = checksum; + extend_wal_checksum(&frame_header[..8], checksum_big_endian, &mut frame_checksum); + extend_wal_checksum(&page, checksum_big_endian, &mut frame_checksum); + if frame_checksum + != [ + read_u32_be(&frame_header[16..20]), + read_u32_be(&frame_header[20..]), + ] + { + break; + } + checksum = frame_checksum; + + if read_u32_be(&frame_header[..4]) == 1 { + pending_page_one = Some(read_u32_be(&page[60..64])); + } + if read_u32_be(&frame_header[4..8]) != 0 { + if let Some(version) = pending_page_one.take() { + committed_page_one = Some(version); + } + } + } + + Ok(WalFileVersion::Valid(committed_page_one)) +} + +fn read_exact_frame_part(reader: &mut impl Read, buffer: &mut [u8]) -> Result { + match reader.read_exact(buffer) { + Ok(()) => Ok(true), + Err(error) if error.kind() == ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn extend_wal_checksum(bytes: &[u8], big_endian: bool, checksum: &mut [u32; 2]) { + debug_assert_eq!(bytes.len() % 8, 0); + for words in bytes.chunks_exact(8) { + let first = read_checksum_word(&words[..4], big_endian); + checksum[0] = checksum[0].wrapping_add(first).wrapping_add(checksum[1]); + let second = read_checksum_word(&words[4..], big_endian); + checksum[1] = checksum[1].wrapping_add(second).wrapping_add(checksum[0]); + } +} + +fn read_checksum_word(bytes: &[u8], big_endian: bool) -> u32 { + let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; + if big_endian { + u32::from_be_bytes(bytes) + } else { + u32::from_le_bytes(bytes) + } +} + +fn read_u32_be(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, @@ -213,6 +415,16 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(StoreError::InvalidDatabasePath); } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(StoreError::InvalidDatabasePath); + } + } + Ok(()) } @@ -234,21 +446,24 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_database_file(path: &Path) -> Result<(), StoreError> { - match fs::symlink_metadata(path) { - Ok(metadata) => validate_database_metadata(&metadata)?, +fn prepare_database_file(path: &Path) -> Result { + let state = match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + DatabaseFileState::Existing + } Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { - Ok(()) => {} - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Ok(()) => DatabaseFileState::Created, + Err(error) if error.kind() == ErrorKind::AlreadyExists => DatabaseFileState::Existing, Err(error) => return Err(StoreError::file_operation(error)), }, Err(error) => return Err(StoreError::file_operation(error)), - } + }; let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - Ok(()) + Ok(state) } fn database_open_path(path: &Path) -> Result { @@ -287,14 +502,15 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open}; + use super::{configure, open_read_write, prepare}; #[test] fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let (connection, _) = open(&path).unwrap(); + let (path, _) = prepare(&path).unwrap(); + let connection = open_read_write(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index e2ae267..6981bb9 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -32,20 +32,31 @@ impl Store { pub fn open(path: &Path) -> Result { let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); let _initialization_guard = initialization_guard(initialization_lock)?; - let (mut connection, database_path) = connection::open(path)?; + let (database_path, file_state) = connection::prepare(path)?; + connection::validate_sidecars(&database_path)?; - let found = - match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { + if file_state == connection::DatabaseFileState::Existing { + let preflight = connection::open_read_only(&database_path)?; + if let Some(found) = connection::file_user_version(&database_path)? { + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + } + let found = match preflight + .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + { Ok(found) => found, Err(error) => { connection::validate_sidecars(&database_path)?; return Err(error.into()); } }; - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } } + let mut connection = connection::open_read_write(&database_path)?; connection::enforce_database_permissions(&database_path)?; connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index f4a2571..ffc5158 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -120,6 +120,121 @@ fn future_database_version_fails_before_any_migration() { assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } +#[cfg(unix)] +#[test] +fn crash_left_wal_future_version_is_rejected_without_mutating_any_database_file() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + + run_crash_helper(&path, "wal-v99"); + + let sidecars = sqlite_sidecar_paths(&path); + let wal_path = &sidecars[1]; + let shm_path = &sidecars[2]; + assert!(wal_path.exists()); + assert!(shm_path.exists()); + assert_eq!(database_header_user_version(&path), 1); + + for file in [&path, wal_path, shm_path] { + set_mode(file, 0o644); + } + let before = [ + snapshot_file(&path), + snapshot_file(wal_path), + snapshot_file(shm_path), + ]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!( + &error, + StoreError::UnsupportedDatabaseVersion { found: 99, .. } + ), + "unexpected error: {error:?}" + ); + assert_eq!( + error.to_string(), + "unsupported database version 99; maximum supported version is 1" + ); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("WAL", &before[1], &snapshot_file(wal_path)); + assert_snapshot_unchanged("shared memory", &before[2], &snapshot_file(shm_path)); +} + +#[cfg(unix)] +#[test] +fn hot_journal_read_only_failure_does_not_recover_or_open_read_write() { + use std::error::Error; + + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + execute_batch( + &path, + " + CREATE TABLE hot_journal_seed ( + id INTEGER PRIMARY KEY, + payload BLOB NOT NULL + ) STRICT; + WITH RECURSIVE counter(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM counter WHERE value < 256 + ) + INSERT INTO hot_journal_seed (id, payload) + SELECT value, zeroblob(4096) FROM counter; + ", + ); + + run_crash_helper(&path, "hot-journal"); + + let sidecars = sqlite_sidecar_paths(&path); + let journal_path = &sidecars[0]; + let journal_contents = std::fs::read(journal_path).unwrap(); + assert!(journal_contents.len() > 512); + assert!(journal_contents[..8].iter().any(|byte| *byte != 0)); + set_mode(&path, 0o644); + set_mode(journal_path, 0o644); + let before = [snapshot_file(&path), snapshot_file(journal_path)]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!(&error, StoreError::DatabaseOperation), + "unexpected error: {error:?}" + ); + assert_eq!(error.to_string(), "store database operation failed"); + assert_eq!( + format!("{error:?}"), + "StoreError(store database operation failed)" + ); + assert!(error.source().is_none()); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("rollback journal", &before[1], &snapshot_file(journal_path)); +} + +#[test] +fn partially_applied_v1_transaction_rolls_back_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); + + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); + assert!(!table_exists(&path, "canonical_records")); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), 1); + drop(reopened); + assert_eq!(schema_migrations(&path).len(), 1); +} + #[test] fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); @@ -188,6 +303,8 @@ fn concurrent_first_open_applies_migration_once() { const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + set_mode(dir.path(), 0o700); for round in 0..ROUNDS { let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); let barrier = Arc::new(Barrier::new(THREADS)); @@ -327,24 +444,43 @@ fn future_database_sidecar_permissions_are_unchanged() { #[cfg(unix)] #[test] -fn existing_shared_parent_permissions_are_preserved() { +fn existing_shared_parent_is_rejected_without_changes() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, []).unwrap(); + std::fs::write(&path, b"not-a-database").unwrap(); set_mode(&parent, 0o755); set_mode(&path, 0o755); - drop(Store::open(&path).unwrap()); + assert_invalid_database_path(&path); assert_eq!(mode(&parent), 0o755); + assert_eq!(mode(&path), 0o755); + assert_eq!(std::fs::read(&path).unwrap(), b"not-a-database"); +} + +#[cfg(unix)] +#[test] +fn existing_private_parent_is_accepted_without_changing_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("existing"); + let path = parent.join("psyche.sqlite3"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(&path, []).unwrap(); + set_mode(&parent, 0o700); + set_mode(&path, 0o600); + + drop(Store::open(&path).unwrap()); + + assert_eq!(mode(&parent), 0o700); assert_private_file(&path); + assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); } #[cfg(unix)] #[test] -fn relative_filename_preserves_current_directory_permissions() { +fn relative_filename_rejects_shared_current_directory_without_changes() { use std::process::Command; let dir = tempfile::tempdir().unwrap(); @@ -363,7 +499,7 @@ fn relative_filename_preserves_current_directory_permissions() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(mode(dir.path()), 0o755); - assert_private_file(&dir.path().join("psyche.sqlite3")); + assert!(!dir.path().join("psyche.sqlite3").exists()); } #[cfg(unix)] @@ -373,7 +509,55 @@ fn relative_filename_open_helper() { return; } - drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); + assert_invalid_database_path(Path::new("psyche.sqlite3")); +} + +#[cfg(unix)] +#[test] +fn crash_left_database_helper() { + let Some(helper) = std::env::var_os("PSYCHE_STORE_CRASH_HELPER") else { + return; + }; + let path = PathBuf::from( + std::env::var_os("PSYCHE_STORE_CRASH_HELPER_PATH") + .unwrap_or_else(|| panic!("crash helper database path is missing")), + ); + let connection = Connection::open(&path).unwrap(); + + match helper.to_str() { + Some("wal-v99") => connection + .execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + PRAGMA synchronous = FULL; + BEGIN IMMEDIATE; + CREATE TABLE wal_future_marker ( + value TEXT NOT NULL + ) STRICT; + INSERT INTO wal_future_marker (value) VALUES ('future-in-wal'); + PRAGMA user_version = 99; + COMMIT; + ", + ) + .unwrap(), + Some("hot-journal") => connection + .execute_batch( + " + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA cache_size = 1; + PRAGMA cache_spill = ON; + BEGIN IMMEDIATE; + UPDATE hot_journal_seed + SET payload = randomblob(4096); + ", + ) + .unwrap(), + _ => panic!("unknown crash helper mode"), + } + + std::process::exit(0); } #[cfg(unix)] @@ -399,6 +583,7 @@ fn symlink_database_is_rejected_without_mutating_its_target() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); + set_mode(dir.path(), 0o700); let target = dir.path().join("target.sqlite3"); let path = dir.path().join("linked.sqlite3"); std::fs::write(&target, []).unwrap(); @@ -414,6 +599,58 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } +#[cfg(unix)] +fn run_crash_helper(path: &Path, helper: &str) { + use std::process::Command; + + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "crash_left_database_helper", "--nocapture"]) + .env("PSYCHE_STORE_CRASH_HELPER", helper) + .env("PSYCHE_STORE_CRASH_HELPER_PATH", path) + .output() + .unwrap(); + assert!( + output.status.success(), + "crash helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[derive(Debug, Eq, PartialEq)] +struct FileSnapshot { + contents: Vec, + len: u64, + modified: std::time::SystemTime, + mode: u32, +} + +#[cfg(unix)] +fn snapshot_file(path: &Path) -> FileSnapshot { + let contents = std::fs::read(path).unwrap(); + let metadata = std::fs::metadata(path).unwrap(); + FileSnapshot { + contents, + len: metadata.len(), + modified: metadata.modified().unwrap(), + mode: mode(path), + } +} + +#[cfg(unix)] +fn assert_snapshot_unchanged(label: &str, before: &FileSnapshot, after: &FileSnapshot) { + assert_eq!(after.len, before.len, "{label} length changed"); + assert_eq!(after.modified, before.modified, "{label} mtime changed"); + assert_eq!(after.mode, before.mode, "{label} mode changed"); + assert_eq!(after.contents, before.contents, "{label} contents changed"); +} + +#[cfg(unix)] +fn database_header_user_version(path: &Path) -> u32 { + let contents = std::fs::read(path).unwrap(); + u32::from_be_bytes(contents[60..64].try_into().unwrap()) +} + fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { sqlite_sidecar_paths(path) .into_iter() diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index aba1ff2..b2f8276 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::Connection; +use rusqlite::{Connection, TransactionBehavior}; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,18 +15,27 @@ pub(super) enum Fixture { Version0, Version1, Version99, + PartiallyAppliedV1, MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let name = match fixture { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", + Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let connection = Connection::open(&path).unwrap(); + let mut connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -64,6 +73,33 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), + Fixture::PartiallyAppliedV1 => { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Exclusive) + .unwrap(); + transaction + .execute_batch( + " + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) STRICT; + CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) + ) STRICT; + PRAGMA user_version = 1; + ", + ) + .unwrap(); + drop(transaction); + } Fixture::MigrationConflictV1 => connection .execute_batch( " From e64751a8a777f9c1e7d1810e2ff13b76d1b23503 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:41:27 -0500 Subject: [PATCH 23/66] fix(store): validate persisted revision chains Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/error.rs | 4 + crates/psyche-store/src/execution_bindings.rs | 299 ++++++----- crates/psyche-store/src/records.rs | 121 +++-- crates/psyche-store/src/transitions.rs | 59 ++- crates/psyche-store/tests/records.rs | 493 ++++++++++++++++++ 5 files changed, 810 insertions(+), 166 deletions(-) diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index 15d14fa..fa08af1 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -66,6 +66,9 @@ pub enum StoreError { /// Conflicting one-based record version. record_version: u64, }, + /// Persisted rows failed canonical or revision-chain integrity validation. + #[error("stored database content failed integrity validation")] + DatabaseCorruption, /// Creating the store's parent directory failed. #[error("store directory operation failed")] DirectoryOperation, @@ -178,6 +181,7 @@ mod tests { record_id: id, record_version: 2, }, + StoreError::DatabaseCorruption, ]; for error in errors { diff --git a/crates/psyche-store/src/execution_bindings.rs b/crates/psyche-store/src/execution_bindings.rs index d2f06ad..d50de75 100644 --- a/crates/psyche-store/src/execution_bindings.rs +++ b/crates/psyche-store/src/execution_bindings.rs @@ -1,14 +1,26 @@ +use psyche_core::contracts::execution::CancellationState; use psyche_core::contracts::{CanonicalDocument, ContractError, ExecutionBinding, SchemaKind}; -use psyche_core::digest::{canonical_bytes, digest}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::RecordId; -use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; +use rusqlite::{Connection, TransactionBehavior, params}; use time::format_description::well_known::Rfc3339; use crate::records::InsertStatus; use crate::{Store, StoreError, records}; struct StoredRevision { - revision: u64, + attempt_id: String, + revision: i64, + schema_version: String, + digest: String, + previous_revision_digest: Option, + canonical_json: Vec, + created_at: String, +} + +struct ValidatedRevision { + binding: ExecutionBinding, + digest: Sha256Digest, canonical_json: Vec, } @@ -34,38 +46,21 @@ pub(crate) fn insert( .format(&Rfc3339) .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let stored = load_stored_revisions(&transaction, &binding.attempt_id)?; + let history = validate_revision_chain(stored, &binding.attempt_id)?; - let existing: Option> = transaction - .query_row( - " - SELECT canonical_json - FROM execution_binding_revisions - WHERE attempt_id = ?1 AND revision = ?2 - ", - params![binding.attempt_id.as_str(), sql_revision], - |row| row.get(0), - ) - .optional()?; - if let Some(existing) = existing { - if existing == canonical_json { + if let Some(existing) = history + .iter() + .find(|revision| revision.binding.revision == binding.revision) + { + if existing.canonical_json == canonical_json { transaction.commit()?; return Ok(InsertStatus::AlreadyPresent); } return Err(revision_conflict(binding)); } - let latest = latest_revision(&transaction, &binding.attempt_id)?; - match latest { - None => { - if binding.revision != 1 || binding.previous_revision_digest.is_some() { - return Err(revision_conflict(binding)); - } - } - Some(latest) => { - validate_next_revision(&transaction, binding, &latest)?; - } - } - + validate_next_revision(binding, &history)?; transaction.execute( " INSERT INTO execution_binding_revisions ( @@ -101,21 +96,11 @@ pub(crate) fn revisions( attempt_id: &RecordId, ) -> Result, StoreError> { records::validate_kind_id(SchemaKind::ExecutionBinding, attempt_id)?; - let mut statement = connection.prepare( - " - SELECT canonical_json - FROM execution_binding_revisions - WHERE attempt_id = ?1 - ORDER BY revision - ", - )?; - let canonical = statement - .query_map([attempt_id.as_str()], |row| row.get::<_, Vec>(0))? - .collect::>>()?; - canonical + let stored = load_stored_revisions(connection, attempt_id)?; + Ok(validate_revision_chain(stored, attempt_id)? .into_iter() - .map(|bytes| decode_binding(&bytes)) - .collect() + .map(|revision| revision.binding) + .collect()) } pub(crate) fn latest_canonical_bytes( @@ -123,45 +108,139 @@ pub(crate) fn latest_canonical_bytes( attempt_id: &RecordId, ) -> Result>, StoreError> { records::validate_kind_id(SchemaKind::ExecutionBinding, attempt_id)?; - connection - .query_row( - " - SELECT canonical_json - FROM execution_binding_revisions - WHERE attempt_id = ?1 - ORDER BY revision DESC - LIMIT 1 - ", - [attempt_id.as_str()], - |row| row.get(0), - ) - .optional() + let stored = load_stored_revisions(connection, attempt_id)?; + Ok(validate_revision_chain(stored, attempt_id)? + .pop() + .map(|revision| revision.canonical_json)) +} + +fn load_stored_revisions( + connection: &Connection, + attempt_id: &RecordId, +) -> Result, StoreError> { + let mut statement = connection.prepare( + " + SELECT + attempt_id, + revision, + schema_version, + digest, + previous_revision_digest, + canonical_json, + created_at + FROM execution_binding_revisions + WHERE attempt_id = ?1 + ORDER BY revision + ", + )?; + statement + .query_map([attempt_id.as_str()], |row| { + Ok(StoredRevision { + attempt_id: row.get(0)?, + revision: row.get(1)?, + schema_version: row.get(2)?, + digest: row.get(3)?, + previous_revision_digest: row.get(4)?, + canonical_json: row.get(5)?, + created_at: row.get(6)?, + }) + })? + .collect::>>() .map_err(Into::into) } +fn validate_revision_chain( + stored: Vec, + expected_attempt_id: &RecordId, +) -> Result, StoreError> { + let mut validated = Vec::with_capacity(stored.len()); + for row in stored { + let binding = decode_stored_binding(&row.canonical_json)?; + let canonical_json = + canonical_bytes(&binding).map_err(|_| StoreError::DatabaseCorruption)?; + let revision_digest = digest(&binding).map_err(|_| StoreError::DatabaseCorruption)?; + let revision = u64::try_from(row.revision).map_err(|_| StoreError::DatabaseCorruption)?; + let created_at = binding + .revision_created_at + .format(&Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + if row.attempt_id != expected_attempt_id.as_str() + || binding.attempt_id != *expected_attempt_id + || revision != binding.revision + || row.schema_version != binding.schema_version.to_string() + || row.digest != revision_digest.as_str() + || row.previous_revision_digest.as_deref() + != binding + .previous_revision_digest + .as_ref() + .map(Sha256Digest::as_str) + || row.canonical_json != canonical_json + || row.created_at != created_at + { + return Err(StoreError::DatabaseCorruption); + } + validated.push(ValidatedRevision { + binding, + digest: revision_digest, + canonical_json, + }); + } + + let Some(initial) = validated.first() else { + return Ok(validated); + }; + for (index, current) in validated.iter().enumerate() { + let expected_revision = u64::try_from(index) + .map_err(|_| StoreError::DatabaseCorruption)? + .checked_add(1) + .ok_or(StoreError::DatabaseCorruption)?; + if current.binding.revision != expected_revision { + return Err(StoreError::DatabaseCorruption); + } + if index == 0 { + if current.binding.previous_revision_digest.is_some() { + return Err(StoreError::DatabaseCorruption); + } + continue; + } + + let previous = &validated[index - 1]; + if current.binding.previous_revision_digest.as_ref() != Some(&previous.digest) + || current.binding.revision_created_at <= previous.binding.revision_created_at + || !frozen_execution_fields_match(&initial.binding, ¤t.binding) + || !session_binding_is_append_only(&previous.binding, ¤t.binding) + || !termination_binding_is_append_only(&previous.binding, ¤t.binding) + || !cancellation_binding_is_append_only(&previous.binding, ¤t.binding) + { + return Err(StoreError::DatabaseCorruption); + } + } + Ok(validated) +} + fn validate_next_revision( - transaction: &Transaction<'_>, binding: &ExecutionBinding, - latest: &StoredRevision, + history: &[ValidatedRevision], ) -> Result<(), StoreError> { + let Some(latest) = history.last() else { + if binding.revision == 1 && binding.previous_revision_digest.is_none() { + return Ok(()); + } + return Err(revision_conflict(binding)); + }; let expected_revision = latest + .binding .revision .checked_add(1) .ok_or_else(|| revision_conflict(binding))?; - let latest_binding = decode_binding(&latest.canonical_json)?; - let latest_digest = digest(&latest_binding)?; + let initial = &history[0].binding; if binding.revision != expected_revision - || binding.previous_revision_digest.as_ref() != Some(&latest_digest) - { - return Err(revision_conflict(binding)); - } - - let initial = - first_revision(transaction, &binding.attempt_id)?.ok_or(StoreError::DatabaseOperation)?; - if binding.revision_created_at <= latest_binding.revision_created_at - || !frozen_execution_fields_match(&initial, binding) - || !session_binding_is_append_only(&latest_binding, binding) - || !termination_binding_is_append_only(&latest_binding, binding) + || binding.previous_revision_digest.as_ref() != Some(&latest.digest) + || binding.revision_created_at <= latest.binding.revision_created_at + || !frozen_execution_fields_match(initial, binding) + || !session_binding_is_append_only(&latest.binding, binding) + || !termination_binding_is_append_only(&latest.binding, binding) + || !cancellation_binding_is_append_only(&latest.binding, binding) { return Err(revision_conflict(binding)); } @@ -200,60 +279,38 @@ fn termination_binding_is_append_only( } } -fn latest_revision( - transaction: &Transaction<'_>, - attempt_id: &RecordId, -) -> Result, StoreError> { - let stored = transaction - .query_row( - " - SELECT revision, canonical_json - FROM execution_binding_revisions - WHERE attempt_id = ?1 - ORDER BY revision DESC - LIMIT 1 - ", - [attempt_id.as_str()], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)), - ) - .optional()?; - stored - .map(|(revision, canonical_json)| { - let revision = u64::try_from(revision).map_err(|_| StoreError::DatabaseOperation)?; - Ok(StoredRevision { - revision, - canonical_json, - }) - }) - .transpose() -} - -fn first_revision( - transaction: &Transaction<'_>, - attempt_id: &RecordId, -) -> Result, StoreError> { - transaction - .query_row( - " - SELECT canonical_json - FROM execution_binding_revisions - WHERE attempt_id = ?1 AND revision = 1 - ", - [attempt_id.as_str()], - |row| row.get::<_, Vec>(0), - ) - .optional()? - .map(|bytes| decode_binding(&bytes)) - .transpose() +fn cancellation_binding_is_append_only( + latest: &ExecutionBinding, + candidate: &ExecutionBinding, +) -> bool { + match latest.cancellation_state { + CancellationState::NotRequested => matches!( + candidate.cancellation_state, + CancellationState::NotRequested | CancellationState::TerminationRequested + ), + CancellationState::TerminationRequested => matches!( + candidate.cancellation_state, + CancellationState::TerminationRequested + | CancellationState::AcknowledgedTerminated + | CancellationState::AcknowledgedAlreadyTerminal + | CancellationState::TerminationUnknown + ), + CancellationState::AcknowledgedTerminated + | CancellationState::AcknowledgedAlreadyTerminal + | CancellationState::TerminationUnknown => { + candidate.cancellation_state == latest.cancellation_state + && candidate.cancellation_acknowledgement == latest.cancellation_acknowledgement + && candidate.cancellation_unresolved == latest.cancellation_unresolved + } + } } -fn decode_binding(bytes: &[u8]) -> Result { - match psyche_core::contracts::decode_document(bytes)? { +fn decode_stored_binding(bytes: &[u8]) -> Result { + match psyche_core::contracts::decode_document(bytes) + .map_err(|_| StoreError::DatabaseCorruption)? + { CanonicalDocument::ExecutionBinding(binding) => Ok(binding), - document => Err(StoreError::Contract(ContractError::SchemaMismatch { - expected: SchemaKind::ExecutionBinding, - found: document.schema_version().kind, - })), + _ => Err(StoreError::DatabaseCorruption), } } diff --git a/crates/psyche-store/src/records.rs b/crates/psyche-store/src/records.rs index 57610a9..fd9db25 100644 --- a/crates/psyche-store/src/records.rs +++ b/crates/psyche-store/src/records.rs @@ -8,6 +8,14 @@ use rusqlite::{OptionalExtension, TransactionBehavior, params}; use crate::{Store, StoreError, execution_bindings}; +struct StoredCanonicalRecord { + kind: String, + record_id: String, + schema_version: String, + digest: String, + canonical_json: Vec, +} + /// Result of ingesting bytes at the store boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IngestOutcome { @@ -60,16 +68,7 @@ impl Store { let Some(bytes) = self.load_canonical_bytes(kind, id)? else { return Ok(None); }; - let document = decode_document(&bytes)?; - if document.schema_version().kind != kind { - return Err(StoreError::Contract(ContractError::SchemaMismatch { - expected: kind, - found: document.schema_version().kind, - })); - } - if document.persistable_record_id() != Some(id) { - return Err(StoreError::DatabaseOperation); - } + let document = decode_document(&bytes).map_err(|_| StoreError::DatabaseCorruption)?; Ok(Some(document)) } @@ -83,18 +82,11 @@ impl Store { if kind == SchemaKind::ExecutionBinding { return execution_bindings::latest_canonical_bytes(&self.connection, id); } - self.connection - .query_row( - " - SELECT canonical_json - FROM canonical_records - WHERE kind = ?1 AND record_id = ?2 - ", - params![kind_key(kind), id.as_str()], - |row| row.get(0), - ) - .optional() - .map_err(Into::into) + let Some(stored) = stored_canonical_record(&self.connection, kind, id)? else { + return Ok(None); + }; + validate_stored_canonical_record(&stored, kind, id)?; + Ok(Some(stored.canonical_json)) } /// Counts persisted logical records of one schema kind. @@ -155,6 +147,18 @@ impl Store { let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(stored) = stored_canonical_record(&transaction, kind, id)? { + validate_stored_canonical_record(&stored, kind, id)?; + if stored.canonical_json == bytes { + transaction.commit()?; + return Ok(InsertStatus::AlreadyPresent); + } + return Err(StoreError::RecordConflict { + kind, + record_id: id.clone(), + }); + } + transaction.execute( " INSERT INTO canonical_records ( @@ -166,7 +170,6 @@ impl Store { created_at ) VALUES (?1, ?2, ?3, ?4, ?5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ON CONFLICT DO NOTHING ", params![ kind_key(kind), @@ -176,28 +179,8 @@ impl Store { bytes, ], )?; - let stored_digest: String = transaction.query_row( - " - SELECT digest - FROM canonical_records - WHERE kind = ?1 AND record_id = ?2 - ", - params![kind_key(kind), id.as_str()], - |row| row.get(0), - )?; - if stored_digest != record_digest.as_str() { - return Err(StoreError::RecordConflict { - kind, - record_id: id.clone(), - }); - } - let status = if transaction.changes() == 1 { - InsertStatus::Inserted - } else { - InsertStatus::AlreadyPresent - }; transaction.commit()?; - Ok(status) + Ok(InsertStatus::Inserted) } fn quarantine_decode_rejection( @@ -247,6 +230,56 @@ impl Store { } } +fn stored_canonical_record( + connection: &rusqlite::Connection, + kind: SchemaKind, + id: &RecordId, +) -> Result, StoreError> { + connection + .query_row( + " + SELECT kind, record_id, schema_version, digest, canonical_json + FROM canonical_records + WHERE kind = ?1 AND record_id = ?2 + ", + params![kind_key(kind), id.as_str()], + |row| { + Ok(StoredCanonicalRecord { + kind: row.get(0)?, + record_id: row.get(1)?, + schema_version: row.get(2)?, + digest: row.get(3)?, + canonical_json: row.get(4)?, + }) + }, + ) + .optional() + .map_err(Into::into) +} + +fn validate_stored_canonical_record( + stored: &StoredCanonicalRecord, + expected_kind: SchemaKind, + expected_id: &RecordId, +) -> Result<(), StoreError> { + let document = + decode_document(&stored.canonical_json).map_err(|_| StoreError::DatabaseCorruption)?; + let canonical = canonical_bytes(&document).map_err(|_| StoreError::DatabaseCorruption)?; + let recomputed_digest = digest(&document).map_err(|_| StoreError::DatabaseCorruption)?; + let schema_version = document.schema_version(); + if canonical != stored.canonical_json + || stored.kind != kind_key(expected_kind) + || stored.record_id != expected_id.as_str() + || schema_version.kind != expected_kind + || stored.schema_version != schema_version.to_string() + || stored.digest != recomputed_digest.as_str() + || document.persistable_record_id() != Some(expected_id) + { + return Err(StoreError::DatabaseCorruption); + } + Ok(()) +} + pub(crate) fn kind_key(kind: SchemaKind) -> &'static str { match kind { SchemaKind::IdentitySnapshot => "identity_snapshot", diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs index 58b84da..023026f 100644 --- a/crates/psyche-store/src/transitions.rs +++ b/crates/psyche-store/src/transitions.rs @@ -38,6 +38,16 @@ struct TransitionDigestInput<'a> { created_at: time::OffsetDateTime, } +struct StoredTransition { + kind: String, + record_id: String, + from_state: Option, + to_state: String, + record_version: i64, + transition_digest: String, + created_at: String, +} + impl Transition { /// Builds a transition and binds its canonical digest. pub fn new( @@ -141,6 +151,53 @@ impl Store { let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing: Option = transaction + .query_row( + " + SELECT + kind, + record_id, + from_state, + to_state, + record_version, + transition_digest, + created_at + FROM transitions + WHERE kind = ?1 AND record_id = ?2 AND record_version = ?3 + ", + params![ + records::kind_key(transition.kind), + transition.record_id.as_str(), + sql_version, + ], + |row| { + Ok(StoredTransition { + kind: row.get(0)?, + record_id: row.get(1)?, + from_state: row.get(2)?, + to_state: row.get(3)?, + record_version: row.get(4)?, + transition_digest: row.get(5)?, + created_at: row.get(6)?, + }) + }, + ) + .optional()?; + if let Some(stored) = existing { + if stored.kind == records::kind_key(transition.kind) + && stored.record_id == transition.record_id.as_str() + && stored.from_state == transition.from_state + && stored.to_state == transition.to_state + && stored.record_version == sql_version + && stored.transition_digest == transition.transition_digest.as_str() + && stored.created_at == created_at + { + transaction.commit()?; + return Ok(()); + } + return Err(transition_conflict(transition)); + } + let latest: Option<(i64, String)> = transaction .query_row( " @@ -161,7 +218,7 @@ impl Store { None => transition.record_version == 1, Some((previous_version, previous_state)) => { let previous_version = - u64::try_from(previous_version).map_err(|_| StoreError::DatabaseOperation)?; + u64::try_from(previous_version).map_err(|_| StoreError::DatabaseCorruption)?; previous_version .checked_add(1) .is_some_and(|next| next == transition.record_version) diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index a79cb37..4bf284d 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -1,6 +1,8 @@ #![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] use std::collections::BTreeMap; +use std::error::Error; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Barrier}; use psyche_core::contracts::error::{ErrorBody, ErrorCode, ErrorEnvelope}; @@ -20,6 +22,7 @@ use psyche_core::contracts::{ use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::{RecordId, RequestId}; use psyche_store::{IngestOutcome, Store, StoreError, Transition}; +use rusqlite::Connection; use serde::Serialize; use serde_json::{Map, json}; use time::OffsetDateTime; @@ -31,6 +34,30 @@ fn test_store() -> (Store, tempfile::TempDir) { (store, dir) } +fn test_store_with_path() -> (Store, tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + let store = Store::open(&path).unwrap(); + (store, dir, path) +} + +fn raw_connection(path: &Path) -> Connection { + Connection::open(path).unwrap() +} + +fn assert_database_corruption(result: Result) { + let error = result.unwrap_err(); + assert_eq!( + error.to_string(), + "stored database content failed integrity validation" + ); + assert_eq!( + format!("{error:?}"), + "StoreError(stored database content failed integrity validation)" + ); + assert!(error.source().is_none()); +} + fn at(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &Rfc3339).unwrap() } @@ -264,6 +291,15 @@ fn fixture_acknowledged_revision(previous: &ExecutionBinding) -> ExecutionBindin acknowledged } +fn fixture_already_terminal_revision(previous: &ExecutionBinding) -> ExecutionBinding { + let mut acknowledged = next_revision(previous); + acknowledged.cancellation_state = CancellationState::AcknowledgedAlreadyTerminal; + let mut evidence = acknowledgement(&acknowledged); + evidence.kind = CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + acknowledged.cancellation_acknowledgement = Some(evidence); + acknowledged +} + fn fixture_unresolved_revision(previous: &ExecutionBinding) -> ExecutionBinding { let mut unresolved_binding = next_revision(previous); unresolved_binding.cancellation_state = CancellationState::TerminationUnknown; @@ -1312,6 +1348,463 @@ fn transition_digest_uses_the_exact_owned_canonical_contract() { transition.validate().unwrap(); } +#[test] +fn execution_cancellation_legal_forward_paths_and_stable_terminal_revisions_append() { + for terminal_kind in [ + CancellationState::AcknowledgedTerminated, + CancellationState::AcknowledgedAlreadyTerminal, + CancellationState::TerminationUnknown, + ] { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let unchanged = fixture_next_not_requested_revision(&initial); + let requested = fixture_termination_requested_revision(&unchanged); + let requested_again = fixture_next_termination_requested_revision(&requested); + for binding in [&initial, &unchanged, &requested, &requested_again] { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + + let terminal = match terminal_kind { + CancellationState::AcknowledgedTerminated => { + fixture_acknowledged_revision(&requested_again) + } + CancellationState::AcknowledgedAlreadyTerminal => { + fixture_already_terminal_revision(&requested_again) + } + CancellationState::TerminationUnknown => fixture_unresolved_revision(&requested_again), + _ => unreachable!(), + }; + store + .insert(&CanonicalDocument::ExecutionBinding(terminal.clone())) + .unwrap(); + + let mut stable = next_revision(&terminal); + stable.event_cursor = Some("cursor:after-terminal".to_owned()); + store + .insert(&CanonicalDocument::ExecutionBinding(stable.clone())) + .unwrap(); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![ + initial, + unchanged, + requested, + requested_again, + terminal, + stable + ] + ); + } +} + +#[test] +fn execution_cancellation_rejects_direct_not_requested_to_any_terminal_state() { + for terminal_kind in [ + CancellationState::AcknowledgedTerminated, + CancellationState::AcknowledgedAlreadyTerminal, + CancellationState::TerminationUnknown, + ] { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + let mut terminal = fixture_termination_requested_revision(&initial); + terminal.cancellation_state = terminal_kind; + match terminal_kind { + CancellationState::AcknowledgedTerminated => { + terminal.cancellation_acknowledgement = Some(acknowledgement(&terminal)); + } + CancellationState::AcknowledgedAlreadyTerminal => { + let mut evidence = acknowledgement(&terminal); + evidence.kind = CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + terminal.cancellation_acknowledgement = Some(evidence); + } + CancellationState::TerminationUnknown => { + terminal.cancellation_unresolved = Some(unresolved(&terminal)); + } + _ => unreachable!(), + } + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(terminal)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + store + .execution_binding_revisions(&initial.attempt_id) + .unwrap(), + vec![initial] + ); + } +} + +#[test] +fn execution_cancellation_rejects_requested_and_terminal_regressions() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + for binding in [&initial, &requested] { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + let requested_regression = fixture_not_requested_revision_after(&requested); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(requested_regression)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + + let terminal = fixture_acknowledged_revision(&requested); + store + .insert(&CanonicalDocument::ExecutionBinding(terminal.clone())) + .unwrap(); + let mut terminal_regression = next_revision(&terminal); + terminal_regression.cancellation_state = CancellationState::TerminationRequested; + terminal_regression.cancellation_acknowledgement = None; + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(terminal_regression)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + let terminal_removal = fixture_not_requested_revision_after(&terminal); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(terminal_removal)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); +} + +#[test] +fn execution_cancellation_rejects_terminal_switches_and_evidence_mutation() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + let terminal = fixture_acknowledged_revision(&requested); + for binding in [&initial, &requested, &terminal] { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + + let mut switched = next_revision(&terminal); + switched.cancellation_state = CancellationState::AcknowledgedAlreadyTerminal; + switched.cancellation_acknowledgement.as_mut().unwrap().kind = + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(switched)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + + let mut mutated = next_revision(&terminal); + mutated + .cancellation_acknowledgement + .as_mut() + .unwrap() + .authority_evidence_digest = fixture_other_digest(); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(mutated)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); +} + +#[test] +fn execution_cancellation_rejects_unknown_to_acknowledged() { + let (mut store, _dir) = test_store(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + let unknown = fixture_unresolved_revision(&requested); + for binding in [&initial, &requested, &unknown] { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + let mut acknowledged = next_revision(&unknown); + acknowledged.cancellation_state = CancellationState::AcknowledgedTerminated; + acknowledged.cancellation_unresolved = None; + acknowledged.cancellation_acknowledgement = Some(acknowledgement(&acknowledged)); + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(acknowledged)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); +} + +#[test] +fn transition_exact_replay_is_idempotent_and_divergent_identity_conflicts() { + let (mut store, _dir) = test_store(); + let original = transition(1, None, "admitted"); + store.append_transition(&original).unwrap(); + store.append_transition(&original).unwrap(); + assert_eq!(store.count_transitions().unwrap(), 1); + + let changed_field = transition(1, None, "running"); + assert!(matches!( + store.append_transition(&changed_field), + Err(StoreError::TransitionConflict { .. }) + )); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +#[test] +fn transition_exact_replay_compares_the_stored_digest() { + let (mut store, _dir, path) = test_store_with_path(); + let original = transition(1, None, "admitted"); + store.append_transition(&original).unwrap(); + raw_connection(&path) + .execute( + "UPDATE transitions SET transition_digest = ?1 WHERE record_version = 1", + [fixture_other_digest().as_str()], + ) + .unwrap(); + assert!(matches!( + store.append_transition(&original), + Err(StoreError::TransitionConflict { .. }) + )); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +fn insert_intent_for_tamper() -> ( + Store, + tempfile::TempDir, + PathBuf, + CanonicalDocument, + RecordId, +) { + let (mut store, dir, path) = test_store_with_path(); + let intent = fixture_intent("Review A"); + let id = intent.intent_id.clone(); + let document = CanonicalDocument::Intent(intent); + store.insert(&document).unwrap(); + (store, dir, path, document, id) +} + +fn assert_canonical_corruption_across_access_paths( + store: &mut Store, + document: &CanonicalDocument, + id: &RecordId, +) { + assert_database_corruption(store.load(SchemaKind::Intent, id)); + assert_database_corruption(store.load_canonical_bytes(SchemaKind::Intent, id)); + assert_database_corruption(store.insert(document)); +} + +#[test] +fn canonical_record_detects_digest_only_bytes_noncanonical_and_malformed_tamper() { + for case in ["digest", "bytes", "noncanonical", "malformed"] { + let (mut store, _dir, path, document, id) = insert_intent_for_tamper(); + let connection = raw_connection(&path); + match case { + "digest" => { + connection + .execute( + "UPDATE canonical_records SET digest = ?1", + [fixture_other_digest().as_str()], + ) + .unwrap(); + } + "bytes" => { + let changed = + canonical_bytes(&CanonicalDocument::Intent(fixture_intent("Review B"))) + .unwrap(); + connection + .execute( + "UPDATE canonical_records SET canonical_json = ?1", + [changed], + ) + .unwrap(); + } + "noncanonical" => { + let mut bytes = vec![b' ']; + bytes.extend(canonical_bytes(&document).unwrap()); + connection + .execute("UPDATE canonical_records SET canonical_json = ?1", [bytes]) + .unwrap(); + } + "malformed" => { + connection + .execute( + "UPDATE canonical_records SET canonical_json = ?1", + [b"{".as_slice()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + assert_canonical_corruption_across_access_paths(&mut store, &document, &id); + } +} + +#[test] +fn canonical_record_detects_schema_kind_and_record_id_metadata_tamper() { + let (mut schema_store, _dir, schema_path, document, id) = insert_intent_for_tamper(); + raw_connection(&schema_path) + .execute( + "UPDATE canonical_records SET schema_version = 'psyche.graph.v1'", + [], + ) + .unwrap(); + assert_canonical_corruption_across_access_paths(&mut schema_store, &document, &id); + + let (mut id_store, _dir, id_path, _document, _id) = insert_intent_for_tamper(); + let other_id = record_id(RecordKind::Intent, "01J00000000000000000000009"); + raw_connection(&id_path) + .execute( + "UPDATE canonical_records SET record_id = ?1", + [other_id.as_str()], + ) + .unwrap(); + let mut other_intent = fixture_intent("Review A"); + other_intent.intent_id = other_id.clone(); + let other_document = CanonicalDocument::Intent(other_intent); + assert_canonical_corruption_across_access_paths(&mut id_store, &other_document, &other_id); + + let (kind_store, _dir, kind_path, _document, _id) = insert_intent_for_tamper(); + let graph_id = record_id(RecordKind::Graph, "01J00000000000000000000010"); + raw_connection(&kind_path) + .execute( + "UPDATE canonical_records SET kind = 'graph', record_id = ?1", + [graph_id.as_str()], + ) + .unwrap(); + assert_database_corruption(kind_store.load(SchemaKind::Graph, &graph_id)); + assert_database_corruption(kind_store.load_canonical_bytes(SchemaKind::Graph, &graph_id)); +} + +fn binding_chain_for_tamper() -> ( + Store, + tempfile::TempDir, + PathBuf, + ExecutionBinding, + ExecutionBinding, +) { + let (mut store, dir, path) = test_store_with_path(); + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + for binding in [&initial, &requested] { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + (store, dir, path, initial, requested) +} + +fn assert_execution_corruption_across_access_paths( + store: &mut Store, + initial: &ExecutionBinding, + requested: &ExecutionBinding, +) { + assert_database_corruption(store.execution_binding_revisions(&initial.attempt_id)); + assert_database_corruption(store.load(SchemaKind::ExecutionBinding, &initial.attempt_id)); + assert_database_corruption( + store.load_canonical_bytes(SchemaKind::ExecutionBinding, &initial.attempt_id), + ); + assert_database_corruption(store.insert(&CanonicalDocument::ExecutionBinding(initial.clone()))); + let append = fixture_next_termination_requested_revision(requested); + assert_database_corruption(store.insert(&CanonicalDocument::ExecutionBinding(append))); +} + +#[test] +fn execution_revision_chain_detects_blob_digest_link_schema_timestamp_and_gap_tamper() { + for case in [ + "revision_blob", + "digest", + "previous_digest", + "schema", + "timestamp", + "gap", + ] { + let (mut store, _dir, path, initial, requested) = binding_chain_for_tamper(); + let connection = raw_connection(&path); + connection + .pragma_update(None, "foreign_keys", false) + .unwrap(); + match case { + "revision_blob" => { + let first: Vec = connection + .query_row( + "SELECT canonical_json FROM execution_binding_revisions WHERE revision = 1", + [], + |row| row.get(0), + ) + .unwrap(); + connection + .execute( + "UPDATE execution_binding_revisions SET canonical_json = ?1 WHERE revision = 2", + [first], + ) + .unwrap(); + } + "digest" => { + connection + .execute( + "UPDATE execution_binding_revisions SET digest = ?1 WHERE revision = 2", + [fixture_other_digest().as_str()], + ) + .unwrap(); + } + "previous_digest" => { + connection + .execute( + "UPDATE execution_binding_revisions SET previous_revision_digest = ?1 WHERE revision = 2", + [fixture_other_digest().as_str()], + ) + .unwrap(); + } + "schema" => { + connection + .execute( + "UPDATE execution_binding_revisions SET schema_version = 'psyche.intent.v1' WHERE revision = 2", + [], + ) + .unwrap(); + } + "timestamp" => { + connection + .execute( + "UPDATE execution_binding_revisions SET created_at = '2026-08-05T12:00:00Z' WHERE revision = 2", + [], + ) + .unwrap(); + } + "gap" => { + connection + .execute( + "UPDATE execution_binding_revisions SET revision = 3 WHERE revision = 2", + [], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + assert_execution_corruption_across_access_paths(&mut store, &initial, &requested); + } +} + +#[test] +fn execution_revision_chain_detects_attempt_metadata_tamper() { + let (store, _dir, path, _initial, _requested) = binding_chain_for_tamper(); + let other_attempt = fixture_other_attempt_id(); + let connection = raw_connection(&path); + connection + .pragma_update(None, "foreign_keys", false) + .unwrap(); + connection + .execute( + "UPDATE execution_binding_revisions SET attempt_id = ?1 WHERE revision = 2", + [other_attempt.as_str()], + ) + .unwrap(); + drop(connection); + assert_database_corruption(store.execution_binding_revisions(&other_attempt)); + assert_database_corruption(store.load(SchemaKind::ExecutionBinding, &other_attempt)); + assert_database_corruption( + store.load_canonical_bytes(SchemaKind::ExecutionBinding, &other_attempt), + ); +} + proptest::proptest! { #[test] fn reinsertion_never_changes_stored_bytes(outcome in "[a-zA-Z0-9 ]{1,80}") { From d3092b569f51928b82952654aa55511e004328af Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:42:22 -0500 Subject: [PATCH 24/66] fix(store): restore approved database open contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 246 ++-------------------- crates/psyche-store/src/lib.rs | 21 +- crates/psyche-store/tests/migrations.rs | 249 +---------------------- crates/psyche-store/tests/support/mod.rs | 40 +--- 4 files changed, 28 insertions(+), 528 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 2968871..6c98d86 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,6 +1,6 @@ use std::{ - fs::{self, File, OpenOptions}, - io::{BufReader, ErrorKind, Read}, + fs::{self, OpenOptions}, + io::ErrorKind, path::{Path, PathBuf}, thread, time::{Duration, Instant}, @@ -13,65 +13,19 @@ use crate::StoreError; const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); -const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0"; -const SQLITE_HEADER_SIZE: usize = 100; -const WAL_HEADER_SIZE: usize = 32; -const WAL_FRAME_HEADER_SIZE: usize = 24; -const WAL_FORMAT_VERSION: u32 = 3_007_000; -const WAL_MAGIC: u32 = 0x377f_0682; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DatabaseFileState { - Existing, - Created, -} -pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { +pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; - let state = prepare_database_file(path)?; + prepare_database_file(path)?; let open_path = database_open_path(path)?; - Ok((open_path, state)) -} -pub(crate) fn open_read_only(path: &Path) -> Result { - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(path, flags)?; - connection.busy_timeout(BUSY_TIMEOUT)?; - Ok(connection) -} - -pub(crate) fn open_read_write(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(path, flags)?; + let connection = Connection::open_with_flags(&open_path, flags)?; connection.busy_timeout(BUSY_TIMEOUT)?; - Ok(connection) -} - -pub(crate) fn file_user_version(path: &Path) -> Result, StoreError> { - // A read-only WAL query may update reader marks in `-shm`. Read committed - // page-one frames first so a future schema can be rejected without that. - let [journal_path, wal_path, _] = sqlite_sidecar_paths(path); - if existing_file_len(&journal_path)?.is_some_and(|len| len > 0) { - return Ok(None); - } - - let Some((main_version, page_size)) = main_file_header(path)? else { - return Ok(None); - }; - if page_size == 0 { - return Ok(Some(main_version)); - } - - match wal_file_user_version(&wal_path, page_size)? { - WalFileVersion::Absent => Ok(Some(main_version)), - WalFileVersion::Invalid => Ok(None), - WalFileVersion::Valid(version) => Ok(Some(version.unwrap_or(main_version))), - } + Ok((connection, open_path)) } pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { @@ -195,162 +149,6 @@ fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { }) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WalFileVersion { - Absent, - Invalid, - Valid(Option), -} - -fn existing_file_len(path: &Path) -> Result, StoreError> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - validate_database_metadata(&metadata)?; - Ok(Some(metadata.len())) - } - Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), - Err(error) => Err(StoreError::file_operation(error)), - } -} - -fn main_file_header(path: &Path) -> Result, StoreError> { - let mut file = File::open(path).map_err(StoreError::file_operation)?; - let len = file.metadata().map_err(StoreError::file_operation)?.len(); - if len == 0 { - return Ok(Some((0, 0))); - } - if len < SQLITE_HEADER_SIZE as u64 { - return Ok(None); - } - - let mut header = [0_u8; SQLITE_HEADER_SIZE]; - file.read_exact(&mut header) - .map_err(StoreError::file_operation)?; - if &header[..SQLITE_HEADER.len()] != SQLITE_HEADER { - return Ok(None); - } - - let encoded_page_size = u16::from_be_bytes([header[16], header[17]]); - let page_size = if encoded_page_size == 1 { - 65_536 - } else { - u32::from(encoded_page_size) - }; - if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { - return Ok(None); - } - - Ok(Some((read_u32_be(&header[60..64]), page_size))) -} - -fn wal_file_user_version( - path: &Path, - expected_page_size: u32, -) -> Result { - let file = match File::open(path) { - Ok(file) => file, - Err(error) if error.kind() == ErrorKind::NotFound => { - return Ok(WalFileVersion::Absent); - } - Err(error) => return Err(StoreError::file_operation(error)), - }; - if file.metadata().map_err(StoreError::file_operation)?.len() < WAL_HEADER_SIZE as u64 { - return Ok(WalFileVersion::Absent); - } - - let mut reader = BufReader::new(file); - let mut header = [0_u8; WAL_HEADER_SIZE]; - reader - .read_exact(&mut header) - .map_err(StoreError::file_operation)?; - let magic = read_u32_be(&header[..4]); - let page_size = read_u32_be(&header[8..12]); - if magic & !1 != WAL_MAGIC - || read_u32_be(&header[4..8]) != WAL_FORMAT_VERSION - || page_size != expected_page_size - { - return Ok(WalFileVersion::Invalid); - } - - let checksum_big_endian = magic & 1 == 1; - let mut checksum = [0_u32; 2]; - extend_wal_checksum(&header[..24], checksum_big_endian, &mut checksum); - if checksum != [read_u32_be(&header[24..28]), read_u32_be(&header[28..])] { - return Ok(WalFileVersion::Invalid); - } - - let salt = &header[16..24]; - let mut frame_header = [0_u8; WAL_FRAME_HEADER_SIZE]; - let mut page = vec![0_u8; page_size as usize]; - let mut pending_page_one = None; - let mut committed_page_one = None; - loop { - if !read_exact_frame_part(&mut reader, &mut frame_header)? - || !read_exact_frame_part(&mut reader, &mut page)? - { - break; - } - if read_u32_be(&frame_header[..4]) == 0 || &frame_header[8..16] != salt { - break; - } - - let mut frame_checksum = checksum; - extend_wal_checksum(&frame_header[..8], checksum_big_endian, &mut frame_checksum); - extend_wal_checksum(&page, checksum_big_endian, &mut frame_checksum); - if frame_checksum - != [ - read_u32_be(&frame_header[16..20]), - read_u32_be(&frame_header[20..]), - ] - { - break; - } - checksum = frame_checksum; - - if read_u32_be(&frame_header[..4]) == 1 { - pending_page_one = Some(read_u32_be(&page[60..64])); - } - if read_u32_be(&frame_header[4..8]) != 0 { - if let Some(version) = pending_page_one.take() { - committed_page_one = Some(version); - } - } - } - - Ok(WalFileVersion::Valid(committed_page_one)) -} - -fn read_exact_frame_part(reader: &mut impl Read, buffer: &mut [u8]) -> Result { - match reader.read_exact(buffer) { - Ok(()) => Ok(true), - Err(error) if error.kind() == ErrorKind::UnexpectedEof => Ok(false), - Err(error) => Err(StoreError::file_operation(error)), - } -} - -fn extend_wal_checksum(bytes: &[u8], big_endian: bool, checksum: &mut [u32; 2]) { - debug_assert_eq!(bytes.len() % 8, 0); - for words in bytes.chunks_exact(8) { - let first = read_checksum_word(&words[..4], big_endian); - checksum[0] = checksum[0].wrapping_add(first).wrapping_add(checksum[1]); - let second = read_checksum_word(&words[4..], big_endian); - checksum[1] = checksum[1].wrapping_add(second).wrapping_add(checksum[0]); - } -} - -fn read_checksum_word(bytes: &[u8], big_endian: bool) -> u32 { - let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; - if big_endian { - u32::from_be_bytes(bytes) - } else { - u32::from_le_bytes(bytes) - } -} - -fn read_u32_be(bytes: &[u8]) -> u32 { - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) -} - fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, @@ -415,16 +213,6 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(StoreError::InvalidDatabasePath); } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - if metadata.permissions().mode() & 0o777 != 0o700 { - return Err(StoreError::InvalidDatabasePath); - } - } - Ok(()) } @@ -446,24 +234,21 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_database_file(path: &Path) -> Result { - let state = match fs::symlink_metadata(path) { - Ok(metadata) => { - validate_database_metadata(&metadata)?; - DatabaseFileState::Existing - } +fn prepare_database_file(path: &Path) -> Result<(), StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => validate_database_metadata(&metadata)?, Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { - Ok(()) => DatabaseFileState::Created, - Err(error) if error.kind() == ErrorKind::AlreadyExists => DatabaseFileState::Existing, + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} Err(error) => return Err(StoreError::file_operation(error)), }, Err(error) => return Err(StoreError::file_operation(error)), - }; + } let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - Ok(state) + Ok(()) } fn database_open_path(path: &Path) -> Result { @@ -502,15 +287,14 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open_read_write, prepare}; + use super::{configure, open}; #[test] fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let (path, _) = prepare(&path).unwrap(); - let connection = open_read_write(&path).unwrap(); + let (connection, _) = open(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 6981bb9..e2ae267 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -32,31 +32,20 @@ impl Store { pub fn open(path: &Path) -> Result { let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); let _initialization_guard = initialization_guard(initialization_lock)?; - let (database_path, file_state) = connection::prepare(path)?; - connection::validate_sidecars(&database_path)?; + let (mut connection, database_path) = connection::open(path)?; - if file_state == connection::DatabaseFileState::Existing { - let preflight = connection::open_read_only(&database_path)?; - if let Some(found) = connection::file_user_version(&database_path)? { - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); - } - } - let found = match preflight - .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) - { + let found = + match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { Ok(found) => found, Err(error) => { connection::validate_sidecars(&database_path)?; return Err(error.into()); } }; - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); - } + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); } - let mut connection = connection::open_read_write(&database_path)?; connection::enforce_database_permissions(&database_path)?; connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index ffc5158..f4a2571 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -120,121 +120,6 @@ fn future_database_version_fails_before_any_migration() { assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } -#[cfg(unix)] -#[test] -fn crash_left_wal_future_version_is_rejected_without_mutating_any_database_file() { - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::Version1); - - run_crash_helper(&path, "wal-v99"); - - let sidecars = sqlite_sidecar_paths(&path); - let wal_path = &sidecars[1]; - let shm_path = &sidecars[2]; - assert!(wal_path.exists()); - assert!(shm_path.exists()); - assert_eq!(database_header_user_version(&path), 1); - - for file in [&path, wal_path, shm_path] { - set_mode(file, 0o644); - } - let before = [ - snapshot_file(&path), - snapshot_file(wal_path), - snapshot_file(shm_path), - ]; - - let error = Store::open(&path).unwrap_err(); - - assert!( - matches!( - &error, - StoreError::UnsupportedDatabaseVersion { found: 99, .. } - ), - "unexpected error: {error:?}" - ); - assert_eq!( - error.to_string(), - "unsupported database version 99; maximum supported version is 1" - ); - assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); - assert_snapshot_unchanged("WAL", &before[1], &snapshot_file(wal_path)); - assert_snapshot_unchanged("shared memory", &before[2], &snapshot_file(shm_path)); -} - -#[cfg(unix)] -#[test] -fn hot_journal_read_only_failure_does_not_recover_or_open_read_write() { - use std::error::Error; - - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::Version0); - execute_batch( - &path, - " - CREATE TABLE hot_journal_seed ( - id INTEGER PRIMARY KEY, - payload BLOB NOT NULL - ) STRICT; - WITH RECURSIVE counter(value) AS ( - SELECT 1 - UNION ALL - SELECT value + 1 FROM counter WHERE value < 256 - ) - INSERT INTO hot_journal_seed (id, payload) - SELECT value, zeroblob(4096) FROM counter; - ", - ); - - run_crash_helper(&path, "hot-journal"); - - let sidecars = sqlite_sidecar_paths(&path); - let journal_path = &sidecars[0]; - let journal_contents = std::fs::read(journal_path).unwrap(); - assert!(journal_contents.len() > 512); - assert!(journal_contents[..8].iter().any(|byte| *byte != 0)); - set_mode(&path, 0o644); - set_mode(journal_path, 0o644); - let before = [snapshot_file(&path), snapshot_file(journal_path)]; - - let error = Store::open(&path).unwrap_err(); - - assert!( - matches!(&error, StoreError::DatabaseOperation), - "unexpected error: {error:?}" - ); - assert_eq!(error.to_string(), "store database operation failed"); - assert_eq!( - format!("{error:?}"), - "StoreError(store database operation failed)" - ); - assert!(error.source().is_none()); - assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); - assert_snapshot_unchanged("rollback journal", &before[1], &snapshot_file(journal_path)); -} - -#[test] -fn partially_applied_v1_transaction_rolls_back_and_recovers() { - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); - - assert_eq!(user_version(&path), 0); - assert!(!table_exists(&path, "schema_migrations")); - assert!(!table_exists(&path, "canonical_records")); - - let store = Store::open(&path).unwrap(); - assert_eq!(store.schema_version().unwrap(), 1); - drop(store); - - assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); - assert_eq!(schema_migrations(&path).len(), 1); - - let reopened = Store::open(&path).unwrap(); - assert_eq!(reopened.schema_version().unwrap(), 1); - drop(reopened); - assert_eq!(schema_migrations(&path).len(), 1); -} - #[test] fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); @@ -303,8 +188,6 @@ fn concurrent_first_open_applies_migration_once() { const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); - #[cfg(unix)] - set_mode(dir.path(), 0o700); for round in 0..ROUNDS { let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); let barrier = Arc::new(Barrier::new(THREADS)); @@ -444,43 +327,24 @@ fn future_database_sidecar_permissions_are_unchanged() { #[cfg(unix)] #[test] -fn existing_shared_parent_is_rejected_without_changes() { +fn existing_shared_parent_permissions_are_preserved() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, b"not-a-database").unwrap(); + std::fs::write(&path, []).unwrap(); set_mode(&parent, 0o755); set_mode(&path, 0o755); - assert_invalid_database_path(&path); - - assert_eq!(mode(&parent), 0o755); - assert_eq!(mode(&path), 0o755); - assert_eq!(std::fs::read(&path).unwrap(), b"not-a-database"); -} - -#[cfg(unix)] -#[test] -fn existing_private_parent_is_accepted_without_changing_its_mode() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().join("existing"); - let path = parent.join("psyche.sqlite3"); - std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, []).unwrap(); - set_mode(&parent, 0o700); - set_mode(&path, 0o600); - drop(Store::open(&path).unwrap()); - assert_eq!(mode(&parent), 0o700); + assert_eq!(mode(&parent), 0o755); assert_private_file(&path); - assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); } #[cfg(unix)] #[test] -fn relative_filename_rejects_shared_current_directory_without_changes() { +fn relative_filename_preserves_current_directory_permissions() { use std::process::Command; let dir = tempfile::tempdir().unwrap(); @@ -499,7 +363,7 @@ fn relative_filename_rejects_shared_current_directory_without_changes() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(mode(dir.path()), 0o755); - assert!(!dir.path().join("psyche.sqlite3").exists()); + assert_private_file(&dir.path().join("psyche.sqlite3")); } #[cfg(unix)] @@ -509,55 +373,7 @@ fn relative_filename_open_helper() { return; } - assert_invalid_database_path(Path::new("psyche.sqlite3")); -} - -#[cfg(unix)] -#[test] -fn crash_left_database_helper() { - let Some(helper) = std::env::var_os("PSYCHE_STORE_CRASH_HELPER") else { - return; - }; - let path = PathBuf::from( - std::env::var_os("PSYCHE_STORE_CRASH_HELPER_PATH") - .unwrap_or_else(|| panic!("crash helper database path is missing")), - ); - let connection = Connection::open(&path).unwrap(); - - match helper.to_str() { - Some("wal-v99") => connection - .execute_batch( - " - PRAGMA journal_mode = WAL; - PRAGMA wal_autocheckpoint = 0; - PRAGMA synchronous = FULL; - BEGIN IMMEDIATE; - CREATE TABLE wal_future_marker ( - value TEXT NOT NULL - ) STRICT; - INSERT INTO wal_future_marker (value) VALUES ('future-in-wal'); - PRAGMA user_version = 99; - COMMIT; - ", - ) - .unwrap(), - Some("hot-journal") => connection - .execute_batch( - " - PRAGMA journal_mode = DELETE; - PRAGMA synchronous = FULL; - PRAGMA cache_size = 1; - PRAGMA cache_spill = ON; - BEGIN IMMEDIATE; - UPDATE hot_journal_seed - SET payload = randomblob(4096); - ", - ) - .unwrap(), - _ => panic!("unknown crash helper mode"), - } - - std::process::exit(0); + drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); } #[cfg(unix)] @@ -583,7 +399,6 @@ fn symlink_database_is_rejected_without_mutating_its_target() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); - set_mode(dir.path(), 0o700); let target = dir.path().join("target.sqlite3"); let path = dir.path().join("linked.sqlite3"); std::fs::write(&target, []).unwrap(); @@ -599,58 +414,6 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } -#[cfg(unix)] -fn run_crash_helper(path: &Path, helper: &str) { - use std::process::Command; - - let output = Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "crash_left_database_helper", "--nocapture"]) - .env("PSYCHE_STORE_CRASH_HELPER", helper) - .env("PSYCHE_STORE_CRASH_HELPER_PATH", path) - .output() - .unwrap(); - assert!( - output.status.success(), - "crash helper failed: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[cfg(unix)] -#[derive(Debug, Eq, PartialEq)] -struct FileSnapshot { - contents: Vec, - len: u64, - modified: std::time::SystemTime, - mode: u32, -} - -#[cfg(unix)] -fn snapshot_file(path: &Path) -> FileSnapshot { - let contents = std::fs::read(path).unwrap(); - let metadata = std::fs::metadata(path).unwrap(); - FileSnapshot { - contents, - len: metadata.len(), - modified: metadata.modified().unwrap(), - mode: mode(path), - } -} - -#[cfg(unix)] -fn assert_snapshot_unchanged(label: &str, before: &FileSnapshot, after: &FileSnapshot) { - assert_eq!(after.len, before.len, "{label} length changed"); - assert_eq!(after.modified, before.modified, "{label} mtime changed"); - assert_eq!(after.mode, before.mode, "{label} mode changed"); - assert_eq!(after.contents, before.contents, "{label} contents changed"); -} - -#[cfg(unix)] -fn database_header_user_version(path: &Path) -> u32 { - let contents = std::fs::read(path).unwrap(); - u32::from_be_bytes(contents[60..64].try_into().unwrap()) -} - fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { sqlite_sidecar_paths(path) .into_iter() diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index b2f8276..aba1ff2 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::{Connection, TransactionBehavior}; +use rusqlite::Connection; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,27 +15,18 @@ pub(super) enum Fixture { Version0, Version1, Version99, - PartiallyAppliedV1, MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); - } - let name = match fixture { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", - Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let mut connection = Connection::open(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -73,33 +64,6 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), - Fixture::PartiallyAppliedV1 => { - let transaction = connection - .transaction_with_behavior(TransactionBehavior::Exclusive) - .unwrap(); - transaction - .execute_batch( - " - CREATE TABLE schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL - ) STRICT; - CREATE TABLE canonical_records ( - kind TEXT NOT NULL, - record_id TEXT NOT NULL, - schema_version TEXT NOT NULL, - digest TEXT NOT NULL, - canonical_json BLOB NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (kind, record_id), - UNIQUE (kind, record_id, digest) - ) STRICT; - PRAGMA user_version = 1; - ", - ) - .unwrap(); - drop(transaction); - } Fixture::MigrationConflictV1 => connection .execute_batch( " From f92181ba4008050bb84f09ff863dd03b6e63211a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:49:41 -0500 Subject: [PATCH 25/66] fix(store): freeze canonical timestamp representations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/execution_bindings.rs | 68 +++++++- crates/psyche-store/tests/records.rs | 156 +++++++++++++++++- 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/crates/psyche-store/src/execution_bindings.rs b/crates/psyche-store/src/execution_bindings.rs index d50de75..36dad81 100644 --- a/crates/psyche-store/src/execution_bindings.rs +++ b/crates/psyche-store/src/execution_bindings.rs @@ -1,4 +1,7 @@ -use psyche_core::contracts::execution::CancellationState; +use psyche_core::contracts::execution::{ + CancellationAcknowledgementEvidence, CancellationState, CancellationUnresolvedEvidence, + TerminationRequestCorrelation, +}; use psyche_core::contracts::{CanonicalDocument, ContractError, ExecutionBinding, SchemaKind}; use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::RecordId; @@ -253,11 +256,18 @@ fn frozen_execution_fields_match(initial: &ExecutionBinding, candidate: &Executi && initial.project_id == candidate.project_id && initial.request_id == candidate.request_id && initial.request_digest == candidate.request_digest - && initial.request_created_at == candidate.request_created_at - && initial.request_valid_until == candidate.request_valid_until + && canonical_timestamps_match(initial.request_created_at, candidate.request_created_at) + && canonical_timestamps_match(initial.request_valid_until, candidate.request_valid_until) && initial.coven_contract_version == candidate.coven_contract_version } +fn canonical_timestamps_match( + previous: time::OffsetDateTime, + candidate: time::OffsetDateTime, +) -> bool { + previous == candidate && previous.offset() == candidate.offset() +} + fn session_binding_is_append_only(latest: &ExecutionBinding, candidate: &ExecutionBinding) -> bool { match (&latest.coven_session_id, &candidate.coven_session_id) { (None, _) => true, @@ -273,12 +283,24 @@ fn termination_binding_is_append_only( match &latest.termination_request { None => true, Some(previous) => { - candidate.termination_request.as_ref() == Some(previous) + candidate + .termination_request + .as_ref() + .is_some_and(|candidate| termination_requests_match(previous, candidate)) && candidate.termination_reason_code == latest.termination_reason_code } } } +fn termination_requests_match( + previous: &TerminationRequestCorrelation, + candidate: &TerminationRequestCorrelation, +) -> bool { + previous == candidate + && canonical_timestamps_match(previous.created_at, candidate.created_at) + && canonical_timestamps_match(previous.valid_until, candidate.valid_until) +} + fn cancellation_binding_is_append_only( latest: &ExecutionBinding, candidate: &ExecutionBinding, @@ -299,9 +321,43 @@ fn cancellation_binding_is_append_only( | CancellationState::AcknowledgedAlreadyTerminal | CancellationState::TerminationUnknown => { candidate.cancellation_state == latest.cancellation_state - && candidate.cancellation_acknowledgement == latest.cancellation_acknowledgement - && candidate.cancellation_unresolved == latest.cancellation_unresolved + && cancellation_acknowledgements_match( + latest.cancellation_acknowledgement.as_ref(), + candidate.cancellation_acknowledgement.as_ref(), + ) + && cancellation_unresolved_evidence_matches( + latest.cancellation_unresolved.as_ref(), + candidate.cancellation_unresolved.as_ref(), + ) + } + } +} + +fn cancellation_acknowledgements_match( + previous: Option<&CancellationAcknowledgementEvidence>, + candidate: Option<&CancellationAcknowledgementEvidence>, +) -> bool { + match (previous, candidate) { + (None, None) => true, + (Some(previous), Some(candidate)) => { + previous == candidate + && canonical_timestamps_match(previous.acknowledged_at, candidate.acknowledged_at) + } + _ => false, + } +} + +fn cancellation_unresolved_evidence_matches( + previous: Option<&CancellationUnresolvedEvidence>, + candidate: Option<&CancellationUnresolvedEvidence>, +) -> bool { + match (previous, candidate) { + (None, None) => true, + (Some(previous), Some(candidate)) => { + previous == candidate + && canonical_timestamps_match(previous.recorded_at, candidate.recorded_at) } + _ => false, } } diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index 4bf284d..ca8c22f 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -25,8 +25,8 @@ use psyche_store::{IngestOutcome, Store, StoreError, Transition}; use rusqlite::Connection; use serde::Serialize; use serde_json::{Map, json}; -use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; +use time::{OffsetDateTime, UtcOffset}; fn test_store() -> (Store, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); @@ -1017,6 +1017,160 @@ fn assert_next_revision_conflict(mutate: impl FnOnce(&mut ExecutionBinding)) { ); } +fn with_different_offset(timestamp: OffsetDateTime) -> OffsetDateTime { + timestamp.to_offset(UtcOffset::from_hms(1, 0, 0).unwrap()) +} + +fn assert_offset_only_next_revision_conflict( + persisted: Vec, + candidate: ExecutionBinding, + original_timestamp: OffsetDateTime, + changed_timestamp: OffsetDateTime, +) { + assert_eq!(original_timestamp, changed_timestamp); + assert_ne!(original_timestamp.offset(), changed_timestamp.offset()); + assert_ne!( + original_timestamp.format(&Rfc3339).unwrap(), + changed_timestamp.format(&Rfc3339).unwrap() + ); + candidate.validate().unwrap(); + let previous = persisted.last().unwrap(); + assert_eq!( + candidate.previous_revision_digest.as_ref(), + Some(&digest(previous).unwrap()) + ); + + let (mut store, _dir) = test_store(); + for binding in &persisted { + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + } + assert!(matches!( + store.insert(&CanonicalDocument::ExecutionBinding(candidate)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + assert_eq!( + store + .execution_binding_revisions(&previous.attempt_id) + .unwrap(), + persisted + ); +} + +#[test] +fn execution_binding_revision_freezes_request_timestamp_offsets() { + let initial = fixture_execution_binding_revision_1(); + let mut changed_created_at = next_revision(&initial); + let original_created_at = changed_created_at.request_created_at; + changed_created_at.request_created_at = with_different_offset(original_created_at); + assert_offset_only_next_revision_conflict( + vec![initial.clone()], + changed_created_at, + original_created_at, + with_different_offset(original_created_at), + ); + + let mut changed_valid_until = next_revision(&initial); + let original_valid_until = changed_valid_until.request_valid_until; + changed_valid_until.request_valid_until = with_different_offset(original_valid_until); + assert_offset_only_next_revision_conflict( + vec![initial], + changed_valid_until, + original_valid_until, + with_different_offset(original_valid_until), + ); +} + +#[test] +fn execution_binding_revision_freezes_termination_timestamp_offsets() { + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + let mut changed_created_at = next_revision(&requested); + let original_created_at = changed_created_at + .termination_request + .as_ref() + .unwrap() + .created_at; + changed_created_at + .termination_request + .as_mut() + .unwrap() + .created_at = with_different_offset(original_created_at); + assert_offset_only_next_revision_conflict( + vec![initial.clone(), requested.clone()], + changed_created_at, + original_created_at, + with_different_offset(original_created_at), + ); + + let mut changed_valid_until = next_revision(&requested); + let original_valid_until = changed_valid_until + .termination_request + .as_ref() + .unwrap() + .valid_until; + changed_valid_until + .termination_request + .as_mut() + .unwrap() + .valid_until = with_different_offset(original_valid_until); + assert_offset_only_next_revision_conflict( + vec![initial, requested], + changed_valid_until, + original_valid_until, + with_different_offset(original_valid_until), + ); +} + +#[test] +fn execution_binding_revision_freezes_acknowledgement_timestamp_offset() { + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + let acknowledged = fixture_acknowledged_revision(&requested); + let mut candidate = next_revision(&acknowledged); + let original = candidate + .cancellation_acknowledgement + .as_ref() + .unwrap() + .acknowledged_at; + candidate + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = with_different_offset(original); + assert_offset_only_next_revision_conflict( + vec![initial, requested, acknowledged], + candidate, + original, + with_different_offset(original), + ); +} + +#[test] +fn execution_binding_revision_freezes_unresolved_timestamp_offset() { + let initial = fixture_execution_binding_revision_1(); + let requested = fixture_termination_requested_revision(&initial); + let unresolved = fixture_unresolved_revision(&requested); + let mut candidate = next_revision(&unresolved); + let original = candidate + .cancellation_unresolved + .as_ref() + .unwrap() + .recorded_at; + candidate + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = with_different_offset(original); + assert_offset_only_next_revision_conflict( + vec![initial, requested, unresolved], + candidate, + original, + with_different_offset(original), + ); +} + #[test] fn execution_binding_revision_rejects_every_frozen_execution_field_change() { assert_next_revision_conflict(|revision| { From cee705c5ac48ddb2e15a4ffad527bf9002f35884 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:55:48 -0500 Subject: [PATCH 26/66] fix(store): authenticate transition history Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/transitions.rs | 172 +++++++++++++++---------- crates/psyche-store/tests/records.rs | 159 ++++++++++++++++++++++- 2 files changed, 261 insertions(+), 70 deletions(-) diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs index 023026f..d5f51ad 100644 --- a/crates/psyche-store/src/transitions.rs +++ b/crates/psyche-store/src/transitions.rs @@ -1,7 +1,7 @@ use psyche_core::contracts::{ContractError, SchemaKind}; use psyche_core::digest::{Sha256Digest, digest}; use psyche_core::id::RecordId; -use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use rusqlite::{TransactionBehavior, params}; use time::format_description::well_known::Rfc3339; use crate::{Store, StoreError, records}; @@ -120,7 +120,7 @@ impl Transition { if self.record_version == 0 { return Err(invalid(self.kind, "record_version")); } - if self.record_version > 1 && self.from_state.is_none() { + if (self.record_version == 1) != self.from_state.is_none() { return Err(invalid(self.kind, "from_state")); } if let Some(from_state) = &self.from_state { @@ -151,78 +151,26 @@ impl Store { let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; - let existing: Option = transaction - .query_row( - " - SELECT - kind, - record_id, - from_state, - to_state, - record_version, - transition_digest, - created_at - FROM transitions - WHERE kind = ?1 AND record_id = ?2 AND record_version = ?3 - ", - params![ - records::kind_key(transition.kind), - transition.record_id.as_str(), - sql_version, - ], - |row| { - Ok(StoredTransition { - kind: row.get(0)?, - record_id: row.get(1)?, - from_state: row.get(2)?, - to_state: row.get(3)?, - record_version: row.get(4)?, - transition_digest: row.get(5)?, - created_at: row.get(6)?, - }) - }, - ) - .optional()?; - if let Some(stored) = existing { - if stored.kind == records::kind_key(transition.kind) - && stored.record_id == transition.record_id.as_str() - && stored.from_state == transition.from_state - && stored.to_state == transition.to_state - && stored.record_version == sql_version - && stored.transition_digest == transition.transition_digest.as_str() - && stored.created_at == created_at - { + let history = authenticated_history(&transaction, transition.kind, &transition.record_id)?; + if let Some(stored) = history + .iter() + .find(|stored| stored.record_version == transition.record_version) + { + if stored == transition { transaction.commit()?; return Ok(()); } return Err(transition_conflict(transition)); } - let latest: Option<(i64, String)> = transaction - .query_row( - " - SELECT record_version, to_state - FROM transitions - WHERE kind = ?1 AND record_id = ?2 - ORDER BY record_version DESC - LIMIT 1 - ", - params![ - records::kind_key(transition.kind), - transition.record_id.as_str() - ], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - let valid_position = match latest { + let valid_position = match history.last() { None => transition.record_version == 1, - Some((previous_version, previous_state)) => { - let previous_version = - u64::try_from(previous_version).map_err(|_| StoreError::DatabaseCorruption)?; - previous_version + Some(previous) => { + previous + .record_version .checked_add(1) .is_some_and(|next| next == transition.record_version) - && transition.from_state.as_deref() == Some(previous_state.as_str()) + && transition.from_state.as_deref() == Some(previous.to_state.as_str()) } }; if !valid_position { @@ -265,6 +213,100 @@ impl Store { } } +fn authenticated_history( + connection: &rusqlite::Connection, + kind: SchemaKind, + record_id: &RecordId, +) -> Result, StoreError> { + let mut statement = connection.prepare( + " + SELECT + kind, + record_id, + from_state, + to_state, + record_version, + transition_digest, + created_at + FROM transitions + WHERE kind = ?1 AND record_id = ?2 + ORDER BY record_version ASC + ", + )?; + let stored = statement + .query_map( + params![records::kind_key(kind), record_id.as_str()], + |row| { + Ok(StoredTransition { + kind: row.get(0)?, + record_id: row.get(1)?, + from_state: row.get(2)?, + to_state: row.get(3)?, + record_version: row.get(4)?, + transition_digest: row.get(5)?, + created_at: row.get(6)?, + }) + }, + )? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption)?; + + authenticate_stored_history(stored, kind, record_id) +} + +fn authenticate_stored_history( + stored: Vec, + expected_kind: SchemaKind, + expected_id: &RecordId, +) -> Result, StoreError> { + let mut history = Vec::with_capacity(stored.len()); + let mut expected_version = 1_u64; + + for row in stored { + let record_version = + u64::try_from(row.record_version).map_err(|_| StoreError::DatabaseCorruption)?; + let created_at = time::OffsetDateTime::parse(&row.created_at, &Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + let reconstructed = Transition::new( + expected_kind, + expected_id.clone(), + record_version, + row.from_state.clone(), + row.to_state.clone(), + created_at, + ) + .map_err(|_| StoreError::DatabaseCorruption)?; + let canonical_created_at = reconstructed + .created_at + .format(&Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + + if row.kind != records::kind_key(reconstructed.kind) + || row.record_id != reconstructed.record_id.as_str() + || row.from_state != reconstructed.from_state + || row.to_state != reconstructed.to_state + || row.record_version + != i64::try_from(reconstructed.record_version) + .map_err(|_| StoreError::DatabaseCorruption)? + || row.transition_digest != reconstructed.transition_digest.as_str() + || row.created_at != canonical_created_at + || reconstructed.record_version != expected_version + || history.last().is_some_and(|previous: &Transition| { + reconstructed.from_state.as_deref() != Some(previous.to_state.as_str()) + }) + { + return Err(StoreError::DatabaseCorruption); + } + + expected_version = expected_version + .checked_add(1) + .ok_or(StoreError::DatabaseCorruption)?; + history.push(reconstructed); + } + + Ok(history) +} + fn validate_state( value: &str, schema: SchemaKind, diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index ca8c22f..d8b57f9 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -1432,6 +1432,42 @@ fn transition_append_requires_exact_version_and_prior_state() { assert_eq!(store.count_transitions().unwrap(), 1); } +#[test] +fn concurrent_transition_forks_have_one_durable_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + let mut first = Store::open(&path).unwrap(); + first + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + let second = Store::open(&path).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + + let left_barrier = Arc::clone(&barrier); + let left_thread = std::thread::spawn(move || { + let mut store = first; + left_barrier.wait(); + store.append_transition(&transition(2, Some("admitted"), "running")) + }); + let right_barrier = Arc::clone(&barrier); + let right_thread = std::thread::spawn(move || { + let mut store = second; + right_barrier.wait(); + store.append_transition(&transition(2, Some("admitted"), "cancelled")) + }); + + let results = [left_thread.join().unwrap(), right_thread.join().unwrap()]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(StoreError::TransitionConflict { .. }))) + .count(), + 1 + ); + assert_eq!(Store::open(&path).unwrap().count_transitions().unwrap(), 2); +} + #[test] fn transition_contract_rejects_invalid_states_and_version_overflow_without_writing() { let (store, _dir) = test_store(); @@ -1452,6 +1488,14 @@ fn transition_contract_rejects_invalid_states_and_version_overflow_without_writi "running".to_owned(), at("2026-08-05T12:00:00Z"), ), + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + 1, + Some("draft".to_owned()), + "admitted".to_owned(), + at("2026-08-05T12:00:00Z"), + ), Transition::new( SchemaKind::ExecutionBinding, fixture_attempt_id(), @@ -1701,7 +1745,7 @@ fn transition_exact_replay_is_idempotent_and_divergent_identity_conflicts() { } #[test] -fn transition_exact_replay_compares_the_stored_digest() { +fn transition_exact_replay_authenticates_the_stored_history() { let (mut store, _dir, path) = test_store_with_path(); let original = transition(1, None, "admitted"); store.append_transition(&original).unwrap(); @@ -1711,10 +1755,115 @@ fn transition_exact_replay_compares_the_stored_digest() { [fixture_other_digest().as_str()], ) .unwrap(); - assert!(matches!( - store.append_transition(&original), - Err(StoreError::TransitionConflict { .. }) - )); + assert_database_corruption(store.append_transition(&original)); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +#[test] +fn transition_append_rejects_prior_digest_corruption_without_writing() { + let (mut store, _dir, path) = test_store_with_path(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + raw_connection(&path) + .execute( + "UPDATE transitions SET transition_digest = ?1 WHERE record_version = 1", + [fixture_other_digest().as_str()], + ) + .unwrap(); + + assert_database_corruption(store.append_transition(&transition( + 2, + Some("admitted"), + "running", + ))); + assert_eq!(store.count_transitions().unwrap(), 1); +} + +#[test] +fn transition_append_rejects_prior_state_corruption_without_writing() { + for (column, version, value, next) in [ + ( + "to_state", + 1, + "queued", + transition(2, Some("admitted"), "running"), + ), + ( + "from_state", + 2, + "queued", + transition(3, Some("running"), "completed"), + ), + ] { + let (mut store, _dir, path) = test_store_with_path(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + if version == 2 { + store + .append_transition(&transition(2, Some("admitted"), "running")) + .unwrap(); + } + raw_connection(&path) + .execute( + &format!("UPDATE transitions SET {column} = ?1 WHERE record_version = ?2"), + rusqlite::params![value, version], + ) + .unwrap(); + let count = store.count_transitions().unwrap(); + + assert_database_corruption(store.append_transition(&next)); + assert_eq!(store.count_transitions().unwrap(), count); + } +} + +#[test] +fn transition_append_rejects_stored_version_gap_without_writing() { + let (mut store, _dir, path) = test_store_with_path(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + store + .append_transition(&transition(2, Some("admitted"), "running")) + .unwrap(); + raw_connection(&path) + .execute( + "UPDATE transitions SET record_version = 3 WHERE record_version = 2", + [], + ) + .unwrap(); + + assert_database_corruption(store.append_transition(&transition( + 4, + Some("running"), + "completed", + ))); + assert_eq!(store.count_transitions().unwrap(), 2); +} + +#[test] +fn transition_append_rejects_noncanonical_stored_timestamp_without_writing() { + let (mut store, _dir, path) = test_store_with_path(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + raw_connection(&path) + .execute( + " + UPDATE transitions + SET created_at = '2026-08-05T12:00:01+00:00' + WHERE record_version = 1 + ", + [], + ) + .unwrap(); + + assert_database_corruption(store.append_transition(&transition( + 2, + Some("admitted"), + "running", + ))); assert_eq!(store.count_transitions().unwrap(), 1); } From 416e3be68a7e4dcfc382a80906fea33efbdd580f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:56:25 -0500 Subject: [PATCH 27/66] fix(store): reinstate fail-closed recovery Restore the reviewed read-only recovery preflight, private storage boundary, and complete migration rollback fixtures after the concurrent persistence work removed them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 246 ++++++++++++++++++++-- crates/psyche-store/src/lib.rs | 21 +- crates/psyche-store/tests/migrations.rs | 249 ++++++++++++++++++++++- crates/psyche-store/tests/support/mod.rs | 40 +++- 4 files changed, 528 insertions(+), 28 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 6c98d86..2968871 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,6 +1,6 @@ use std::{ - fs::{self, OpenOptions}, - io::ErrorKind, + fs::{self, File, OpenOptions}, + io::{BufReader, ErrorKind, Read}, path::{Path, PathBuf}, thread, time::{Duration, Instant}, @@ -13,19 +13,65 @@ use crate::StoreError; const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); +const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0"; +const SQLITE_HEADER_SIZE: usize = 100; +const WAL_HEADER_SIZE: usize = 32; +const WAL_FRAME_HEADER_SIZE: usize = 24; +const WAL_FORMAT_VERSION: u32 = 3_007_000; +const WAL_MAGIC: u32 = 0x377f_0682; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DatabaseFileState { + Existing, + Created, +} -pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { +pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; - prepare_database_file(path)?; + let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; + Ok((open_path, state)) +} +pub(crate) fn open_read_only(path: &Path) -> Result { + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = Connection::open_with_flags(path, flags)?; + connection.busy_timeout(BUSY_TIMEOUT)?; + Ok(connection) +} + +pub(crate) fn open_read_write(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(&open_path, flags)?; + let connection = Connection::open_with_flags(path, flags)?; connection.busy_timeout(BUSY_TIMEOUT)?; - Ok((connection, open_path)) + Ok(connection) +} + +pub(crate) fn file_user_version(path: &Path) -> Result, StoreError> { + // A read-only WAL query may update reader marks in `-shm`. Read committed + // page-one frames first so a future schema can be rejected without that. + let [journal_path, wal_path, _] = sqlite_sidecar_paths(path); + if existing_file_len(&journal_path)?.is_some_and(|len| len > 0) { + return Ok(None); + } + + let Some((main_version, page_size)) = main_file_header(path)? else { + return Ok(None); + }; + if page_size == 0 { + return Ok(Some(main_version)); + } + + match wal_file_user_version(&wal_path, page_size)? { + WalFileVersion::Absent => Ok(Some(main_version)), + WalFileVersion::Invalid => Ok(None), + WalFileVersion::Valid(version) => Ok(Some(version.unwrap_or(main_version))), + } } pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { @@ -149,6 +195,162 @@ fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { }) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WalFileVersion { + Absent, + Invalid, + Valid(Option), +} + +fn existing_file_len(path: &Path) -> Result, StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + Ok(Some(metadata.len())) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn main_file_header(path: &Path) -> Result, StoreError> { + let mut file = File::open(path).map_err(StoreError::file_operation)?; + let len = file.metadata().map_err(StoreError::file_operation)?.len(); + if len == 0 { + return Ok(Some((0, 0))); + } + if len < SQLITE_HEADER_SIZE as u64 { + return Ok(None); + } + + let mut header = [0_u8; SQLITE_HEADER_SIZE]; + file.read_exact(&mut header) + .map_err(StoreError::file_operation)?; + if &header[..SQLITE_HEADER.len()] != SQLITE_HEADER { + return Ok(None); + } + + let encoded_page_size = u16::from_be_bytes([header[16], header[17]]); + let page_size = if encoded_page_size == 1 { + 65_536 + } else { + u32::from(encoded_page_size) + }; + if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { + return Ok(None); + } + + Ok(Some((read_u32_be(&header[60..64]), page_size))) +} + +fn wal_file_user_version( + path: &Path, + expected_page_size: u32, +) -> Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(WalFileVersion::Absent); + } + Err(error) => return Err(StoreError::file_operation(error)), + }; + if file.metadata().map_err(StoreError::file_operation)?.len() < WAL_HEADER_SIZE as u64 { + return Ok(WalFileVersion::Absent); + } + + let mut reader = BufReader::new(file); + let mut header = [0_u8; WAL_HEADER_SIZE]; + reader + .read_exact(&mut header) + .map_err(StoreError::file_operation)?; + let magic = read_u32_be(&header[..4]); + let page_size = read_u32_be(&header[8..12]); + if magic & !1 != WAL_MAGIC + || read_u32_be(&header[4..8]) != WAL_FORMAT_VERSION + || page_size != expected_page_size + { + return Ok(WalFileVersion::Invalid); + } + + let checksum_big_endian = magic & 1 == 1; + let mut checksum = [0_u32; 2]; + extend_wal_checksum(&header[..24], checksum_big_endian, &mut checksum); + if checksum != [read_u32_be(&header[24..28]), read_u32_be(&header[28..])] { + return Ok(WalFileVersion::Invalid); + } + + let salt = &header[16..24]; + let mut frame_header = [0_u8; WAL_FRAME_HEADER_SIZE]; + let mut page = vec![0_u8; page_size as usize]; + let mut pending_page_one = None; + let mut committed_page_one = None; + loop { + if !read_exact_frame_part(&mut reader, &mut frame_header)? + || !read_exact_frame_part(&mut reader, &mut page)? + { + break; + } + if read_u32_be(&frame_header[..4]) == 0 || &frame_header[8..16] != salt { + break; + } + + let mut frame_checksum = checksum; + extend_wal_checksum(&frame_header[..8], checksum_big_endian, &mut frame_checksum); + extend_wal_checksum(&page, checksum_big_endian, &mut frame_checksum); + if frame_checksum + != [ + read_u32_be(&frame_header[16..20]), + read_u32_be(&frame_header[20..]), + ] + { + break; + } + checksum = frame_checksum; + + if read_u32_be(&frame_header[..4]) == 1 { + pending_page_one = Some(read_u32_be(&page[60..64])); + } + if read_u32_be(&frame_header[4..8]) != 0 { + if let Some(version) = pending_page_one.take() { + committed_page_one = Some(version); + } + } + } + + Ok(WalFileVersion::Valid(committed_page_one)) +} + +fn read_exact_frame_part(reader: &mut impl Read, buffer: &mut [u8]) -> Result { + match reader.read_exact(buffer) { + Ok(()) => Ok(true), + Err(error) if error.kind() == ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn extend_wal_checksum(bytes: &[u8], big_endian: bool, checksum: &mut [u32; 2]) { + debug_assert_eq!(bytes.len() % 8, 0); + for words in bytes.chunks_exact(8) { + let first = read_checksum_word(&words[..4], big_endian); + checksum[0] = checksum[0].wrapping_add(first).wrapping_add(checksum[1]); + let second = read_checksum_word(&words[4..], big_endian); + checksum[1] = checksum[1].wrapping_add(second).wrapping_add(checksum[0]); + } +} + +fn read_checksum_word(bytes: &[u8], big_endian: bool) -> u32 { + let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; + if big_endian { + u32::from_be_bytes(bytes) + } else { + u32::from_le_bytes(bytes) + } +} + +fn read_u32_be(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, @@ -213,6 +415,16 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(StoreError::InvalidDatabasePath); } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(StoreError::InvalidDatabasePath); + } + } + Ok(()) } @@ -234,21 +446,24 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_database_file(path: &Path) -> Result<(), StoreError> { - match fs::symlink_metadata(path) { - Ok(metadata) => validate_database_metadata(&metadata)?, +fn prepare_database_file(path: &Path) -> Result { + let state = match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + DatabaseFileState::Existing + } Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { - Ok(()) => {} - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Ok(()) => DatabaseFileState::Created, + Err(error) if error.kind() == ErrorKind::AlreadyExists => DatabaseFileState::Existing, Err(error) => return Err(StoreError::file_operation(error)), }, Err(error) => return Err(StoreError::file_operation(error)), - } + }; let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - Ok(()) + Ok(state) } fn database_open_path(path: &Path) -> Result { @@ -287,14 +502,15 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open}; + use super::{configure, open_read_write, prepare}; #[test] fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let (connection, _) = open(&path).unwrap(); + let (path, _) = prepare(&path).unwrap(); + let connection = open_read_write(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index e2ae267..6981bb9 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -32,20 +32,31 @@ impl Store { pub fn open(path: &Path) -> Result { let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); let _initialization_guard = initialization_guard(initialization_lock)?; - let (mut connection, database_path) = connection::open(path)?; + let (database_path, file_state) = connection::prepare(path)?; + connection::validate_sidecars(&database_path)?; - let found = - match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { + if file_state == connection::DatabaseFileState::Existing { + let preflight = connection::open_read_only(&database_path)?; + if let Some(found) = connection::file_user_version(&database_path)? { + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + } + let found = match preflight + .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + { Ok(found) => found, Err(error) => { connection::validate_sidecars(&database_path)?; return Err(error.into()); } }; - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } } + let mut connection = connection::open_read_write(&database_path)?; connection::enforce_database_permissions(&database_path)?; connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index f4a2571..ffc5158 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -120,6 +120,121 @@ fn future_database_version_fails_before_any_migration() { assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } +#[cfg(unix)] +#[test] +fn crash_left_wal_future_version_is_rejected_without_mutating_any_database_file() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + + run_crash_helper(&path, "wal-v99"); + + let sidecars = sqlite_sidecar_paths(&path); + let wal_path = &sidecars[1]; + let shm_path = &sidecars[2]; + assert!(wal_path.exists()); + assert!(shm_path.exists()); + assert_eq!(database_header_user_version(&path), 1); + + for file in [&path, wal_path, shm_path] { + set_mode(file, 0o644); + } + let before = [ + snapshot_file(&path), + snapshot_file(wal_path), + snapshot_file(shm_path), + ]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!( + &error, + StoreError::UnsupportedDatabaseVersion { found: 99, .. } + ), + "unexpected error: {error:?}" + ); + assert_eq!( + error.to_string(), + "unsupported database version 99; maximum supported version is 1" + ); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("WAL", &before[1], &snapshot_file(wal_path)); + assert_snapshot_unchanged("shared memory", &before[2], &snapshot_file(shm_path)); +} + +#[cfg(unix)] +#[test] +fn hot_journal_read_only_failure_does_not_recover_or_open_read_write() { + use std::error::Error; + + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + execute_batch( + &path, + " + CREATE TABLE hot_journal_seed ( + id INTEGER PRIMARY KEY, + payload BLOB NOT NULL + ) STRICT; + WITH RECURSIVE counter(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM counter WHERE value < 256 + ) + INSERT INTO hot_journal_seed (id, payload) + SELECT value, zeroblob(4096) FROM counter; + ", + ); + + run_crash_helper(&path, "hot-journal"); + + let sidecars = sqlite_sidecar_paths(&path); + let journal_path = &sidecars[0]; + let journal_contents = std::fs::read(journal_path).unwrap(); + assert!(journal_contents.len() > 512); + assert!(journal_contents[..8].iter().any(|byte| *byte != 0)); + set_mode(&path, 0o644); + set_mode(journal_path, 0o644); + let before = [snapshot_file(&path), snapshot_file(journal_path)]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!(&error, StoreError::DatabaseOperation), + "unexpected error: {error:?}" + ); + assert_eq!(error.to_string(), "store database operation failed"); + assert_eq!( + format!("{error:?}"), + "StoreError(store database operation failed)" + ); + assert!(error.source().is_none()); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("rollback journal", &before[1], &snapshot_file(journal_path)); +} + +#[test] +fn partially_applied_v1_transaction_rolls_back_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); + + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); + assert!(!table_exists(&path, "canonical_records")); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), 1); + drop(reopened); + assert_eq!(schema_migrations(&path).len(), 1); +} + #[test] fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); @@ -188,6 +303,8 @@ fn concurrent_first_open_applies_migration_once() { const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + set_mode(dir.path(), 0o700); for round in 0..ROUNDS { let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); let barrier = Arc::new(Barrier::new(THREADS)); @@ -327,24 +444,43 @@ fn future_database_sidecar_permissions_are_unchanged() { #[cfg(unix)] #[test] -fn existing_shared_parent_permissions_are_preserved() { +fn existing_shared_parent_is_rejected_without_changes() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, []).unwrap(); + std::fs::write(&path, b"not-a-database").unwrap(); set_mode(&parent, 0o755); set_mode(&path, 0o755); - drop(Store::open(&path).unwrap()); + assert_invalid_database_path(&path); assert_eq!(mode(&parent), 0o755); + assert_eq!(mode(&path), 0o755); + assert_eq!(std::fs::read(&path).unwrap(), b"not-a-database"); +} + +#[cfg(unix)] +#[test] +fn existing_private_parent_is_accepted_without_changing_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("existing"); + let path = parent.join("psyche.sqlite3"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(&path, []).unwrap(); + set_mode(&parent, 0o700); + set_mode(&path, 0o600); + + drop(Store::open(&path).unwrap()); + + assert_eq!(mode(&parent), 0o700); assert_private_file(&path); + assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); } #[cfg(unix)] #[test] -fn relative_filename_preserves_current_directory_permissions() { +fn relative_filename_rejects_shared_current_directory_without_changes() { use std::process::Command; let dir = tempfile::tempdir().unwrap(); @@ -363,7 +499,7 @@ fn relative_filename_preserves_current_directory_permissions() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(mode(dir.path()), 0o755); - assert_private_file(&dir.path().join("psyche.sqlite3")); + assert!(!dir.path().join("psyche.sqlite3").exists()); } #[cfg(unix)] @@ -373,7 +509,55 @@ fn relative_filename_open_helper() { return; } - drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); + assert_invalid_database_path(Path::new("psyche.sqlite3")); +} + +#[cfg(unix)] +#[test] +fn crash_left_database_helper() { + let Some(helper) = std::env::var_os("PSYCHE_STORE_CRASH_HELPER") else { + return; + }; + let path = PathBuf::from( + std::env::var_os("PSYCHE_STORE_CRASH_HELPER_PATH") + .unwrap_or_else(|| panic!("crash helper database path is missing")), + ); + let connection = Connection::open(&path).unwrap(); + + match helper.to_str() { + Some("wal-v99") => connection + .execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + PRAGMA synchronous = FULL; + BEGIN IMMEDIATE; + CREATE TABLE wal_future_marker ( + value TEXT NOT NULL + ) STRICT; + INSERT INTO wal_future_marker (value) VALUES ('future-in-wal'); + PRAGMA user_version = 99; + COMMIT; + ", + ) + .unwrap(), + Some("hot-journal") => connection + .execute_batch( + " + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA cache_size = 1; + PRAGMA cache_spill = ON; + BEGIN IMMEDIATE; + UPDATE hot_journal_seed + SET payload = randomblob(4096); + ", + ) + .unwrap(), + _ => panic!("unknown crash helper mode"), + } + + std::process::exit(0); } #[cfg(unix)] @@ -399,6 +583,7 @@ fn symlink_database_is_rejected_without_mutating_its_target() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); + set_mode(dir.path(), 0o700); let target = dir.path().join("target.sqlite3"); let path = dir.path().join("linked.sqlite3"); std::fs::write(&target, []).unwrap(); @@ -414,6 +599,58 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } +#[cfg(unix)] +fn run_crash_helper(path: &Path, helper: &str) { + use std::process::Command; + + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "crash_left_database_helper", "--nocapture"]) + .env("PSYCHE_STORE_CRASH_HELPER", helper) + .env("PSYCHE_STORE_CRASH_HELPER_PATH", path) + .output() + .unwrap(); + assert!( + output.status.success(), + "crash helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[derive(Debug, Eq, PartialEq)] +struct FileSnapshot { + contents: Vec, + len: u64, + modified: std::time::SystemTime, + mode: u32, +} + +#[cfg(unix)] +fn snapshot_file(path: &Path) -> FileSnapshot { + let contents = std::fs::read(path).unwrap(); + let metadata = std::fs::metadata(path).unwrap(); + FileSnapshot { + contents, + len: metadata.len(), + modified: metadata.modified().unwrap(), + mode: mode(path), + } +} + +#[cfg(unix)] +fn assert_snapshot_unchanged(label: &str, before: &FileSnapshot, after: &FileSnapshot) { + assert_eq!(after.len, before.len, "{label} length changed"); + assert_eq!(after.modified, before.modified, "{label} mtime changed"); + assert_eq!(after.mode, before.mode, "{label} mode changed"); + assert_eq!(after.contents, before.contents, "{label} contents changed"); +} + +#[cfg(unix)] +fn database_header_user_version(path: &Path) -> u32 { + let contents = std::fs::read(path).unwrap(); + u32::from_be_bytes(contents[60..64].try_into().unwrap()) +} + fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { sqlite_sidecar_paths(path) .into_iter() diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index aba1ff2..b2f8276 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::Connection; +use rusqlite::{Connection, TransactionBehavior}; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,18 +15,27 @@ pub(super) enum Fixture { Version0, Version1, Version99, + PartiallyAppliedV1, MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let name = match fixture { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", + Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let connection = Connection::open(&path).unwrap(); + let mut connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -64,6 +73,33 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), + Fixture::PartiallyAppliedV1 => { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Exclusive) + .unwrap(); + transaction + .execute_batch( + " + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) STRICT; + CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) + ) STRICT; + PRAGMA user_version = 1; + ", + ) + .unwrap(); + drop(transaction); + } Fixture::MigrationConflictV1 => connection .execute_batch( " From e1960fe4faa4674d3e763da51a4426812484e350 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:10:51 -0500 Subject: [PATCH 28/66] feat(store): add quarantine and safe retention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 120 +++ crates/psyche-store/Cargo.toml | 3 +- crates/psyche-store/src/error.rs | 38 + crates/psyche-store/src/execution_bindings.rs | 25 +- crates/psyche-store/src/lib.rs | 7 + crates/psyche-store/src/quarantine.rs | 866 ++++++++++++++++++ crates/psyche-store/src/records.rs | 137 +-- crates/psyche-store/src/retention.rs | 102 +++ crates/psyche-store/src/transitions.rs | 30 + crates/psyche-store/tests/retention.rs | 551 +++++++++++ 10 files changed, 1825 insertions(+), 54 deletions(-) create mode 100644 crates/psyche-store/src/quarantine.rs create mode 100644 crates/psyche-store/src/retention.rs create mode 100644 crates/psyche-store/tests/retention.rs diff --git a/Cargo.lock b/Cargo.lock index d98a76c..55769a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cc" version = "1.4.2" @@ -297,6 +303,30 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -382,6 +412,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -636,6 +677,7 @@ dependencies = [ "tempfile", "thiserror", "time", + "ulid", ] [[package]] @@ -767,6 +809,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -884,6 +932,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -1141,6 +1195,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "serde", + "web-time", +] + [[package]] name = "unarray" version = "0.1.4" @@ -1201,6 +1266,61 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index 944d034..544351e 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -11,12 +11,13 @@ publish.workspace = true psyche-core = { workspace = true } rusqlite = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } time = { workspace = true } +ulid = { workspace = true } [dev-dependencies] proptest = { workspace = true } -serde_json = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/psyche-store/src/error.rs b/crates/psyche-store/src/error.rs index fa08af1..15502c8 100644 --- a/crates/psyche-store/src/error.rs +++ b/crates/psyche-store/src/error.rs @@ -1,8 +1,11 @@ use std::fmt; use psyche_core::contracts::{ContractError, SchemaKind}; +use psyche_core::digest::Sha256Digest; use psyche_core::id::RecordId; +use crate::quarantine::QuarantineId; + /// A stable, payload-free store failure. #[derive(thiserror::Error)] #[non_exhaustive] @@ -66,6 +69,41 @@ pub enum StoreError { /// Conflicting one-based record version. record_version: u64, }, + /// A quarantine identifier did not use the strict `qua_` ULID shape. + #[error("quarantine identifier is invalid")] + InvalidQuarantineId, + /// Caller-supplied quarantine metadata was not safely bounded or validated. + #[error("quarantine record is invalid")] + InvalidQuarantineRecord, + /// An existing digest/reason pair carried different retained metadata. + #[error("quarantine content conflicts with stored metadata")] + QuarantineConflict { + /// Complete raw-payload digest shared by the conflicting requests. + payload_digest: Sha256Digest, + }, + /// No quarantine row has the requested validated identity. + #[error("quarantine record was not found")] + QuarantineNotFound { + /// Validated identity that was not found. + quarantine_id: QuarantineId, + }, + /// A resolution timestamp was non-UTC or earlier than discovery. + #[error("quarantine resolution is invalid")] + InvalidQuarantineResolution { + /// Quarantine row the invalid resolution targeted. + quarantine_id: QuarantineId, + }, + /// A quarantine row already has a different durable resolution. + #[error("quarantine resolution conflicts with stored resolution")] + QuarantineResolutionConflict { + /// Quarantine row that already has a winner. + quarantine_id: QuarantineId, + /// Digest of the competing resolution. + resolution_digest: Sha256Digest, + }, + /// A retention cutoff was not expressed in UTC. + #[error("retention cutoff is invalid")] + InvalidRetentionCutoff, /// Persisted rows failed canonical or revision-chain integrity validation. #[error("stored database content failed integrity validation")] DatabaseCorruption, diff --git a/crates/psyche-store/src/execution_bindings.rs b/crates/psyche-store/src/execution_bindings.rs index 36dad81..25495fb 100644 --- a/crates/psyche-store/src/execution_bindings.rs +++ b/crates/psyche-store/src/execution_bindings.rs @@ -2,7 +2,9 @@ use psyche_core::contracts::execution::{ CancellationAcknowledgementEvidence, CancellationState, CancellationUnresolvedEvidence, TerminationRequestCorrelation, }; -use psyche_core::contracts::{CanonicalDocument, ContractError, ExecutionBinding, SchemaKind}; +use psyche_core::contracts::{ + CanonicalDocument, ContractError, ExecutionBinding, RecordKind, SchemaKind, +}; use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::RecordId; use rusqlite::{Connection, TransactionBehavior, params}; @@ -117,6 +119,27 @@ pub(crate) fn latest_canonical_bytes( .map(|revision| revision.canonical_json)) } +pub(crate) fn validate_all(connection: &Connection) -> Result<(), StoreError> { + let mut statement = connection.prepare( + " + SELECT DISTINCT attempt_id + FROM execution_binding_revisions + ORDER BY attempt_id + ", + )?; + let attempt_ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption)?; + for attempt_id in attempt_ids { + let attempt_id = RecordId::parse(RecordKind::Attempt, &attempt_id) + .map_err(|_| StoreError::DatabaseCorruption)?; + let stored = load_stored_revisions(connection, &attempt_id)?; + validate_revision_chain(stored, &attempt_id)?; + } + Ok(()) +} + fn load_stored_revisions( connection: &Connection, attempt_id: &RecordId, diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 6981bb9..824a282 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -4,7 +4,9 @@ mod connection; mod error; mod execution_bindings; mod migrations; +mod quarantine; mod records; +mod retention; mod transitions; use std::{ @@ -16,7 +18,12 @@ use rusqlite::TransactionBehavior; pub use error::StoreError; pub use migrations::CURRENT_DATABASE_VERSION; +pub use quarantine::{ + AuditEvent, QuarantineId, QuarantineReasonCode, QuarantineRecord, QuarantineResolution, + QuarantineResolutionCode, ResolveQuarantineOutcome, +}; pub use records::IngestOutcome; +pub use retention::PruneReport; pub use transitions::Transition; /// A configured connection to Psyche's durable SQLite substrate. diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs new file mode 100644 index 0000000..6f2e408 --- /dev/null +++ b/crates/psyche-store/src/quarantine.rs @@ -0,0 +1,866 @@ +use std::fmt; + +use psyche_core::contracts::{RejectedDocument, RejectionReason}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use time::format_description::well_known::Rfc3339; + +use crate::{Store, StoreError}; + +const QUARANTINE_PREFIX: &str = "qua_"; +const ULID_LEN: usize = 26; +const MAX_BOUNDED_PAYLOAD_BYTES: usize = 64 * 1024; +const MAX_SCHEMA_VERSION_BYTES: usize = 128; +const QUARANTINE_RESOLVED_EVENT: &str = "quarantine_resolved"; + +/// A validated `qua_` identifier with one canonical uppercase ULID suffix. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct QuarantineId(String); + +impl QuarantineId { + /// Generates a new quarantine identity. + pub fn new() -> Self { + Self(format!("{QUARANTINE_PREFIX}{}", ulid::Ulid::new())) + } + + /// Strictly parses a canonical quarantine identity. + pub fn parse(value: &str) -> Result { + let Some(suffix) = value.strip_prefix(QUARANTINE_PREFIX) else { + return Err(StoreError::InvalidQuarantineId); + }; + if suffix.len() != ULID_LEN { + return Err(StoreError::InvalidQuarantineId); + } + let parsed = suffix + .parse::() + .map_err(|_| StoreError::InvalidQuarantineId)?; + if parsed.to_string() != suffix { + return Err(StoreError::InvalidQuarantineId); + } + Ok(Self(value.to_owned())) + } + + /// Returns the complete validated identifier. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for QuarantineId { + fn default() -> Self { + Self::new() + } +} + +impl TryFrom for QuarantineId { + type Error = StoreError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: QuarantineId) -> Self { + value.0 + } +} + +/// Stable payload-free classification persisted with a quarantine row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuarantineReasonCode { + /// The complete input exceeded the accepted document bound. + TooLarge, + /// The declared schema kind is unknown. + UnknownSchema, + /// The schema kind is known but its major is unsupported. + UnsupportedMajor, + /// A typed enum field used an unknown spelling. + UnknownEnumValue, + /// The typed document shape was invalid. + InvalidShape, +} + +impl QuarantineReasonCode { + fn as_str(self) -> &'static str { + match self { + Self::TooLarge => "too_large", + Self::UnknownSchema => "unknown_schema", + Self::UnsupportedMajor => "unsupported_major", + Self::UnknownEnumValue => "unknown_enum_value", + Self::InvalidShape => "invalid_shape", + } + } + + fn parse(value: &str) -> Result { + match value { + "too_large" => Ok(Self::TooLarge), + "unknown_schema" => Ok(Self::UnknownSchema), + "unsupported_major" => Ok(Self::UnsupportedMajor), + "unknown_enum_value" => Ok(Self::UnknownEnumValue), + "invalid_shape" => Ok(Self::InvalidShape), + _ => Err(StoreError::DatabaseCorruption), + } + } +} + +impl From<&RejectionReason> for QuarantineReasonCode { + fn from(reason: &RejectionReason) -> Self { + match reason { + RejectionReason::TooLarge => Self::TooLarge, + RejectionReason::UnknownSchema => Self::UnknownSchema, + RejectionReason::UnsupportedMajor { .. } => Self::UnsupportedMajor, + RejectionReason::UnknownEnumValue { .. } => Self::UnknownEnumValue, + RejectionReason::InvalidShape { .. } => Self::InvalidShape, + } + } +} + +/// Stable resolution classification for one quarantine row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuarantineResolutionCode { + /// A newer schema implementation can now decode the bytes. + SchemaNowSupported, + /// The bytes were confirmed to be invalid. + ConfirmedInvalid, + /// The bytes duplicate another durable payload. + DuplicatePayload, +} + +impl QuarantineResolutionCode { + fn as_str(self) -> &'static str { + match self { + Self::SchemaNowSupported => "schema_now_supported", + Self::ConfirmedInvalid => "confirmed_invalid", + Self::DuplicatePayload => "duplicate_payload", + } + } + + fn parse(value: &str) -> Result { + match value { + "schema_now_supported" => Ok(Self::SchemaNowSupported), + "confirmed_invalid" => Ok(Self::ConfirmedInvalid), + "duplicate_payload" => Ok(Self::DuplicatePayload), + _ => Err(StoreError::DatabaseCorruption), + } + } +} + +/// A requested terminal resolution for one quarantine row. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuarantineResolution { + /// Stable terminal classification. + pub code: QuarantineResolutionCode, + /// UTC time at which the resolution became authoritative. + #[serde(with = "time::serde::rfc3339")] + pub resolved_at: time::OffsetDateTime, +} + +/// Durable result of resolving one quarantine row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveQuarantineOutcome { + /// This request durably established the first resolution. + Resolved { + /// Canonical digest of the durable resolution. + resolution_digest: Sha256Digest, + }, + /// The exact same resolution was already durable. + AlreadyResolved { + /// Canonical digest shared by the replay and stored resolution. + resolution_digest: Sha256Digest, + }, +} + +/// One validated persisted quarantine row. +#[derive(Clone, PartialEq, Eq)] +pub struct QuarantineRecord { + /// Durable quarantine identity. + pub quarantine_id: QuarantineId, + /// Safely bounded schema text extracted from the raw input. + pub schema_version: Option, + /// SHA-256 digest over the complete raw input. + pub payload_digest: Sha256Digest, + /// At most 64 KiB retained from the beginning of the raw input. + pub bounded_payload: Vec, + /// Stable payload-free rejection classification. + pub reason: QuarantineReasonCode, + /// Canonical UTC discovery time. + pub discovered_at: time::OffsetDateTime, + /// Canonical UTC terminal resolution time, when resolved. + pub resolved_at: Option, + /// Stable terminal resolution classification, when resolved. + pub resolution_code: Option, + /// Canonical terminal resolution digest, when resolved. + pub resolution_digest: Option, +} + +impl fmt::Debug for QuarantineRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("QuarantineRecord") + .field("quarantine_id", &self.quarantine_id) + .field("schema_version", &self.schema_version) + .field("payload_digest", &self.payload_digest) + .field("bounded_payload_bytes", &self.bounded_payload.len()) + .field("reason", &self.reason) + .field("discovered_at", &self.discovered_at) + .field("resolved_at", &self.resolved_at) + .field("resolution_code", &self.resolution_code) + .field("resolution_digest", &self.resolution_digest) + .finish() + } +} + +/// One validated redacted audit row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditEvent { + /// Monotonic database sequence. + pub sequence: u64, + /// Stable event classification. + pub event_code: String, + /// Validated payload-free event correlation identity. + pub correlation_id: String, + /// Canonical JSON containing only redacted public metadata. + pub public_details_json: Vec, + /// Canonical UTC event time. + pub created_at: time::OffsetDateTime, +} + +struct StoredQuarantineRecord { + quarantine_id: String, + schema_version: Option, + payload_digest: String, + bounded_payload: Vec, + reason: String, + discovered_at: String, + resolved_at: Option, + resolution_code: Option, + resolution_digest: Option, +} + +struct StoredAuditEvent { + sequence: i64, + event_code: String, + correlation_id: String, + public_details_json: Vec, + created_at: String, +} + +#[derive(serde::Serialize)] +struct ResolutionDigestInput<'a> { + quarantine_id: &'a QuarantineId, + payload_digest: &'a Sha256Digest, + reason: QuarantineReasonCode, + resolution_code: QuarantineResolutionCode, + #[serde(with = "time::serde::rfc3339")] + resolved_at: time::OffsetDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct QuarantineResolvedAuditDetails { + quarantine_id: QuarantineId, + payload_digest: Sha256Digest, + reason: QuarantineReasonCode, + resolution_code: QuarantineResolutionCode, + #[serde(with = "time::serde::rfc3339")] + resolved_at: time::OffsetDateTime, + resolution_digest: Sha256Digest, +} + +impl Store { + /// Validates and durably retains one bounded rejected document. + pub fn quarantine(&mut self, rejected: RejectedDocument) -> Result { + validate_rejected(&rejected)?; + let reason = QuarantineReasonCode::from(&rejected.reason); + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = stored_by_digest_and_reason( + &transaction, + rejected.payload_digest.as_str(), + reason.as_str(), + )?; + if existing.len() > 1 { + return Err(StoreError::DatabaseCorruption); + } + if let Some(stored) = existing.into_iter().next() { + let record = validate_stored(stored)?; + if record.schema_version == rejected.schema_version + && record.payload_digest == rejected.payload_digest + && record.bounded_payload == rejected.bounded_payload + && record.reason == reason + { + transaction.commit()?; + return Ok(record.quarantine_id); + } + return Err(StoreError::QuarantineConflict { + payload_digest: rejected.payload_digest, + }); + } + + let quarantine_id = QuarantineId::new(); + let discovered_at = time::OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| StoreError::InvalidQuarantineRecord)?; + transaction.execute( + " + INSERT INTO quarantine_records ( + quarantine_id, + schema_version, + payload_digest, + bounded_payload, + reason, + discovered_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", + params![ + quarantine_id.as_str(), + rejected.schema_version.as_deref(), + rejected.payload_digest.as_str(), + rejected.bounded_payload, + reason.as_str(), + discovered_at, + ], + )?; + let stored = stored_by_id(&transaction, quarantine_id.as_str())? + .ok_or(StoreError::DatabaseCorruption)?; + let persisted = validate_stored(stored)?; + if persisted.quarantine_id != quarantine_id { + return Err(StoreError::DatabaseCorruption); + } + transaction.commit()?; + Ok(quarantine_id) + } + + /// Loads and validates one quarantine row by exact identity. + pub fn quarantine_record( + &self, + id: &QuarantineId, + ) -> Result, StoreError> { + validate_typed_id(id)?; + let Some(stored) = stored_by_id(&self.connection, id.as_str())? else { + return Ok(None); + }; + let record = validate_stored(stored)?; + validate_record_audit(&self.connection, &record)?; + Ok(Some(record)) + } + + /// Atomically establishes or exactly replays one quarantine resolution. + pub fn resolve_quarantine( + &mut self, + id: &QuarantineId, + resolution: &QuarantineResolution, + ) -> Result { + validate_typed_id(id)?; + if resolution.resolved_at.offset() != time::UtcOffset::UTC { + return Err(StoreError::InvalidQuarantineResolution { + quarantine_id: id.clone(), + }); + } + let resolved_at = resolution.resolved_at.format(&Rfc3339).map_err(|_| { + StoreError::InvalidQuarantineResolution { + quarantine_id: id.clone(), + } + })?; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let stored = stored_by_id(&transaction, id.as_str())?.ok_or_else(|| { + StoreError::QuarantineNotFound { + quarantine_id: id.clone(), + } + })?; + let record = validate_stored(stored)?; + validate_record_audit(&transaction, &record)?; + if resolution.resolved_at < record.discovered_at { + return Err(StoreError::InvalidQuarantineResolution { + quarantine_id: id.clone(), + }); + } + let resolution_digest = compute_resolution_digest( + id, + &record.payload_digest, + record.reason, + resolution.code, + resolution.resolved_at, + ) + .map_err(StoreError::from)?; + + if record.resolved_at.is_some() { + let outcome = replay_or_conflict(id, &record, resolution, &resolution_digest)?; + transaction.commit()?; + return Ok(outcome); + } + + let updated = transaction.execute( + " + UPDATE quarantine_records + SET resolved_at = ?1, + resolution_code = ?2, + resolution_digest = ?3 + WHERE quarantine_id = ?4 + AND resolved_at IS NULL + AND resolution_code IS NULL + AND resolution_digest IS NULL + ", + params![ + resolved_at, + resolution.code.as_str(), + resolution_digest.as_str(), + id.as_str(), + ], + )?; + if updated == 0 { + let reloaded = stored_by_id(&transaction, id.as_str())? + .ok_or(StoreError::DatabaseCorruption) + .and_then(validate_stored)?; + validate_record_audit(&transaction, &reloaded)?; + let outcome = replay_or_conflict(id, &reloaded, resolution, &resolution_digest)?; + transaction.commit()?; + return Ok(outcome); + } + if updated != 1 { + return Err(StoreError::DatabaseCorruption); + } + + let details = QuarantineResolvedAuditDetails { + quarantine_id: id.clone(), + payload_digest: record.payload_digest, + reason: record.reason, + resolution_code: resolution.code, + resolved_at: resolution.resolved_at, + resolution_digest: resolution_digest.clone(), + }; + let public_details_json = canonical_bytes(&details)?; + transaction.execute( + " + INSERT INTO audit_events ( + event_code, + correlation_id, + public_details_json, + created_at + ) + VALUES (?1, ?2, ?3, ?4) + ", + params![ + QUARANTINE_RESOLVED_EVENT, + id.as_str(), + public_details_json, + resolved_at, + ], + )?; + let resolved = stored_by_id(&transaction, id.as_str())? + .ok_or(StoreError::DatabaseCorruption) + .and_then(validate_stored)?; + validate_record_audit(&transaction, &resolved)?; + transaction.commit()?; + Ok(ResolveQuarantineOutcome::Resolved { resolution_digest }) + } + + /// Returns every validated redacted audit event in sequence order. + pub fn audit_events(&self) -> Result, StoreError> { + audit_events_from_connection(&self.connection) + } +} + +pub(crate) fn all_records(connection: &Connection) -> Result, StoreError> { + let records = load_all_stored(connection)? + .into_iter() + .map(validate_stored) + .collect::, _>>()?; + let mut identities = std::collections::HashSet::with_capacity(records.len()); + let mut digest_reasons = std::collections::HashSet::with_capacity(records.len()); + for record in &records { + if !identities.insert(record.quarantine_id.clone()) + || !digest_reasons.insert((record.payload_digest.clone(), record.reason)) + { + return Err(StoreError::DatabaseCorruption); + } + validate_record_audit(connection, record)?; + } + Ok(records) +} + +pub(crate) fn audit_events_from_connection( + connection: &Connection, +) -> Result, StoreError> { + load_all_audit_events(connection)? + .into_iter() + .map(validate_audit_event) + .collect() +} + +fn validate_typed_id(id: &QuarantineId) -> Result<(), StoreError> { + if matches!(QuarantineId::parse(id.as_str()), Ok(parsed) if parsed == *id) { + Ok(()) + } else { + Err(StoreError::InvalidQuarantineId) + } +} + +fn validate_rejected(rejected: &RejectedDocument) -> Result<(), StoreError> { + if rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + || !rejected + .schema_version + .as_deref() + .is_none_or(schema_version_is_safe) + || Sha256Digest::parse(rejected.payload_digest.as_str()).is_err() + { + return Err(StoreError::InvalidQuarantineRecord); + } + + if rejected.bounded_payload.len() < MAX_BOUNDED_PAYLOAD_BYTES { + let reconstructed = + RejectedDocument::from_bytes(&rejected.bounded_payload, rejected.reason.clone()); + if reconstructed.payload_digest != rejected.payload_digest + || reconstructed.schema_version != rejected.schema_version + { + return Err(StoreError::InvalidQuarantineRecord); + } + } + Ok(()) +} + +fn schema_version_is_safe(value: &str) -> bool { + value.len() <= MAX_SCHEMA_VERSION_BYTES + && value.starts_with("psyche.") + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_') + }) +} + +fn stored_by_id( + connection: &Connection, + id: &str, +) -> Result, StoreError> { + connection + .query_row( + " + SELECT + quarantine_id, + schema_version, + payload_digest, + bounded_payload, + reason, + discovered_at, + resolved_at, + resolution_code, + resolution_digest + FROM quarantine_records + WHERE quarantine_id = ?1 + ", + [id], + stored_quarantine_from_row, + ) + .optional() + .map_err(|_| StoreError::DatabaseCorruption) +} + +fn stored_by_digest_and_reason( + connection: &Connection, + payload_digest: &str, + reason: &str, +) -> Result, StoreError> { + let mut statement = connection.prepare( + " + SELECT + quarantine_id, + schema_version, + payload_digest, + bounded_payload, + reason, + discovered_at, + resolved_at, + resolution_code, + resolution_digest + FROM quarantine_records + WHERE payload_digest = ?1 AND reason = ?2 + ORDER BY quarantine_id + ", + )?; + statement + .query_map(params![payload_digest, reason], stored_quarantine_from_row)? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption) +} + +fn load_all_stored(connection: &Connection) -> Result, StoreError> { + let mut statement = connection.prepare( + " + SELECT + quarantine_id, + schema_version, + payload_digest, + bounded_payload, + reason, + discovered_at, + resolved_at, + resolution_code, + resolution_digest + FROM quarantine_records + ORDER BY quarantine_id + ", + )?; + statement + .query_map([], stored_quarantine_from_row)? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption) +} + +fn stored_quarantine_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredQuarantineRecord { + quarantine_id: row.get(0)?, + schema_version: row.get(1)?, + payload_digest: row.get(2)?, + bounded_payload: row.get(3)?, + reason: row.get(4)?, + discovered_at: row.get(5)?, + resolved_at: row.get(6)?, + resolution_code: row.get(7)?, + resolution_digest: row.get(8)?, + }) +} + +fn validate_stored(stored: StoredQuarantineRecord) -> Result { + let quarantine_id = + QuarantineId::parse(&stored.quarantine_id).map_err(|_| StoreError::DatabaseCorruption)?; + if !stored + .schema_version + .as_deref() + .is_none_or(schema_version_is_safe) + || stored.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + { + return Err(StoreError::DatabaseCorruption); + } + let payload_digest = + Sha256Digest::parse(&stored.payload_digest).map_err(|_| StoreError::DatabaseCorruption)?; + let reason = QuarantineReasonCode::parse(&stored.reason)?; + let discovered_at = parse_canonical_utc(&stored.discovered_at)?; + + let resolution_columns = ( + stored.resolved_at.as_deref(), + stored.resolution_code.as_deref(), + stored.resolution_digest.as_deref(), + ); + let (resolved_at, resolution_code, resolution_digest) = match resolution_columns { + (None, None, None) => (None, None, None), + (Some(resolved_at), Some(resolution_code), Some(resolution_digest)) => { + let resolved_at = parse_canonical_utc(resolved_at)?; + if resolved_at < discovered_at { + return Err(StoreError::DatabaseCorruption); + } + let resolution_code = QuarantineResolutionCode::parse(resolution_code)?; + let resolution_digest = Sha256Digest::parse(resolution_digest) + .map_err(|_| StoreError::DatabaseCorruption)?; + let recomputed = compute_resolution_digest( + &quarantine_id, + &payload_digest, + reason, + resolution_code, + resolved_at, + ) + .map_err(|_| StoreError::DatabaseCorruption)?; + if recomputed != resolution_digest { + return Err(StoreError::DatabaseCorruption); + } + ( + Some(resolved_at), + Some(resolution_code), + Some(resolution_digest), + ) + } + _ => return Err(StoreError::DatabaseCorruption), + }; + + Ok(QuarantineRecord { + quarantine_id, + schema_version: stored.schema_version, + payload_digest, + bounded_payload: stored.bounded_payload, + reason, + discovered_at, + resolved_at, + resolution_code, + resolution_digest, + }) +} + +fn compute_resolution_digest( + quarantine_id: &QuarantineId, + payload_digest: &Sha256Digest, + reason: QuarantineReasonCode, + resolution_code: QuarantineResolutionCode, + resolved_at: time::OffsetDateTime, +) -> Result { + digest(&ResolutionDigestInput { + quarantine_id, + payload_digest, + reason, + resolution_code, + resolved_at, + }) +} + +fn replay_or_conflict( + id: &QuarantineId, + stored: &QuarantineRecord, + requested: &QuarantineResolution, + requested_digest: &Sha256Digest, +) -> Result { + if stored.resolved_at == Some(requested.resolved_at) + && stored.resolution_code == Some(requested.code) + && stored.resolution_digest.as_ref() == Some(requested_digest) + { + return Ok(ResolveQuarantineOutcome::AlreadyResolved { + resolution_digest: requested_digest.clone(), + }); + } + Err(StoreError::QuarantineResolutionConflict { + quarantine_id: id.clone(), + resolution_digest: requested_digest.clone(), + }) +} + +fn load_all_audit_events(connection: &Connection) -> Result, StoreError> { + let mut statement = connection.prepare( + " + SELECT sequence, event_code, correlation_id, public_details_json, created_at + FROM audit_events + ORDER BY sequence + ", + )?; + statement + .query_map([], |row| { + Ok(StoredAuditEvent { + sequence: row.get(0)?, + event_code: row.get(1)?, + correlation_id: row.get(2)?, + public_details_json: row.get(3)?, + created_at: row.get(4)?, + }) + })? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption) +} + +fn validate_audit_event(stored: StoredAuditEvent) -> Result { + validate_audit_event_with_details(stored).map(|(event, _details)| event) +} + +fn validate_audit_event_with_details( + stored: StoredAuditEvent, +) -> Result<(AuditEvent, QuarantineResolvedAuditDetails), StoreError> { + let sequence = u64::try_from(stored.sequence).map_err(|_| StoreError::DatabaseCorruption)?; + if sequence == 0 || stored.event_code != QUARANTINE_RESOLVED_EVENT { + return Err(StoreError::DatabaseCorruption); + } + let correlation_id = + QuarantineId::parse(&stored.correlation_id).map_err(|_| StoreError::DatabaseCorruption)?; + let details: QuarantineResolvedAuditDetails = + serde_json::from_slice(&stored.public_details_json) + .map_err(|_| StoreError::DatabaseCorruption)?; + let canonical_details = + canonical_bytes(&details).map_err(|_| StoreError::DatabaseCorruption)?; + let created_at = parse_canonical_utc(&stored.created_at)?; + let recomputed = compute_resolution_digest( + &details.quarantine_id, + &details.payload_digest, + details.reason, + details.resolution_code, + details.resolved_at, + ) + .map_err(|_| StoreError::DatabaseCorruption)?; + if canonical_details != stored.public_details_json + || details.quarantine_id != correlation_id + || details.resolution_digest != recomputed + || details.resolved_at != created_at + || details.resolved_at.offset() != time::UtcOffset::UTC + { + return Err(StoreError::DatabaseCorruption); + } + + Ok(( + AuditEvent { + sequence, + event_code: stored.event_code, + correlation_id: stored.correlation_id, + public_details_json: stored.public_details_json, + created_at, + }, + details, + )) +} + +fn validate_record_audit( + connection: &Connection, + record: &QuarantineRecord, +) -> Result<(), StoreError> { + let mut statement = connection.prepare( + " + SELECT sequence, event_code, correlation_id, public_details_json, created_at + FROM audit_events + WHERE correlation_id = ?1 + ORDER BY sequence + ", + )?; + let stored = statement + .query_map([record.quarantine_id.as_str()], |row| { + Ok(StoredAuditEvent { + sequence: row.get(0)?, + event_code: row.get(1)?, + correlation_id: row.get(2)?, + public_details_json: row.get(3)?, + created_at: row.get(4)?, + }) + })? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption)?; + let validated = stored + .into_iter() + .map(validate_audit_event_with_details) + .collect::, _>>()?; + + match ( + record.resolved_at, + record.resolution_code, + record.resolution_digest.as_ref(), + ) { + (None, None, None) if validated.is_empty() => Ok(()), + (Some(resolved_at), Some(resolution_code), Some(resolution_digest)) + if validated.len() == 1 => + { + let details = &validated[0].1; + if details.quarantine_id == record.quarantine_id + && details.payload_digest == record.payload_digest + && details.reason == record.reason + && details.resolution_code == resolution_code + && details.resolved_at == resolved_at + && &details.resolution_digest == resolution_digest + { + Ok(()) + } else { + Err(StoreError::DatabaseCorruption) + } + } + _ => Err(StoreError::DatabaseCorruption), + } +} + +fn parse_canonical_utc(value: &str) -> Result { + let timestamp = + time::OffsetDateTime::parse(value, &Rfc3339).map_err(|_| StoreError::DatabaseCorruption)?; + let canonical = timestamp + .format(&Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + if timestamp.offset() != time::UtcOffset::UTC || canonical != value { + return Err(StoreError::DatabaseCorruption); + } + Ok(timestamp) +} diff --git a/crates/psyche-store/src/records.rs b/crates/psyche-store/src/records.rs index fd9db25..4044041 100644 --- a/crates/psyche-store/src/records.rs +++ b/crates/psyche-store/src/records.rs @@ -1,6 +1,5 @@ use psyche_core::contracts::{ - CanonicalDocument, ContractError, RejectedDocument, RejectionReason, SchemaKind, - decode_document, + CanonicalDocument, ContractError, RejectedDocument, SchemaKind, decode_document, }; use psyche_core::digest::{canonical_bytes, digest}; use psyche_core::id::RecordId; @@ -17,14 +16,17 @@ struct StoredCanonicalRecord { } /// Result of ingesting bytes at the store boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum IngestOutcome { /// A new canonical record was persisted. Inserted, /// The exact canonical record was already present. AlreadyPresent, /// Unsupported bytes were retained only in quarantine. - Quarantined, + Quarantined { + /// Validated identity of the retained quarantine row. + quarantine_id: crate::QuarantineId, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,8 +49,8 @@ impl Store { | ContractError::UnknownEnumValue { .. }), ) => { let rejected = RejectedDocument::from_decode_error(bytes, error); - self.quarantine_decode_rejection(&rejected)?; - Ok(IngestOutcome::Quarantined) + let quarantine_id = self.quarantine(rejected)?; + Ok(IngestOutcome::Quarantined { quarantine_id }) } Err(error) => Err(StoreError::Contract(error)), } @@ -182,52 +184,6 @@ impl Store { transaction.commit()?; Ok(InsertStatus::Inserted) } - - fn quarantine_decode_rejection( - &mut self, - rejected: &RejectedDocument, - ) -> Result<(), StoreError> { - let reason = match rejected.reason { - RejectionReason::TooLarge => "too_large", - RejectionReason::UnknownSchema => "unknown_schema", - RejectionReason::UnsupportedMajor { .. } => "unsupported_major", - RejectionReason::UnknownEnumValue { .. } => "unknown_enum_value", - RejectionReason::InvalidShape { .. } => "invalid_shape", - }; - let transaction = self - .connection - .transaction_with_behavior(TransactionBehavior::Immediate)?; - transaction.execute( - " - INSERT INTO quarantine_records ( - quarantine_id, - schema_version, - payload_digest, - bounded_payload, - reason, - discovered_at - ) - VALUES ( - ?1, - ?2, - ?3, - ?4, - ?5, - strftime('%Y-%m-%dT%H:%M:%fZ', 'now') - ) - ON CONFLICT(quarantine_id) DO NOTHING - ", - params![ - rejected.payload_digest.as_str(), - rejected.schema_version.as_deref(), - rejected.payload_digest.as_str(), - &rejected.bounded_payload, - reason, - ], - )?; - transaction.commit()?; - Ok(()) - } } fn stored_canonical_record( @@ -301,6 +257,83 @@ pub(crate) fn kind_key(kind: SchemaKind) -> &'static str { } } +pub(crate) fn parse_kind_key(value: &str) -> Result { + match value { + "identity_snapshot" => Ok(SchemaKind::IdentitySnapshot), + "intent" => Ok(SchemaKind::Intent), + "surface_event" => Ok(SchemaKind::SurfaceEvent), + "graph" => Ok(SchemaKind::Graph), + "graph_node" => Ok(SchemaKind::GraphNode), + "delegation" => Ok(SchemaKind::Delegation), + "budget" => Ok(SchemaKind::Budget), + "approval" => Ok(SchemaKind::Approval), + "execution_binding" => Ok(SchemaKind::ExecutionBinding), + "evidence" => Ok(SchemaKind::Evidence), + "verdict" => Ok(SchemaKind::Verdict), + "recovery" => Ok(SchemaKind::Recovery), + "addon" => Ok(SchemaKind::Addon), + "surface_effect" => Ok(SchemaKind::SurfaceEffect), + "delivery" => Ok(SchemaKind::Delivery), + "error" => Ok(SchemaKind::Error), + _ => Err(StoreError::DatabaseCorruption), + } +} + +pub(crate) fn schema_kind_for_id(id: &RecordId) -> SchemaKind { + use psyche_core::contracts::RecordKind; + + match id.kind() { + RecordKind::IdentitySnapshot => SchemaKind::IdentitySnapshot, + RecordKind::Intent => SchemaKind::Intent, + RecordKind::Graph => SchemaKind::Graph, + RecordKind::GraphNode => SchemaKind::GraphNode, + RecordKind::Attempt => SchemaKind::ExecutionBinding, + RecordKind::Delegation => SchemaKind::Delegation, + RecordKind::Budget => SchemaKind::Budget, + RecordKind::Approval => SchemaKind::Approval, + RecordKind::Evidence => SchemaKind::Evidence, + RecordKind::Verdict => SchemaKind::Verdict, + RecordKind::Recovery => SchemaKind::Recovery, + RecordKind::Addon => SchemaKind::Addon, + RecordKind::SurfaceEvent => SchemaKind::SurfaceEvent, + RecordKind::SurfaceEffect => SchemaKind::SurfaceEffect, + RecordKind::Delivery => SchemaKind::Delivery, + } +} + +pub(crate) fn validate_all(connection: &rusqlite::Connection) -> Result<(), StoreError> { + let mut statement = connection.prepare( + " + SELECT kind, record_id, schema_version, digest, canonical_json + FROM canonical_records + ORDER BY kind, record_id + ", + )?; + let rows = statement + .query_map([], |row| { + Ok(StoredCanonicalRecord { + kind: row.get(0)?, + record_id: row.get(1)?, + schema_version: row.get(2)?, + digest: row.get(3)?, + canonical_json: row.get(4)?, + }) + })? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption)?; + for row in rows { + let kind = parse_kind_key(&row.kind)?; + if kind == SchemaKind::ExecutionBinding { + return Err(StoreError::DatabaseCorruption); + } + let record_kind = kind.record_kind().ok_or(StoreError::DatabaseCorruption)?; + let id = RecordId::parse(record_kind, &row.record_id) + .map_err(|_| StoreError::DatabaseCorruption)?; + validate_stored_canonical_record(&row, kind, &id)?; + } + Ok(()) +} + pub(crate) fn validate_kind_id(kind: SchemaKind, id: &RecordId) -> Result<(), StoreError> { let expected = kind .record_kind() diff --git a/crates/psyche-store/src/retention.rs b/crates/psyche-store/src/retention.rs new file mode 100644 index 0000000..3aaa7fc --- /dev/null +++ b/crates/psyche-store/src/retention.rs @@ -0,0 +1,102 @@ +use rusqlite::{TransactionBehavior, params}; +use time::format_description::well_known::Rfc3339; + +use crate::{Store, StoreError, execution_bindings, quarantine, records, transitions}; + +/// Counts returned by one conservative retention pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PruneReport { + /// Fully resolved quarantine rows older than the exact cutoff. + pub resolved_quarantine_deleted: u64, + /// Always zero: automated retention never deletes unresolved quarantine. + pub unresolved_quarantine_deleted: u64, + /// Always zero: immutable execution-binding history is retained. + pub execution_binding_revisions_deleted: u64, + /// Always zero: immutable transition history is retained. + pub transitions_deleted: u64, + /// Always zero: opaque audit correlations are retained. + pub audit_events_deleted: u64, +} + +impl Store { + /// Deletes only fully resolved quarantine rows strictly older than `cutoff`. + pub fn prune(&mut self, cutoff: time::OffsetDateTime) -> Result { + if cutoff.offset() != time::UtcOffset::UTC { + return Err(StoreError::InvalidRetentionCutoff); + } + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + + records::validate_all(&transaction)?; + execution_bindings::validate_all(&transaction)?; + transitions::validate_all(&transaction)?; + let quarantine = quarantine::all_records(&transaction)?; + quarantine::audit_events_from_connection(&transaction)?; + + let mut resolved_quarantine_deleted = 0_u64; + for record in quarantine { + let (Some(resolved_at), Some(resolution_code), Some(resolution_digest)) = ( + record.resolved_at, + record.resolution_code, + record.resolution_digest, + ) else { + continue; + }; + if resolved_at >= cutoff { + continue; + } + let resolved_at = resolved_at + .format(&Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + let deleted = transaction.execute( + " + DELETE FROM quarantine_records + WHERE quarantine_id = ?1 + AND resolved_at = ?2 + AND resolution_code = ?3 + AND resolution_digest = ?4 + ", + params![ + record.quarantine_id.as_str(), + resolved_at, + resolution_code_key(resolution_code), + resolution_digest.as_str(), + ], + )?; + if deleted != 1 { + return Err(StoreError::DatabaseCorruption); + } + resolved_quarantine_deleted = resolved_quarantine_deleted + .checked_add(u64::try_from(deleted).map_err(|_| StoreError::DatabaseOperation)?) + .ok_or(StoreError::DatabaseOperation)?; + } + + transaction.commit()?; + Ok(PruneReport { + resolved_quarantine_deleted, + ..PruneReport::default() + }) + } + + /// Forces a truncating WAL checkpoint without changing logical rows. + pub fn checkpoint(&mut self) -> Result<(), StoreError> { + let (busy, log_frames, checkpointed_frames): (i64, i64, i64) = + self.connection + .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 || log_frames < 0 || checkpointed_frames < 0 { + return Err(StoreError::DatabaseOperation); + } + Ok(()) + } +} + +fn resolution_code_key(code: crate::QuarantineResolutionCode) -> &'static str { + match code { + crate::QuarantineResolutionCode::SchemaNowSupported => "schema_now_supported", + crate::QuarantineResolutionCode::ConfirmedInvalid => "confirmed_invalid", + crate::QuarantineResolutionCode::DuplicatePayload => "duplicate_payload", + } +} diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs index d5f51ad..2f1a3ea 100644 --- a/crates/psyche-store/src/transitions.rs +++ b/crates/psyche-store/src/transitions.rs @@ -211,6 +211,36 @@ impl Store { .query_row("SELECT COUNT(*) FROM transitions", [], |row| row.get(0))?; count.try_into().map_err(|_| StoreError::DatabaseOperation) } + + /// Returns one record's validated immutable transition history. + pub fn transitions(&self, record_id: &RecordId) -> Result, StoreError> { + let kind = records::schema_kind_for_id(record_id); + authenticated_history(&self.connection, kind, record_id) + } +} + +pub(crate) fn validate_all(connection: &rusqlite::Connection) -> Result<(), StoreError> { + let mut statement = connection.prepare( + " + SELECT DISTINCT kind, record_id + FROM transitions + ORDER BY kind, record_id + ", + )?; + let histories = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>() + .map_err(|_| StoreError::DatabaseCorruption)?; + for (kind, record_id) in histories { + let kind = records::parse_kind_key(&kind)?; + let record_kind = kind.record_kind().ok_or(StoreError::DatabaseCorruption)?; + let record_id = + RecordId::parse(record_kind, &record_id).map_err(|_| StoreError::DatabaseCorruption)?; + authenticated_history(connection, kind, &record_id)?; + } + Ok(()) } fn authenticated_history( diff --git a/crates/psyche-store/tests/retention.rs b/crates/psyche-store/tests/retention.rs new file mode 100644 index 0000000..43d5f48 --- /dev/null +++ b/crates/psyche-store/tests/retention.rs @@ -0,0 +1,551 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Barrier}; + +use psyche_core::contracts::execution::{AdoptionState, CancellationState, ExecutionBinding}; +use psyche_core::contracts::{ + CanonicalDocument, RecordKind, RejectedDocument, RejectionReason, SchemaKind, SchemaVersion, +}; +use psyche_core::digest::{Sha256Digest, canonical_bytes}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_store::{ + IngestOutcome, QuarantineId, QuarantineReasonCode, QuarantineResolution, + QuarantineResolutionCode, ResolveQuarantineOutcome, Store, StoreError, Transition, +}; +use rusqlite::{Connection, params}; +use serde_json::json; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime, UtcOffset}; + +fn test_store() -> (Store, tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + let store = Store::open(&path).unwrap(); + (store, dir, path) +} + +fn raw_connection(path: &Path) -> Connection { + Connection::open(path).unwrap() +} + +fn at(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn fixture_digest(character: char) -> Sha256Digest { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() +} + +fn record_id(kind: RecordKind, suffix: &str) -> RecordId { + RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())).unwrap() +} + +fn fixture_attempt_id() -> RecordId { + record_id(RecordKind::Attempt, "01J00000000000000000000000") +} + +fn fixture_snapshot_id() -> RecordId { + record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000001") +} + +fn fixture_binding() -> ExecutionBinding { + ExecutionBinding { + schema_version: SchemaVersion::parse("psyche.execution_binding.v1").unwrap(), + attempt_id: fixture_attempt_id(), + revision: 1, + previous_revision_digest: None, + revision_created_at: at("2026-08-05T12:00:00Z"), + familiar_snapshot_id: fixture_snapshot_id(), + project_id: "project-a".to_owned(), + request_id: RequestId::parse("req_01J00000000000000000000002").unwrap(), + request_digest: fixture_digest('a'), + request_created_at: at("2026-08-05T11:59:00Z"), + request_valid_until: at("2026-08-05T12:05:00Z"), + coven_contract_version: "coven.v1".to_owned(), + coven_session_id: None, + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:1".to_owned()), + cancellation_state: CancellationState::NotRequested, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn fixture_transition() -> Transition { + Transition::new( + SchemaKind::ExecutionBinding, + fixture_attempt_id(), + 1, + None, + "admitted".to_owned(), + at("2026-08-05T12:00:01Z"), + ) + .unwrap() +} + +fn quarantined_fixture(store: &mut Store) -> QuarantineId { + let IngestOutcome::Quarantined { quarantine_id } = store + .ingest(br#"{"schema_version":"psyche.intent.v2"}"#) + .unwrap() + else { + panic!("fixture was not quarantined"); + }; + quarantine_id +} + +fn resolution(code: QuarantineResolutionCode, resolved_at: OffsetDateTime) -> QuarantineResolution { + QuarantineResolution { code, resolved_at } +} + +fn assert_database_corruption(result: Result) { + let error = result.unwrap_err(); + assert!(matches!(error, StoreError::DatabaseCorruption)); + assert_eq!( + error.to_string(), + "stored database content failed integrity validation" + ); + assert_eq!( + format!("{error:?}"), + "StoreError(stored database content failed integrity validation)" + ); + assert!(error.source().is_none()); +} + +#[test] +fn quarantine_id_constructor_parser_and_serde_round_trip() { + let generated = QuarantineId::new(); + assert!(generated.as_str().starts_with("qua_")); + assert_eq!(generated.as_str().len(), 30); + assert_eq!(QuarantineId::parse(generated.as_str()).unwrap(), generated); + + let encoded = serde_json::to_string(&generated).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + generated + ); + + for malformed in [ + "01J00000000000000000000000", + "qua_01J0000000000000000000000", + "qua_01J000000000000000000000000", + "qua_01j00000000000000000000000", + "qua_81J00000000000000000000000", + "qua_01I00000000000000000000000", + ] { + assert!(QuarantineId::parse(malformed).is_err(), "{malformed}"); + let json = serde_json::to_string(malformed).unwrap(); + assert!(serde_json::from_str::(&json).is_err()); + } +} + +#[test] +fn unknown_major_is_quarantined_without_dispatchable_record() { + let (mut store, _dir, _path) = test_store(); + let outcome = store + .ingest(br#"{"schema_version":"psyche.intent.v2"}"#) + .unwrap(); + assert!(matches!(outcome, IngestOutcome::Quarantined { .. })); + assert_eq!(store.count_records(SchemaKind::Intent).unwrap(), 0); +} + +#[test] +fn unknown_enum_is_quarantined_without_dispatchable_record() { + let (mut store, _dir, _path) = test_store(); + let bytes = serde_json::to_vec(&json!({ + "schema_version": "psyche.graph.v1", + "graph_id": "grf_01J00000000000000000000003", + "root_intent_id": "int_01J00000000000000000000004", + "owner_principal_id": "principal:one", + "policy_revision": "policy:one", + "state": "future_state", + "version": 1 + })) + .unwrap(); + let IngestOutcome::Quarantined { quarantine_id } = store.ingest(&bytes).unwrap() else { + panic!("unknown enum was not quarantined"); + }; + let rejected = store.quarantine_record(&quarantine_id).unwrap().unwrap(); + assert_eq!(rejected.reason, QuarantineReasonCode::UnknownEnumValue); + assert_eq!(rejected.bounded_payload, bytes); + assert_eq!(store.count_records(SchemaKind::Graph).unwrap(), 0); +} + +#[test] +fn quarantine_is_bounded_idempotent_and_reason_sensitive() { + let (mut store, _dir, _path) = test_store(); + let mut bytes = b"payload-secret-marker".repeat(4_000); + bytes.extend_from_slice(b"tail-not-retained"); + let rejected = RejectedDocument::from_bytes(&bytes, RejectionReason::TooLarge); + let expected_digest = rejected.payload_digest.clone(); + let id = store.quarantine(rejected.clone()).unwrap(); + assert_eq!(store.quarantine(rejected).unwrap(), id); + + let persisted = store.quarantine_record(&id).unwrap().unwrap(); + assert_eq!(persisted.payload_digest, expected_digest); + assert_eq!(persisted.bounded_payload.len(), 64 * 1024); + assert_eq!(persisted.reason, QuarantineReasonCode::TooLarge); + assert!(!format!("{persisted:?}").contains("payload-secret-marker")); + + let same_payload_different_reason = + RejectedDocument::from_bytes(&bytes, RejectionReason::UnknownSchema); + let other_id = store.quarantine(same_payload_different_reason).unwrap(); + assert_ne!(other_id, id); +} + +#[test] +fn quarantine_resolution_is_durable_and_idempotent() { + let (mut store, _dir, path) = test_store(); + let id = quarantined_fixture(&mut store); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let resolution = resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ); + let first = store.resolve_quarantine(&id, &resolution).unwrap(); + let ResolveQuarantineOutcome::Resolved { resolution_digest } = first else { + panic!("first resolution did not resolve"); + }; + assert!(matches!( + store.resolve_quarantine(&id, &resolution).unwrap(), + ResolveQuarantineOutcome::AlreadyResolved { + resolution_digest: repeated + } if repeated == resolution_digest + )); + drop(store); + + let reopened = Store::open(&path).unwrap(); + let persisted = reopened.quarantine_record(&id).unwrap().unwrap(); + assert_eq!( + persisted.resolution_code, + Some(QuarantineResolutionCode::ConfirmedInvalid) + ); + assert_eq!(persisted.resolution_digest, Some(resolution_digest)); + assert_eq!(reopened.audit_events().unwrap().len(), 1); +} + +#[test] +fn quarantine_resolution_rejects_unknown_stale_non_utc_or_conflicting_requests() { + let (mut store, _dir, _path) = test_store(); + let unknown = QuarantineId::parse("qua_01J00000000000000000000000").unwrap(); + let unknown_resolution = resolution( + QuarantineResolutionCode::ConfirmedInvalid, + OffsetDateTime::UNIX_EPOCH, + ); + assert!(matches!( + store.resolve_quarantine(&unknown, &unknown_resolution), + Err(StoreError::QuarantineNotFound { .. }) + )); + + let id = quarantined_fixture(&mut store); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + assert!(matches!( + store.resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at - Duration::nanoseconds(1), + ) + ), + Err(StoreError::InvalidQuarantineResolution { .. }) + )); + assert!(matches!( + store.resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at.to_offset(UtcOffset::from_hms(1, 0, 0).unwrap()), + ) + ), + Err(StoreError::InvalidQuarantineResolution { .. }) + )); + + store + .resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + assert!(matches!( + store.resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::DuplicatePayload, + discovered_at + Duration::seconds(2), + ) + ), + Err(StoreError::QuarantineResolutionConflict { .. }) + )); + assert_eq!(store.audit_events().unwrap().len(), 1); +} + +#[test] +fn concurrent_quarantine_resolution_has_one_durable_winner() { + let (mut first, _dir, path) = test_store(); + let id = quarantined_fixture(&mut first); + let discovered_at = first.quarantine_record(&id).unwrap().unwrap().discovered_at; + let second = Store::open(&path).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + + let left_id = id.clone(); + let left_barrier = Arc::clone(&barrier); + let left = std::thread::spawn(move || { + let mut store = first; + left_barrier.wait(); + store.resolve_quarantine( + &left_id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + }); + let right_id = id.clone(); + let right_barrier = Arc::clone(&barrier); + let right = std::thread::spawn(move || { + let mut store = second; + right_barrier.wait(); + store.resolve_quarantine( + &right_id, + &resolution( + QuarantineResolutionCode::DuplicatePayload, + discovered_at + Duration::seconds(2), + ), + ) + }); + + let results = [left.join().unwrap(), right.join().unwrap()]; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Ok(ResolveQuarantineOutcome::Resolved { .. }))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(StoreError::QuarantineResolutionConflict { .. }))) + .count(), + 1 + ); + + let reopened = Store::open(&path).unwrap(); + assert!( + reopened + .quarantine_record(&id) + .unwrap() + .unwrap() + .resolution_digest + .is_some() + ); + assert_eq!( + reopened + .audit_events() + .unwrap() + .iter() + .filter(|event| event.event_code == "quarantine_resolved") + .count(), + 1 + ); +} + +#[test] +fn resolution_audit_details_are_canonical_and_payload_redacted() { + let (mut store, _dir, _path) = test_store(); + let secret = "resolution-payload-secret"; + let id = store + .quarantine(RejectedDocument::from_bytes( + secret.as_bytes(), + RejectionReason::UnknownSchema, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + store + .resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + + let events = store.audit_events().unwrap(); + assert_eq!(events.len(), 1); + let details: serde_json::Value = + serde_json::from_slice(&events[0].public_details_json).unwrap(); + assert_eq!( + canonical_bytes(&details).unwrap(), + events[0].public_details_json + ); + assert!( + !String::from_utf8(events[0].public_details_json.clone()) + .unwrap() + .contains(secret) + ); +} + +#[test] +fn corrupted_resolution_columns_or_metadata_fail_closed() { + let (mut store, _dir, path) = test_store(); + let id = quarantined_fixture(&mut store); + raw_connection(&path) + .execute_batch( + " + PRAGMA ignore_check_constraints = ON; + UPDATE quarantine_records + SET resolved_at = '2026-08-05T12:00:00Z' + WHERE resolution_code IS NULL; + ", + ) + .unwrap(); + + assert_database_corruption(store.quarantine_record(&id)); + assert_database_corruption(store.resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + at("2026-08-05T12:00:01Z"), + ), + )); + assert_database_corruption(store.prune(at("2026-08-06T00:00:00Z"))); +} + +#[test] +fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { + let (mut store, _dir, path) = test_store(); + let id = quarantined_fixture(&mut store); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + store + .resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + raw_connection(&path) + .execute( + " + INSERT INTO quarantine_records ( + quarantine_id, schema_version, payload_digest, bounded_payload, + reason, discovered_at + ) VALUES ('bad-id', NULL, ?1, X'', 'unknown_schema', ?2) + ", + params![ + fixture_digest('b').as_str(), + discovered_at.format(&Rfc3339).unwrap() + ], + ) + .unwrap(); + + assert_database_corruption(store.prune(discovered_at + Duration::days(1))); + assert!(store.quarantine_record(&id).unwrap().is_some()); +} + +#[test] +fn prune_uses_strictly_older_cutoff_and_never_deletes_unresolved_rows() { + let (mut store, _dir, _path) = test_store(); + let resolved = quarantined_fixture(&mut store); + let discovered_at = store + .quarantine_record(&resolved) + .unwrap() + .unwrap() + .discovered_at; + let resolved_at = discovered_at + Duration::seconds(1); + store + .resolve_quarantine( + &resolved, + &resolution(QuarantineResolutionCode::ConfirmedInvalid, resolved_at), + ) + .unwrap(); + let unresolved = store + .quarantine(RejectedDocument::from_bytes( + br#"{"schema_version":"psyche.future.v1"}"#, + RejectionReason::UnknownSchema, + )) + .unwrap(); + + let equal = store.prune(resolved_at).unwrap(); + assert_eq!(equal.resolved_quarantine_deleted, 0); + assert!(store.quarantine_record(&resolved).unwrap().is_some()); + + let older = store.prune(resolved_at + Duration::nanoseconds(1)).unwrap(); + assert_eq!(older.resolved_quarantine_deleted, 1); + assert_eq!(older.unresolved_quarantine_deleted, 0); + assert!(store.quarantine_record(&resolved).unwrap().is_none()); + assert!(store.quarantine_record(&unresolved).unwrap().is_some()); +} + +#[test] +fn pruning_preserves_unresolved_quarantine_binding_revisions_transitions_and_audit() { + let (mut store, _dir, _path) = test_store(); + let binding = fixture_binding(); + store + .insert(&CanonicalDocument::ExecutionBinding(binding.clone())) + .unwrap(); + store.append_transition(&fixture_transition()).unwrap(); + + let resolved = quarantined_fixture(&mut store); + let discovered_at = store + .quarantine_record(&resolved) + .unwrap() + .unwrap() + .discovered_at; + store + .resolve_quarantine( + &resolved, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + let unresolved = store + .quarantine(RejectedDocument::from_bytes( + br#"{"schema_version":"psyche.other.v1"}"#, + RejectionReason::UnknownSchema, + )) + .unwrap(); + + let bindings_before = store + .execution_binding_revisions(&fixture_attempt_id()) + .unwrap(); + let transitions_before = store.transitions(&fixture_attempt_id()).unwrap(); + let audit_before = store.audit_events().unwrap(); + let report = store.prune(discovered_at + Duration::days(1)).unwrap(); + assert_eq!(report.resolved_quarantine_deleted, 1); + assert_eq!(report.unresolved_quarantine_deleted, 0); + assert_eq!(report.execution_binding_revisions_deleted, 0); + assert_eq!(report.transitions_deleted, 0); + assert_eq!(report.audit_events_deleted, 0); + assert!(store.quarantine_record(&unresolved).unwrap().is_some()); + assert_eq!( + store + .execution_binding_revisions(&fixture_attempt_id()) + .unwrap(), + bindings_before + ); + assert_eq!( + store.transitions(&fixture_attempt_id()).unwrap(), + transitions_before + ); + assert_eq!(store.audit_events().unwrap(), audit_before); +} + +#[test] +fn checkpoint_preserves_committed_state() { + let (mut store, _dir, _path) = test_store(); + let id = quarantined_fixture(&mut store); + store.checkpoint().unwrap(); + assert!(store.quarantine_record(&id).unwrap().is_some()); +} From a2910d8295a2a0324dbfdebb5376d8435497d0dc Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:12:07 -0500 Subject: [PATCH 29/66] fix(store): retain approved database path policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/connection.rs | 246 ++-------------------- crates/psyche-store/src/lib.rs | 21 +- crates/psyche-store/tests/migrations.rs | 249 +---------------------- crates/psyche-store/tests/support/mod.rs | 40 +--- 4 files changed, 28 insertions(+), 528 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 2968871..6c98d86 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,6 +1,6 @@ use std::{ - fs::{self, File, OpenOptions}, - io::{BufReader, ErrorKind, Read}, + fs::{self, OpenOptions}, + io::ErrorKind, path::{Path, PathBuf}, thread, time::{Duration, Instant}, @@ -13,65 +13,19 @@ use crate::StoreError; const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); -const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0"; -const SQLITE_HEADER_SIZE: usize = 100; -const WAL_HEADER_SIZE: usize = 32; -const WAL_FRAME_HEADER_SIZE: usize = 24; -const WAL_FORMAT_VERSION: u32 = 3_007_000; -const WAL_MAGIC: u32 = 0x377f_0682; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DatabaseFileState { - Existing, - Created, -} -pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { +pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; - let state = prepare_database_file(path)?; + prepare_database_file(path)?; let open_path = database_open_path(path)?; - Ok((open_path, state)) -} -pub(crate) fn open_read_only(path: &Path) -> Result { - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(path, flags)?; - connection.busy_timeout(BUSY_TIMEOUT)?; - Ok(connection) -} - -pub(crate) fn open_read_write(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(path, flags)?; + let connection = Connection::open_with_flags(&open_path, flags)?; connection.busy_timeout(BUSY_TIMEOUT)?; - Ok(connection) -} - -pub(crate) fn file_user_version(path: &Path) -> Result, StoreError> { - // A read-only WAL query may update reader marks in `-shm`. Read committed - // page-one frames first so a future schema can be rejected without that. - let [journal_path, wal_path, _] = sqlite_sidecar_paths(path); - if existing_file_len(&journal_path)?.is_some_and(|len| len > 0) { - return Ok(None); - } - - let Some((main_version, page_size)) = main_file_header(path)? else { - return Ok(None); - }; - if page_size == 0 { - return Ok(Some(main_version)); - } - - match wal_file_user_version(&wal_path, page_size)? { - WalFileVersion::Absent => Ok(Some(main_version)), - WalFileVersion::Invalid => Ok(None), - WalFileVersion::Valid(version) => Ok(Some(version.unwrap_or(main_version))), - } + Ok((connection, open_path)) } pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { @@ -195,162 +149,6 @@ fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { }) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WalFileVersion { - Absent, - Invalid, - Valid(Option), -} - -fn existing_file_len(path: &Path) -> Result, StoreError> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - validate_database_metadata(&metadata)?; - Ok(Some(metadata.len())) - } - Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), - Err(error) => Err(StoreError::file_operation(error)), - } -} - -fn main_file_header(path: &Path) -> Result, StoreError> { - let mut file = File::open(path).map_err(StoreError::file_operation)?; - let len = file.metadata().map_err(StoreError::file_operation)?.len(); - if len == 0 { - return Ok(Some((0, 0))); - } - if len < SQLITE_HEADER_SIZE as u64 { - return Ok(None); - } - - let mut header = [0_u8; SQLITE_HEADER_SIZE]; - file.read_exact(&mut header) - .map_err(StoreError::file_operation)?; - if &header[..SQLITE_HEADER.len()] != SQLITE_HEADER { - return Ok(None); - } - - let encoded_page_size = u16::from_be_bytes([header[16], header[17]]); - let page_size = if encoded_page_size == 1 { - 65_536 - } else { - u32::from(encoded_page_size) - }; - if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { - return Ok(None); - } - - Ok(Some((read_u32_be(&header[60..64]), page_size))) -} - -fn wal_file_user_version( - path: &Path, - expected_page_size: u32, -) -> Result { - let file = match File::open(path) { - Ok(file) => file, - Err(error) if error.kind() == ErrorKind::NotFound => { - return Ok(WalFileVersion::Absent); - } - Err(error) => return Err(StoreError::file_operation(error)), - }; - if file.metadata().map_err(StoreError::file_operation)?.len() < WAL_HEADER_SIZE as u64 { - return Ok(WalFileVersion::Absent); - } - - let mut reader = BufReader::new(file); - let mut header = [0_u8; WAL_HEADER_SIZE]; - reader - .read_exact(&mut header) - .map_err(StoreError::file_operation)?; - let magic = read_u32_be(&header[..4]); - let page_size = read_u32_be(&header[8..12]); - if magic & !1 != WAL_MAGIC - || read_u32_be(&header[4..8]) != WAL_FORMAT_VERSION - || page_size != expected_page_size - { - return Ok(WalFileVersion::Invalid); - } - - let checksum_big_endian = magic & 1 == 1; - let mut checksum = [0_u32; 2]; - extend_wal_checksum(&header[..24], checksum_big_endian, &mut checksum); - if checksum != [read_u32_be(&header[24..28]), read_u32_be(&header[28..])] { - return Ok(WalFileVersion::Invalid); - } - - let salt = &header[16..24]; - let mut frame_header = [0_u8; WAL_FRAME_HEADER_SIZE]; - let mut page = vec![0_u8; page_size as usize]; - let mut pending_page_one = None; - let mut committed_page_one = None; - loop { - if !read_exact_frame_part(&mut reader, &mut frame_header)? - || !read_exact_frame_part(&mut reader, &mut page)? - { - break; - } - if read_u32_be(&frame_header[..4]) == 0 || &frame_header[8..16] != salt { - break; - } - - let mut frame_checksum = checksum; - extend_wal_checksum(&frame_header[..8], checksum_big_endian, &mut frame_checksum); - extend_wal_checksum(&page, checksum_big_endian, &mut frame_checksum); - if frame_checksum - != [ - read_u32_be(&frame_header[16..20]), - read_u32_be(&frame_header[20..]), - ] - { - break; - } - checksum = frame_checksum; - - if read_u32_be(&frame_header[..4]) == 1 { - pending_page_one = Some(read_u32_be(&page[60..64])); - } - if read_u32_be(&frame_header[4..8]) != 0 { - if let Some(version) = pending_page_one.take() { - committed_page_one = Some(version); - } - } - } - - Ok(WalFileVersion::Valid(committed_page_one)) -} - -fn read_exact_frame_part(reader: &mut impl Read, buffer: &mut [u8]) -> Result { - match reader.read_exact(buffer) { - Ok(()) => Ok(true), - Err(error) if error.kind() == ErrorKind::UnexpectedEof => Ok(false), - Err(error) => Err(StoreError::file_operation(error)), - } -} - -fn extend_wal_checksum(bytes: &[u8], big_endian: bool, checksum: &mut [u32; 2]) { - debug_assert_eq!(bytes.len() % 8, 0); - for words in bytes.chunks_exact(8) { - let first = read_checksum_word(&words[..4], big_endian); - checksum[0] = checksum[0].wrapping_add(first).wrapping_add(checksum[1]); - let second = read_checksum_word(&words[4..], big_endian); - checksum[1] = checksum[1].wrapping_add(second).wrapping_add(checksum[0]); - } -} - -fn read_checksum_word(bytes: &[u8], big_endian: bool) -> u32 { - let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; - if big_endian { - u32::from_be_bytes(bytes) - } else { - u32::from_le_bytes(bytes) - } -} - -fn read_u32_be(bytes: &[u8]) -> u32 { - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) -} - fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, @@ -415,16 +213,6 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(StoreError::InvalidDatabasePath); } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - if metadata.permissions().mode() & 0o777 != 0o700 { - return Err(StoreError::InvalidDatabasePath); - } - } - Ok(()) } @@ -446,24 +234,21 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_database_file(path: &Path) -> Result { - let state = match fs::symlink_metadata(path) { - Ok(metadata) => { - validate_database_metadata(&metadata)?; - DatabaseFileState::Existing - } +fn prepare_database_file(path: &Path) -> Result<(), StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => validate_database_metadata(&metadata)?, Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { - Ok(()) => DatabaseFileState::Created, - Err(error) if error.kind() == ErrorKind::AlreadyExists => DatabaseFileState::Existing, + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} Err(error) => return Err(StoreError::file_operation(error)), }, Err(error) => return Err(StoreError::file_operation(error)), - }; + } let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - Ok(state) + Ok(()) } fn database_open_path(path: &Path) -> Result { @@ -502,15 +287,14 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open_read_write, prepare}; + use super::{configure, open}; #[test] fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let (path, _) = prepare(&path).unwrap(); - let connection = open_read_write(&path).unwrap(); + let (connection, _) = open(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 824a282..d2d8211 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -39,31 +39,20 @@ impl Store { pub fn open(path: &Path) -> Result { let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); let _initialization_guard = initialization_guard(initialization_lock)?; - let (database_path, file_state) = connection::prepare(path)?; - connection::validate_sidecars(&database_path)?; + let (mut connection, database_path) = connection::open(path)?; - if file_state == connection::DatabaseFileState::Existing { - let preflight = connection::open_read_only(&database_path)?; - if let Some(found) = connection::file_user_version(&database_path)? { - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); - } - } - let found = match preflight - .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) - { + let found = + match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { Ok(found) => found, Err(error) => { connection::validate_sidecars(&database_path)?; return Err(error.into()); } }; - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); - } + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); } - let mut connection = connection::open_read_write(&database_path)?; connection::enforce_database_permissions(&database_path)?; connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index ffc5158..f4a2571 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -120,121 +120,6 @@ fn future_database_version_fails_before_any_migration() { assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } -#[cfg(unix)] -#[test] -fn crash_left_wal_future_version_is_rejected_without_mutating_any_database_file() { - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::Version1); - - run_crash_helper(&path, "wal-v99"); - - let sidecars = sqlite_sidecar_paths(&path); - let wal_path = &sidecars[1]; - let shm_path = &sidecars[2]; - assert!(wal_path.exists()); - assert!(shm_path.exists()); - assert_eq!(database_header_user_version(&path), 1); - - for file in [&path, wal_path, shm_path] { - set_mode(file, 0o644); - } - let before = [ - snapshot_file(&path), - snapshot_file(wal_path), - snapshot_file(shm_path), - ]; - - let error = Store::open(&path).unwrap_err(); - - assert!( - matches!( - &error, - StoreError::UnsupportedDatabaseVersion { found: 99, .. } - ), - "unexpected error: {error:?}" - ); - assert_eq!( - error.to_string(), - "unsupported database version 99; maximum supported version is 1" - ); - assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); - assert_snapshot_unchanged("WAL", &before[1], &snapshot_file(wal_path)); - assert_snapshot_unchanged("shared memory", &before[2], &snapshot_file(shm_path)); -} - -#[cfg(unix)] -#[test] -fn hot_journal_read_only_failure_does_not_recover_or_open_read_write() { - use std::error::Error; - - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::Version0); - execute_batch( - &path, - " - CREATE TABLE hot_journal_seed ( - id INTEGER PRIMARY KEY, - payload BLOB NOT NULL - ) STRICT; - WITH RECURSIVE counter(value) AS ( - SELECT 1 - UNION ALL - SELECT value + 1 FROM counter WHERE value < 256 - ) - INSERT INTO hot_journal_seed (id, payload) - SELECT value, zeroblob(4096) FROM counter; - ", - ); - - run_crash_helper(&path, "hot-journal"); - - let sidecars = sqlite_sidecar_paths(&path); - let journal_path = &sidecars[0]; - let journal_contents = std::fs::read(journal_path).unwrap(); - assert!(journal_contents.len() > 512); - assert!(journal_contents[..8].iter().any(|byte| *byte != 0)); - set_mode(&path, 0o644); - set_mode(journal_path, 0o644); - let before = [snapshot_file(&path), snapshot_file(journal_path)]; - - let error = Store::open(&path).unwrap_err(); - - assert!( - matches!(&error, StoreError::DatabaseOperation), - "unexpected error: {error:?}" - ); - assert_eq!(error.to_string(), "store database operation failed"); - assert_eq!( - format!("{error:?}"), - "StoreError(store database operation failed)" - ); - assert!(error.source().is_none()); - assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); - assert_snapshot_unchanged("rollback journal", &before[1], &snapshot_file(journal_path)); -} - -#[test] -fn partially_applied_v1_transaction_rolls_back_and_recovers() { - let dir = tempfile::tempdir().unwrap(); - let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); - - assert_eq!(user_version(&path), 0); - assert!(!table_exists(&path, "schema_migrations")); - assert!(!table_exists(&path, "canonical_records")); - - let store = Store::open(&path).unwrap(); - assert_eq!(store.schema_version().unwrap(), 1); - drop(store); - - assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); - assert_eq!(schema_migrations(&path).len(), 1); - - let reopened = Store::open(&path).unwrap(); - assert_eq!(reopened.schema_version().unwrap(), 1); - drop(reopened); - assert_eq!(schema_migrations(&path).len(), 1); -} - #[test] fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); @@ -303,8 +188,6 @@ fn concurrent_first_open_applies_migration_once() { const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); - #[cfg(unix)] - set_mode(dir.path(), 0o700); for round in 0..ROUNDS { let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); let barrier = Arc::new(Barrier::new(THREADS)); @@ -444,43 +327,24 @@ fn future_database_sidecar_permissions_are_unchanged() { #[cfg(unix)] #[test] -fn existing_shared_parent_is_rejected_without_changes() { +fn existing_shared_parent_permissions_are_preserved() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, b"not-a-database").unwrap(); + std::fs::write(&path, []).unwrap(); set_mode(&parent, 0o755); set_mode(&path, 0o755); - assert_invalid_database_path(&path); - - assert_eq!(mode(&parent), 0o755); - assert_eq!(mode(&path), 0o755); - assert_eq!(std::fs::read(&path).unwrap(), b"not-a-database"); -} - -#[cfg(unix)] -#[test] -fn existing_private_parent_is_accepted_without_changing_its_mode() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().join("existing"); - let path = parent.join("psyche.sqlite3"); - std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, []).unwrap(); - set_mode(&parent, 0o700); - set_mode(&path, 0o600); - drop(Store::open(&path).unwrap()); - assert_eq!(mode(&parent), 0o700); + assert_eq!(mode(&parent), 0o755); assert_private_file(&path); - assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); } #[cfg(unix)] #[test] -fn relative_filename_rejects_shared_current_directory_without_changes() { +fn relative_filename_preserves_current_directory_permissions() { use std::process::Command; let dir = tempfile::tempdir().unwrap(); @@ -499,7 +363,7 @@ fn relative_filename_rejects_shared_current_directory_without_changes() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(mode(dir.path()), 0o755); - assert!(!dir.path().join("psyche.sqlite3").exists()); + assert_private_file(&dir.path().join("psyche.sqlite3")); } #[cfg(unix)] @@ -509,55 +373,7 @@ fn relative_filename_open_helper() { return; } - assert_invalid_database_path(Path::new("psyche.sqlite3")); -} - -#[cfg(unix)] -#[test] -fn crash_left_database_helper() { - let Some(helper) = std::env::var_os("PSYCHE_STORE_CRASH_HELPER") else { - return; - }; - let path = PathBuf::from( - std::env::var_os("PSYCHE_STORE_CRASH_HELPER_PATH") - .unwrap_or_else(|| panic!("crash helper database path is missing")), - ); - let connection = Connection::open(&path).unwrap(); - - match helper.to_str() { - Some("wal-v99") => connection - .execute_batch( - " - PRAGMA journal_mode = WAL; - PRAGMA wal_autocheckpoint = 0; - PRAGMA synchronous = FULL; - BEGIN IMMEDIATE; - CREATE TABLE wal_future_marker ( - value TEXT NOT NULL - ) STRICT; - INSERT INTO wal_future_marker (value) VALUES ('future-in-wal'); - PRAGMA user_version = 99; - COMMIT; - ", - ) - .unwrap(), - Some("hot-journal") => connection - .execute_batch( - " - PRAGMA journal_mode = DELETE; - PRAGMA synchronous = FULL; - PRAGMA cache_size = 1; - PRAGMA cache_spill = ON; - BEGIN IMMEDIATE; - UPDATE hot_journal_seed - SET payload = randomblob(4096); - ", - ) - .unwrap(), - _ => panic!("unknown crash helper mode"), - } - - std::process::exit(0); + drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); } #[cfg(unix)] @@ -583,7 +399,6 @@ fn symlink_database_is_rejected_without_mutating_its_target() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); - set_mode(dir.path(), 0o700); let target = dir.path().join("target.sqlite3"); let path = dir.path().join("linked.sqlite3"); std::fs::write(&target, []).unwrap(); @@ -599,58 +414,6 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } -#[cfg(unix)] -fn run_crash_helper(path: &Path, helper: &str) { - use std::process::Command; - - let output = Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "crash_left_database_helper", "--nocapture"]) - .env("PSYCHE_STORE_CRASH_HELPER", helper) - .env("PSYCHE_STORE_CRASH_HELPER_PATH", path) - .output() - .unwrap(); - assert!( - output.status.success(), - "crash helper failed: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[cfg(unix)] -#[derive(Debug, Eq, PartialEq)] -struct FileSnapshot { - contents: Vec, - len: u64, - modified: std::time::SystemTime, - mode: u32, -} - -#[cfg(unix)] -fn snapshot_file(path: &Path) -> FileSnapshot { - let contents = std::fs::read(path).unwrap(); - let metadata = std::fs::metadata(path).unwrap(); - FileSnapshot { - contents, - len: metadata.len(), - modified: metadata.modified().unwrap(), - mode: mode(path), - } -} - -#[cfg(unix)] -fn assert_snapshot_unchanged(label: &str, before: &FileSnapshot, after: &FileSnapshot) { - assert_eq!(after.len, before.len, "{label} length changed"); - assert_eq!(after.modified, before.modified, "{label} mtime changed"); - assert_eq!(after.mode, before.mode, "{label} mode changed"); - assert_eq!(after.contents, before.contents, "{label} contents changed"); -} - -#[cfg(unix)] -fn database_header_user_version(path: &Path) -> u32 { - let contents = std::fs::read(path).unwrap(); - u32::from_be_bytes(contents[60..64].try_into().unwrap()) -} - fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { sqlite_sidecar_paths(path) .into_iter() diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index b2f8276..aba1ff2 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::{Connection, TransactionBehavior}; +use rusqlite::Connection; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,27 +15,18 @@ pub(super) enum Fixture { Version0, Version1, Version99, - PartiallyAppliedV1, MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); - } - let name = match fixture { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", - Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let mut connection = Connection::open(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -73,33 +64,6 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), - Fixture::PartiallyAppliedV1 => { - let transaction = connection - .transaction_with_behavior(TransactionBehavior::Exclusive) - .unwrap(); - transaction - .execute_batch( - " - CREATE TABLE schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL - ) STRICT; - CREATE TABLE canonical_records ( - kind TEXT NOT NULL, - record_id TEXT NOT NULL, - schema_version TEXT NOT NULL, - digest TEXT NOT NULL, - canonical_json BLOB NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (kind, record_id), - UNIQUE (kind, record_id, digest) - ) STRICT; - PRAGMA user_version = 1; - ", - ) - .unwrap(); - drop(transaction); - } Fixture::MigrationConflictV1 => connection .execute_batch( " From dcdc68c62a2e701cd4db59e0b47d983ecd92929d Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:12:35 -0500 Subject: [PATCH 30/66] test(store): cover cancellation evidence mutations Exercise every cancellation correlation, evidence, reason, identifier, and window mutation from valid baselines and prove direct insert leaves no durable state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/tests/records.rs | 518 ++++++++++++++++++++++++--- 1 file changed, 466 insertions(+), 52 deletions(-) diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index d8b57f9..d8a9ba6 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -505,70 +505,484 @@ fn direct_insert_rejects_acknowledged_state_without_termination_correlation() { #[test] fn direct_insert_rejects_mismatched_cancellation_evidence() { - let baseline = fixture_acknowledged_execution_binding(); - let mut cases = Vec::new(); - - let mut changed = baseline.clone(); - changed - .cancellation_acknowledgement - .as_mut() - .unwrap() - .execution_request_digest = fixture_other_digest(); - cases.push(changed); - - let mut changed = baseline.clone(); - changed - .cancellation_acknowledgement - .as_mut() - .unwrap() - .execution_request_id = fixture_other_request_id(); - cases.push(changed); + fn mutation( + name: &'static str, + baseline: &ExecutionBinding, + mutate: impl FnOnce(&mut ExecutionBinding), + ) -> (&'static str, ExecutionBinding) { + baseline.validate().unwrap(); + let before = serde_json::to_value(baseline).unwrap(); + let mut binding = baseline.clone(); + mutate(&mut binding); + let after = serde_json::to_value(&binding).unwrap(); + let changed_fields = before + .as_object() + .unwrap() + .keys() + .filter(|field| before.get(*field) != after.get(*field)) + .count(); + assert_eq!(changed_fields, 1, "{name} must mutate exactly one field"); + (name, binding) + } - let mut changed = baseline.clone(); - changed - .cancellation_acknowledgement - .as_mut() - .unwrap() - .termination_request_id = fixture_other_request_id(); - cases.push(changed); + fn assert_no_writes(store: &Store, attempt_id: &RecordId, name: &str) { + assert!( + store + .execution_binding_revisions(attempt_id) + .unwrap() + .is_empty(), + "{name} wrote an execution-binding revision" + ); + assert_eq!( + store.count_records(SchemaKind::ExecutionBinding).unwrap(), + 0, + "{name} wrote an execution-binding record" + ); + assert_eq!( + store.total_record_count().unwrap(), + 0, + "{name} wrote a canonical record" + ); + assert_eq!( + store.count_transitions().unwrap(), + 0, + "{name} wrote a transition" + ); + } - let mut changed = baseline.clone(); - changed + let not_requested = fixture_execution_binding(); + let acknowledged_terminated = fixture_acknowledged_execution_binding(); + let mut acknowledged_already_terminal = fixture_acknowledged_execution_binding(); + acknowledged_already_terminal.cancellation_state = + CancellationState::AcknowledgedAlreadyTerminal; + acknowledged_already_terminal .cancellation_acknowledgement .as_mut() .unwrap() - .session_id = "session-b".to_owned(); - cases.push(changed); - - let mut changed = baseline.clone(); - changed.cancellation_acknowledgement.as_mut().unwrap().kind = - CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; - cases.push(changed); - - let mut changed = baseline.clone(); - changed.cancellation_unresolved = Some(unresolved(&changed)); - cases.push(changed); + .kind = CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + let termination_unknown = fixture_unresolved_execution_binding(); + + for baseline in [ + ¬_requested, + &acknowledged_terminated, + &acknowledged_already_terminal, + &termination_unknown, + ] { + baseline.validate().unwrap(); + } - let mut changed = baseline; - changed.cancellation_state = CancellationState::TerminationUnknown; - cases.push(changed); + let cases = vec![ + mutation( + "acknowledged terminated without evidence", + &acknowledged_terminated, + |binding| binding.cancellation_acknowledgement = None, + ), + mutation( + "acknowledged already terminal without evidence", + &acknowledged_already_terminal, + |binding| binding.cancellation_acknowledgement = None, + ), + mutation( + "termination unknown without evidence", + &termination_unknown, + |binding| binding.cancellation_unresolved = None, + ), + mutation( + "acknowledged terminated with wrong kind", + &acknowledged_terminated, + |binding| { + binding.cancellation_acknowledgement.as_mut().unwrap().kind = + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + }, + ), + mutation( + "acknowledged already terminal with wrong kind", + &acknowledged_already_terminal, + |binding| { + binding.cancellation_acknowledgement.as_mut().unwrap().kind = + CancellationAcknowledgementKind::Terminated; + }, + ), + mutation( + "acknowledged terminated with unresolved evidence", + &acknowledged_terminated, + |binding| binding.cancellation_unresolved = Some(unresolved(binding)), + ), + mutation( + "acknowledged already terminal with unresolved evidence", + &acknowledged_already_terminal, + |binding| binding.cancellation_unresolved = Some(unresolved(binding)), + ), + mutation( + "termination unknown with acknowledgement evidence", + &termination_unknown, + |binding| { + binding.cancellation_acknowledgement = Some(acknowledgement(binding)); + }, + ), + mutation( + "acknowledgement with wrong session", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = "session-b".to_owned(); + }, + ), + mutation( + "acknowledgement with empty session", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = String::new(); + }, + ), + mutation( + "acknowledgement with oversized session", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .session_id = "s".repeat(256); + }, + ), + mutation( + "empty acknowledgement id", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledgement_id = String::new(); + }, + ), + mutation( + "oversized acknowledgement id", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledgement_id = "a".repeat(256); + }, + ), + mutation( + "acknowledgement with wrong termination request id", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + }, + ), + mutation( + "acknowledgement with wrong execution request id", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_id = fixture_other_request_id(); + }, + ), + mutation( + "acknowledgement with wrong execution digest", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .execution_request_digest = fixture_other_digest(); + }, + ), + mutation( + "termination request id reused as execution request id", + &acknowledged_terminated, + |binding| { + binding + .termination_request + .as_mut() + .unwrap() + .termination_request_id = binding.request_id.clone(); + }, + ), + mutation( + "missing termination correlation", + &acknowledged_terminated, + |binding| binding.termination_request = None, + ), + mutation( + "mismatched termination correlation", + &acknowledged_terminated, + |binding| { + binding + .termination_request + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + }, + ), + mutation( + "empty termination window", + &acknowledged_terminated, + |binding| { + let created_at = binding.termination_request.as_ref().unwrap().created_at; + binding.termination_request.as_mut().unwrap().valid_until = created_at; + }, + ), + mutation( + "inverted termination window", + &acknowledged_terminated, + |binding| { + let created_at = binding.termination_request.as_ref().unwrap().created_at; + binding.termination_request.as_mut().unwrap().valid_until = + created_at - time::Duration::nanoseconds(1); + }, + ), + mutation( + "termination before execution request", + &acknowledged_terminated, + |binding| { + binding.termination_request.as_mut().unwrap().created_at = + binding.request_created_at - time::Duration::nanoseconds(1); + }, + ), + mutation( + "absent termination reason", + &acknowledged_terminated, + |binding| binding.termination_reason_code = None, + ), + mutation("unexpected termination reason", ¬_requested, |binding| { + binding.termination_reason_code = Some("operator_request".to_owned()); + }), + mutation( + "empty termination reason", + &acknowledged_terminated, + |binding| binding.termination_reason_code = Some(String::new()), + ), + mutation( + "oversized termination reason", + &acknowledged_terminated, + |binding| binding.termination_reason_code = Some("a".repeat(129)), + ), + mutation( + "invalid termination reason", + &acknowledged_terminated, + |binding| binding.termination_reason_code = Some("OperatorRequest".to_owned()), + ), + mutation( + "acknowledgement before termination start", + &acknowledged_terminated, + |binding| { + let before_start = binding.termination_request.as_ref().unwrap().created_at + - time::Duration::nanoseconds(1); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = before_start; + }, + ), + mutation( + "acknowledgement after termination deadline", + &acknowledged_terminated, + |binding| { + let after_deadline = binding.termination_request.as_ref().unwrap().valid_until + + time::Duration::nanoseconds(1); + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .acknowledged_at = after_deadline; + }, + ), + mutation( + "unresolved evidence with wrong session", + &termination_unknown, + |binding| { + binding.cancellation_unresolved.as_mut().unwrap().session_id = + "session-b".to_owned(); + }, + ), + mutation( + "unresolved evidence with empty session", + &termination_unknown, + |binding| { + binding.cancellation_unresolved.as_mut().unwrap().session_id = String::new(); + }, + ), + mutation( + "unresolved evidence with oversized session", + &termination_unknown, + |binding| { + binding.cancellation_unresolved.as_mut().unwrap().session_id = "s".repeat(256); + }, + ), + mutation("empty disposition id", &termination_unknown, |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .disposition_id = String::new(); + }), + mutation( + "oversized disposition id", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .disposition_id = "d".repeat(256); + }, + ), + mutation( + "unresolved evidence with wrong termination request id", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .termination_request_id = fixture_other_request_id(); + }, + ), + mutation( + "unresolved evidence with wrong execution request id", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .execution_request_id = fixture_other_request_id(); + }, + ), + mutation( + "unresolved evidence with wrong execution digest", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .execution_request_digest = fixture_other_digest(); + }, + ), + mutation( + "unresolved evidence with empty reason", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .reason_code = String::new(); + }, + ), + mutation( + "unresolved evidence with oversized reason", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .reason_code = "r".repeat(129); + }, + ), + mutation( + "unresolved evidence with invalid reason", + &termination_unknown, + |binding| { + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .reason_code = "TimedOut".to_owned(); + }, + ), + mutation( + "unresolved evidence before termination start", + &termination_unknown, + |binding| { + let before_start = binding.termination_request.as_ref().unwrap().created_at + - time::Duration::nanoseconds(1); + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = before_start; + }, + ), + mutation( + "unresolved evidence after termination deadline", + &termination_unknown, + |binding| { + let after_deadline = binding.termination_request.as_ref().unwrap().valid_until + + time::Duration::nanoseconds(1); + binding + .cancellation_unresolved + .as_mut() + .unwrap() + .recorded_at = after_deadline; + }, + ), + ]; - for binding in cases { + for (name, binding) in cases { let (mut store, _dir) = test_store(); let attempt_id = binding.attempt_id.clone(); - assert!(matches!( - store.insert(&CanonicalDocument::ExecutionBinding(binding)), - Err(StoreError::Contract( - ContractError::CancellationEvidenceMismatch - )) - )); + let result = store.insert(&CanonicalDocument::ExecutionBinding(binding)); assert!( - store - .execution_binding_revisions(&attempt_id) - .unwrap() - .is_empty() + matches!( + result, + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + ), + "{name} returned {result:?}" ); + assert_no_writes(&store, &attempt_id, name); } + + // `Sha256Digest` rejects malformed text at construction, so mutate its wire + // field while retaining the same store-boundary and no-write assertions. + let invalid_authority_digest = format!("sha256:{}", "g".repeat(64)); + assert!(Sha256Digest::parse(&invalid_authority_digest).is_err()); + let mut invalid_authority = serde_json::to_value(&acknowledged_terminated).unwrap(); + invalid_authority["cancellation_acknowledgement"]["authority_evidence_digest"] = + json!(invalid_authority_digest); + let invalid_authority = serde_json::to_vec(&invalid_authority).unwrap(); + let (mut store, _dir) = test_store(); + let result = store.ingest(&invalid_authority); + assert!( + matches!( + result, + Err(StoreError::Contract( + ContractError::CancellationEvidenceMismatch + )) + ), + "invalid authority digest returned {result:?}" + ); + assert_no_writes( + &store, + &acknowledged_terminated.attempt_id, + "invalid authority digest", + ); } #[test] From d23668f3d272b15af6880548edc655ebdfe51ad4 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:16:52 -0500 Subject: [PATCH 31/66] test(store): preserve Task 7 gate names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/tests/retention.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/psyche-store/tests/retention.rs b/crates/psyche-store/tests/retention.rs index 43d5f48..b0a33ef 100644 --- a/crates/psyche-store/tests/retention.rs +++ b/crates/psyche-store/tests/retention.rs @@ -229,7 +229,7 @@ fn quarantine_resolution_is_durable_and_idempotent() { } #[test] -fn quarantine_resolution_rejects_unknown_stale_non_utc_or_conflicting_requests() { +fn quarantine_resolution_rejects_unknown_stale_or_conflicting_requests() { let (mut store, _dir, _path) = test_store(); let unknown = QuarantineId::parse("qua_01J00000000000000000000000").unwrap(); let unknown_resolution = resolution( @@ -487,7 +487,7 @@ fn prune_uses_strictly_older_cutoff_and_never_deletes_unresolved_rows() { } #[test] -fn pruning_preserves_unresolved_quarantine_binding_revisions_transitions_and_audit() { +fn pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions() { let (mut store, _dir, _path) = test_store(); let binding = fixture_binding(); store From d26d078783687995fafdcaed94d9ad00678c67c7 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:43:44 -0500 Subject: [PATCH 32/66] fix(store): validate quarantine audit integrity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/quarantine.rs | 1 + crates/psyche-store/src/records.rs | 21 ++++- crates/psyche-store/src/transitions.rs | 24 ++++-- crates/psyche-store/tests/records.rs | 38 +++++++++ crates/psyche-store/tests/retention.rs | 111 ++++++++++++++++++++++++- 5 files changed, 182 insertions(+), 13 deletions(-) diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index 6f2e408..6075471 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -290,6 +290,7 @@ impl Store { } if let Some(stored) = existing.into_iter().next() { let record = validate_stored(stored)?; + validate_record_audit(&transaction, &record)?; if record.schema_version == rejected.schema_version && record.payload_digest == rejected.payload_digest && record.bounded_payload == rejected.bounded_payload diff --git a/crates/psyche-store/src/records.rs b/crates/psyche-store/src/records.rs index 4044041..902ec9f 100644 --- a/crates/psyche-store/src/records.rs +++ b/crates/psyche-store/src/records.rs @@ -4,6 +4,7 @@ use psyche_core::contracts::{ use psyche_core::digest::{canonical_bytes, digest}; use psyche_core::id::RecordId; use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use time::format_description::well_known::Rfc3339; use crate::{Store, StoreError, execution_bindings}; @@ -13,6 +14,7 @@ struct StoredCanonicalRecord { schema_version: String, digest: String, canonical_json: Vec, + created_at: String, } /// Result of ingesting bytes at the store boundary. @@ -146,6 +148,9 @@ impl Store { let bytes = canonical_bytes(document)?; let record_digest = digest(document)?; let schema_version = document.schema_version().to_string(); + let created_at = time::OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -171,7 +176,7 @@ impl Store { canonical_json, created_at ) - VALUES (?1, ?2, ?3, ?4, ?5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ", params![ kind_key(kind), @@ -179,6 +184,7 @@ impl Store { schema_version, record_digest.as_str(), bytes, + created_at, ], )?; transaction.commit()?; @@ -194,7 +200,7 @@ fn stored_canonical_record( connection .query_row( " - SELECT kind, record_id, schema_version, digest, canonical_json + SELECT kind, record_id, schema_version, digest, canonical_json, created_at FROM canonical_records WHERE kind = ?1 AND record_id = ?2 ", @@ -206,6 +212,7 @@ fn stored_canonical_record( schema_version: row.get(2)?, digest: row.get(3)?, canonical_json: row.get(4)?, + created_at: row.get(5)?, }) }, ) @@ -223,6 +230,11 @@ fn validate_stored_canonical_record( let canonical = canonical_bytes(&document).map_err(|_| StoreError::DatabaseCorruption)?; let recomputed_digest = digest(&document).map_err(|_| StoreError::DatabaseCorruption)?; let schema_version = document.schema_version(); + let created_at = time::OffsetDateTime::parse(&stored.created_at, &Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; + let canonical_created_at = created_at + .format(&Rfc3339) + .map_err(|_| StoreError::DatabaseCorruption)?; if canonical != stored.canonical_json || stored.kind != kind_key(expected_kind) || stored.record_id != expected_id.as_str() @@ -230,6 +242,8 @@ fn validate_stored_canonical_record( || stored.schema_version != schema_version.to_string() || stored.digest != recomputed_digest.as_str() || document.persistable_record_id() != Some(expected_id) + || created_at.offset() != time::UtcOffset::UTC + || stored.created_at != canonical_created_at { return Err(StoreError::DatabaseCorruption); } @@ -304,7 +318,7 @@ pub(crate) fn schema_kind_for_id(id: &RecordId) -> SchemaKind { pub(crate) fn validate_all(connection: &rusqlite::Connection) -> Result<(), StoreError> { let mut statement = connection.prepare( " - SELECT kind, record_id, schema_version, digest, canonical_json + SELECT kind, record_id, schema_version, digest, canonical_json, created_at FROM canonical_records ORDER BY kind, record_id ", @@ -317,6 +331,7 @@ pub(crate) fn validate_all(connection: &rusqlite::Connection) -> Result<(), Stor schema_version: row.get(2)?, digest: row.get(3)?, canonical_json: row.get(4)?, + created_at: row.get(5)?, }) })? .collect::>>() diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs index 2f1a3ea..be42a19 100644 --- a/crates/psyche-store/src/transitions.rs +++ b/crates/psyche-store/src/transitions.rs @@ -39,6 +39,7 @@ struct TransitionDigestInput<'a> { } struct StoredTransition { + sequence: i64, kind: String, record_id: String, from_state: Option, @@ -251,6 +252,7 @@ fn authenticated_history( let mut statement = connection.prepare( " SELECT + sequence, kind, record_id, from_state, @@ -268,13 +270,14 @@ fn authenticated_history( params![records::kind_key(kind), record_id.as_str()], |row| { Ok(StoredTransition { - kind: row.get(0)?, - record_id: row.get(1)?, - from_state: row.get(2)?, - to_state: row.get(3)?, - record_version: row.get(4)?, - transition_digest: row.get(5)?, - created_at: row.get(6)?, + sequence: row.get(0)?, + kind: row.get(1)?, + record_id: row.get(2)?, + from_state: row.get(3)?, + to_state: row.get(4)?, + record_version: row.get(5)?, + transition_digest: row.get(6)?, + created_at: row.get(7)?, }) }, )? @@ -291,8 +294,10 @@ fn authenticate_stored_history( ) -> Result, StoreError> { let mut history = Vec::with_capacity(stored.len()); let mut expected_version = 1_u64; + let mut previous_sequence = None; for row in stored { + let sequence = u64::try_from(row.sequence).map_err(|_| StoreError::DatabaseCorruption)?; let record_version = u64::try_from(row.record_version).map_err(|_| StoreError::DatabaseCorruption)?; let created_at = time::OffsetDateTime::parse(&row.created_at, &Rfc3339) @@ -311,7 +316,9 @@ fn authenticate_stored_history( .format(&Rfc3339) .map_err(|_| StoreError::DatabaseCorruption)?; - if row.kind != records::kind_key(reconstructed.kind) + if sequence == 0 + || previous_sequence.is_some_and(|previous| sequence <= previous) + || row.kind != records::kind_key(reconstructed.kind) || row.record_id != reconstructed.record_id.as_str() || row.from_state != reconstructed.from_state || row.to_state != reconstructed.to_state @@ -331,6 +338,7 @@ fn authenticate_stored_history( expected_version = expected_version .checked_add(1) .ok_or(StoreError::DatabaseCorruption)?; + previous_sequence = Some(sequence); history.push(reconstructed); } diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index d8a9ba6..0f3e07d 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -2281,6 +2281,44 @@ fn transition_append_rejects_noncanonical_stored_timestamp_without_writing() { assert_eq!(store.count_transitions().unwrap(), 1); } +#[test] +fn transition_history_rejects_nonpositive_or_nonmonotonic_sequence() { + for tamper in ["nonpositive", "nonmonotonic"] { + let (mut store, _dir, path) = test_store_with_path(); + store + .append_transition(&transition(1, None, "admitted")) + .unwrap(); + if tamper == "nonmonotonic" { + store + .append_transition(&transition(2, Some("admitted"), "running")) + .unwrap(); + } + let connection = raw_connection(&path); + match tamper { + "nonpositive" => { + connection + .execute( + "UPDATE transitions SET sequence = 0 WHERE record_version = 1", + [], + ) + .unwrap(); + } + "nonmonotonic" => { + connection + .execute( + "UPDATE transitions SET sequence = 3 WHERE record_version = 1", + [], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_database_corruption(store.transitions(&fixture_attempt_id())); + } +} + fn insert_intent_for_tamper() -> ( Store, tempfile::TempDir, diff --git a/crates/psyche-store/tests/retention.rs b/crates/psyche-store/tests/retention.rs index b0a33ef..3c6719c 100644 --- a/crates/psyche-store/tests/retention.rs +++ b/crates/psyche-store/tests/retention.rs @@ -6,7 +6,8 @@ use std::sync::{Arc, Barrier}; use psyche_core::contracts::execution::{AdoptionState, CancellationState, ExecutionBinding}; use psyche_core::contracts::{ - CanonicalDocument, RecordKind, RejectedDocument, RejectionReason, SchemaKind, SchemaVersion, + CanonicalDocument, Intent, RecordKind, RejectedDocument, RejectionReason, SchemaKind, + SchemaVersion, }; use psyche_core::digest::{Sha256Digest, canonical_bytes}; use psyche_core::id::{RecordId, RequestId}; @@ -15,7 +16,7 @@ use psyche_store::{ QuarantineResolutionCode, ResolveQuarantineOutcome, Store, StoreError, Transition, }; use rusqlite::{Connection, params}; -use serde_json::json; +use serde_json::{Map, json}; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime, UtcOffset}; @@ -76,6 +77,22 @@ fn fixture_binding() -> ExecutionBinding { } } +fn fixture_intent() -> Intent { + Intent { + schema_version: SchemaVersion::parse("psyche.intent.v1").unwrap(), + intent_id: record_id(RecordKind::Intent, "01J00000000000000000000003"), + principal_id: "principal-a".to_owned(), + familiar_snapshot_id: fixture_snapshot_id(), + project_id: "project-a".to_owned(), + requested_outcome: "retain integrity".to_owned(), + constraints: Map::new(), + required_evidence: vec!["review".to_owned()], + surface_event_id: None, + created_at: at("2026-08-05T12:00:00Z"), + digest: fixture_digest('c'), + } +} + fn fixture_transition() -> Transition { Transition::new( SchemaKind::ExecutionBinding, @@ -228,6 +245,67 @@ fn quarantine_resolution_is_durable_and_idempotent() { assert_eq!(reopened.audit_events().unwrap().len(), 1); } +#[test] +fn exact_quarantine_replay_rejects_missing_corrupt_or_duplicate_resolution_audit() { + for tamper in ["delete", "corrupt", "duplicate"] { + let (mut store, _dir, path) = test_store(); + let rejected = RejectedDocument::from_bytes( + br#"{"schema_version":"psyche.future.v1"}"#, + RejectionReason::UnknownSchema, + ); + let id = store.quarantine(rejected.clone()).unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + store + .resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + + let connection = raw_connection(&path); + match tamper { + "delete" => { + connection + .execute( + "DELETE FROM audit_events WHERE correlation_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "corrupt" => { + connection + .execute( + "UPDATE audit_events SET public_details_json = X'7B' WHERE correlation_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "duplicate" => { + connection + .execute( + " + INSERT INTO audit_events ( + event_code, correlation_id, public_details_json, created_at + ) + SELECT event_code, correlation_id, public_details_json, created_at + FROM audit_events + WHERE correlation_id = ?1 + ", + [id.as_str()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_database_corruption(store.quarantine(rejected)); + } +} + #[test] fn quarantine_resolution_rejects_unknown_stale_or_conflicting_requests() { let (mut store, _dir, _path) = test_store(); @@ -452,6 +530,35 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { assert!(store.quarantine_record(&id).unwrap().is_some()); } +#[test] +fn malformed_canonical_created_at_fails_prune_before_deleting_eligible_quarantine() { + for tampered in [ + "not-a-timestamp", + "2026-08-05T13:00:00+01:00", + "2026-08-05T12:00:00+00:00", + ] { + let (mut store, _dir, path) = test_store(); + store + .insert(&CanonicalDocument::Intent(fixture_intent())) + .unwrap(); + let id = quarantined_fixture(&mut store); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let resolved_at = discovered_at + Duration::seconds(1); + store + .resolve_quarantine( + &id, + &resolution(QuarantineResolutionCode::ConfirmedInvalid, resolved_at), + ) + .unwrap(); + raw_connection(&path) + .execute("UPDATE canonical_records SET created_at = ?1", [tampered]) + .unwrap(); + + assert_database_corruption(store.prune(resolved_at + Duration::nanoseconds(1))); + assert!(store.quarantine_record(&id).unwrap().is_some()); + } +} + #[test] fn prune_uses_strictly_older_cutoff_and_never_deletes_unresolved_rows() { let (mut store, _dir, _path) = test_store(); From 9ddb9b5439aaa7c38507f742f1790af4c8b41816 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:57:38 -0500 Subject: [PATCH 33/66] fix(store): snapshot quarantine integrity reads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/quarantine.rs | 55 ++++++++-- crates/psyche-store/tests/retention.rs | 144 ++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 8 deletions(-) diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index 6075471..a2d491d 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -1,6 +1,6 @@ use std::fmt; -use psyche_core::contracts::{RejectedDocument, RejectionReason}; +use psyche_core::contracts::{RejectedDocument, RejectionReason, SchemaKind}; use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use time::format_description::well_known::Rfc3339; @@ -104,6 +104,25 @@ impl QuarantineReasonCode { _ => Err(StoreError::DatabaseCorruption), } } + + fn rejection_reason(self) -> RejectionReason { + match self { + Self::TooLarge => RejectionReason::TooLarge, + Self::UnknownSchema => RejectionReason::UnknownSchema, + Self::UnsupportedMajor => RejectionReason::UnsupportedMajor { + found: 0, + supported: 0, + }, + Self::UnknownEnumValue => RejectionReason::UnknownEnumValue { + schema: SchemaKind::Error, + field: "persisted", + }, + Self::InvalidShape => RejectionReason::InvalidShape { + schema: SchemaKind::Error, + field: "persisted", + }, + } + } } impl From<&RejectionReason> for QuarantineReasonCode { @@ -345,12 +364,25 @@ impl Store { id: &QuarantineId, ) -> Result, StoreError> { validate_typed_id(id)?; - let Some(stored) = stored_by_id(&self.connection, id.as_str())? else { - return Ok(None); - }; - let record = validate_stored(stored)?; - validate_record_audit(&self.connection, &record)?; - Ok(Some(record)) + let transaction = self.connection.unchecked_transaction()?; + let result = (|| { + let Some(stored) = stored_by_id(&transaction, id.as_str())? else { + return Ok(None); + }; + let record = validate_stored(stored)?; + validate_record_audit(&transaction, &record)?; + Ok(Some(record)) + })(); + match result { + Ok(record) => { + transaction.commit()?; + Ok(record) + } + Err(error) => { + transaction.rollback()?; + Err(error) + } + } } /// Atomically establishes or exactly replays one quarantine resolution. @@ -643,6 +675,15 @@ fn validate_stored(stored: StoredQuarantineRecord) -> Result>(); + let barrier = Arc::new(Barrier::new(READER_COUNT + 1)); + let resolved = Arc::new(AtomicBool::new(false)); + let handles = readers + .into_iter() + .map(|reader| { + let id = id.clone(); + let barrier = Arc::clone(&barrier); + let resolved = Arc::clone(&resolved); + std::thread::spawn(move || -> Result<(), StoreError> { + reader.quarantine_record(&id)?; + barrier.wait(); + while !resolved.load(Ordering::Acquire) { + reader.quarantine_record(&id)?; + std::thread::yield_now(); + } + for _ in 0..32 { + reader.quarantine_record(&id)?; + } + Ok(()) + }) + }) + .collect::>(); + + barrier.wait(); + std::thread::sleep(std::time::Duration::from_millis(10)); + resolver + .resolve_quarantine( + &id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + ) + .unwrap(); + resolved.store(true, Ordering::Release); + + for handle in handles { + handle.join().unwrap().unwrap(); + } + let final_record = resolver.quarantine_record(&id).unwrap().unwrap(); + assert_eq!( + final_record.resolution_code, + Some(QuarantineResolutionCode::ConfirmedInvalid) + ); + assert!(final_record.resolution_digest.is_some()); + assert_eq!(resolver.audit_events().unwrap().len(), 1); +} + #[test] fn resolution_audit_details_are_canonical_and_payload_redacted() { let (mut store, _dir, _path) = test_store(); From e03603909a8ac9cc6231684f341b02fb2f79a081 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:58:37 -0500 Subject: [PATCH 34/66] fix(store): persist quarantine integrity metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/mod.rs | 132 +++++++- crates/psyche-core/tests/decode.rs | 11 + .../migrations/001_foundation.sql | 2 + crates/psyche-store/src/quarantine.rs | 68 +++- crates/psyche-store/tests/migrations.rs | 50 ++- crates/psyche-store/tests/retention.rs | 318 ++++++++++++++++-- crates/psyche-store/tests/support/mod.rs | 14 + 7 files changed, 534 insertions(+), 61 deletions(-) diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 0adb979..d390887 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -642,23 +642,37 @@ pub struct RejectedDocument { pub bounded_payload: Vec, /// Payload-light rejection classification. pub reason: RejectionReason, + attestation: RejectedDocumentAttestation, +} + +#[derive(Clone, PartialEq, Eq)] +struct RejectedDocumentAttestation { + original_len: usize, + material_digest: Sha256Digest, } impl RejectedDocument { /// Builds quarantine input without requiring valid UTF-8 or valid JSON. pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { - let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { - strict_json(bytes) - .ok() - .and_then(|value| retained_schema_version(&value)) - } else { - None + let schema_version = retained_schema_version_from_bytes(bytes); + let payload_digest = Sha256Digest::from_raw_bytes(bytes); + let bounded_payload = bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(); + let attestation = RejectedDocumentAttestation { + original_len: bytes.len(), + material_digest: rejected_document_attestation( + bytes.len(), + &schema_version, + &payload_digest, + &bounded_payload, + &reason, + ), }; Self { schema_version, - payload_digest: Sha256Digest::from_raw_bytes(bytes), - bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), + payload_digest, + bounded_payload, reason, + attestation, } } @@ -693,6 +707,40 @@ impl RejectedDocument { }; Self::from_bytes(bytes, reason) } + + /// Returns the complete raw-input length captured by the constructor. + pub fn original_payload_len(&self) -> usize { + self.attestation.original_len + } + + /// Returns the SHA-256 digest of the retained payload bytes. + pub fn retained_payload_digest(&self) -> Sha256Digest { + Sha256Digest::from_raw_bytes(&self.bounded_payload) + } + + /// Returns whether the public fields still match constructor-attested input. + pub fn is_authentic(&self) -> bool { + let expected_bounded_len = self + .attestation + .original_len + .min(MAX_REJECTED_PAYLOAD_BYTES); + if self.bounded_payload.len() != expected_bounded_len { + return false; + } + if self.attestation.original_len <= MAX_REJECTED_PAYLOAD_BYTES + && (Sha256Digest::from_raw_bytes(&self.bounded_payload) != self.payload_digest + || retained_schema_version_from_bytes(&self.bounded_payload) != self.schema_version) + { + return false; + } + rejected_document_attestation( + self.attestation.original_len, + &self.schema_version, + &self.payload_digest, + &self.bounded_payload, + &self.reason, + ) == self.attestation.material_digest + } } impl fmt::Debug for RejectedDocument { @@ -705,6 +753,74 @@ impl fmt::Debug for RejectedDocument { } } +fn retained_schema_version_from_bytes(bytes: &[u8]) -> Option { + if bytes.len() > MAX_DOCUMENT_BYTES { + return None; + } + strict_json(bytes) + .ok() + .and_then(|value| retained_schema_version(&value)) +} + +fn rejected_document_attestation( + original_len: usize, + schema_version: &Option, + payload_digest: &Sha256Digest, + bounded_payload: &[u8], + reason: &RejectionReason, +) -> Sha256Digest { + let mut material = b"psyche.rejected-document.attestation.v1".to_vec(); + append_attestation_frame(&mut material, b"original_len", &original_len.to_be_bytes()); + match schema_version { + Some(schema_version) => { + append_attestation_frame(&mut material, b"schema_present", b"true"); + append_attestation_frame(&mut material, b"schema_version", schema_version.as_bytes()); + } + None => append_attestation_frame(&mut material, b"schema_present", b"false"), + } + append_attestation_frame( + &mut material, + b"payload_digest", + payload_digest.as_str().as_bytes(), + ); + append_attestation_frame(&mut material, b"bounded_payload", bounded_payload); + append_rejection_reason_attestation(&mut material, reason); + Sha256Digest::from_raw_bytes(&material) +} + +fn append_rejection_reason_attestation(material: &mut Vec, reason: &RejectionReason) { + match reason { + RejectionReason::TooLarge => { + append_attestation_frame(material, b"reason", b"too_large"); + } + RejectionReason::UnknownSchema => { + append_attestation_frame(material, b"reason", b"unknown_schema"); + } + RejectionReason::UnsupportedMajor { found, supported } => { + append_attestation_frame(material, b"reason", b"unsupported_major"); + append_attestation_frame(material, b"found", &found.to_be_bytes()); + append_attestation_frame(material, b"supported", &supported.to_be_bytes()); + } + RejectionReason::UnknownEnumValue { schema, field } => { + append_attestation_frame(material, b"reason", b"unknown_enum_value"); + append_attestation_frame(material, b"schema", schema.name().as_bytes()); + append_attestation_frame(material, b"field", field.as_bytes()); + } + RejectionReason::InvalidShape { schema, field } => { + append_attestation_frame(material, b"reason", b"invalid_shape"); + append_attestation_frame(material, b"schema", schema.name().as_bytes()); + append_attestation_frame(material, b"field", field.as_bytes()); + } + } +} + +fn append_attestation_frame(material: &mut Vec, label: &[u8], value: &[u8]) { + material.extend_from_slice(&label.len().to_be_bytes()); + material.extend_from_slice(label); + material.extend_from_slice(&value.len().to_be_bytes()); + material.extend_from_slice(value); +} + /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs index 0648100..4543f54 100644 --- a/crates/psyche-core/tests/decode.rs +++ b/crates/psyche-core/tests/decode.rs @@ -306,6 +306,9 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { small.payload_digest.as_str(), "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); + assert_eq!(small.original_payload_len(), 3); + assert_eq!(small.retained_payload_digest(), small.payload_digest); + assert!(small.is_authentic()); let mut left = vec![b'a'; 64 * 1024 + 1]; let mut right = left.clone(); @@ -331,6 +334,14 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { assert_eq!(right.bounded_payload.len(), 64 * 1024); assert_eq!(left.bounded_payload, right.bounded_payload); assert_ne!(left.payload_digest, right.payload_digest); + assert_eq!(left.original_payload_len(), 64 * 1024 + 1); + assert_eq!(right.original_payload_len(), 64 * 1024 + 1); + assert_eq!( + left.retained_payload_digest(), + right.retained_payload_digest() + ); + assert!(left.is_authentic()); + assert!(right.is_authentic()); } #[test] diff --git a/crates/psyche-store/migrations/001_foundation.sql b/crates/psyche-store/migrations/001_foundation.sql index b500bc1..5593060 100644 --- a/crates/psyche-store/migrations/001_foundation.sql +++ b/crates/psyche-store/migrations/001_foundation.sql @@ -45,6 +45,8 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, + original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), + retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index a2d491d..20189d5 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -203,6 +203,10 @@ pub struct QuarantineRecord { pub schema_version: Option, /// SHA-256 digest over the complete raw input. pub payload_digest: Sha256Digest, + /// Complete raw-input length before the retained payload was bounded. + pub original_payload_len: usize, + /// SHA-256 digest over exactly the retained payload bytes. + pub retained_payload_digest: Sha256Digest, /// At most 64 KiB retained from the beginning of the raw input. pub bounded_payload: Vec, /// Stable payload-free rejection classification. @@ -224,6 +228,8 @@ impl fmt::Debug for QuarantineRecord { .field("quarantine_id", &self.quarantine_id) .field("schema_version", &self.schema_version) .field("payload_digest", &self.payload_digest) + .field("original_payload_len", &self.original_payload_len) + .field("retained_payload_digest", &self.retained_payload_digest) .field("bounded_payload_bytes", &self.bounded_payload.len()) .field("reason", &self.reason) .field("discovered_at", &self.discovered_at) @@ -253,6 +259,8 @@ struct StoredQuarantineRecord { quarantine_id: String, schema_version: Option, payload_digest: String, + original_payload_len: i64, + retained_payload_digest: String, bounded_payload: Vec, reason: String, discovered_at: String, @@ -296,6 +304,10 @@ impl Store { pub fn quarantine(&mut self, rejected: RejectedDocument) -> Result { validate_rejected(&rejected)?; let reason = QuarantineReasonCode::from(&rejected.reason); + let original_payload_len = rejected.original_payload_len(); + let stored_original_payload_len = + i64::try_from(original_payload_len).map_err(|_| StoreError::InvalidQuarantineRecord)?; + let retained_payload_digest = rejected.retained_payload_digest(); let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -312,6 +324,8 @@ impl Store { validate_record_audit(&transaction, &record)?; if record.schema_version == rejected.schema_version && record.payload_digest == rejected.payload_digest + && record.original_payload_len == original_payload_len + && record.retained_payload_digest == retained_payload_digest && record.bounded_payload == rejected.bounded_payload && record.reason == reason { @@ -333,16 +347,20 @@ impl Store { quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ", params![ quarantine_id.as_str(), rejected.schema_version.as_deref(), rejected.payload_digest.as_str(), + stored_original_payload_len, + retained_payload_digest.as_str(), rejected.bounded_payload, reason.as_str(), discovered_at, @@ -539,7 +557,8 @@ fn validate_typed_id(id: &QuarantineId) -> Result<(), StoreError> { } fn validate_rejected(rejected: &RejectedDocument) -> Result<(), StoreError> { - if rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + if !rejected.is_authentic() + || rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES || !rejected .schema_version .as_deref() @@ -580,6 +599,8 @@ fn stored_by_id( quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at, @@ -607,6 +628,8 @@ fn stored_by_digest_and_reason( quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at, @@ -631,6 +654,8 @@ fn load_all_stored(connection: &Connection) -> Result) -> rusqlite::Result Result { let quarantine_id = QuarantineId::parse(&stored.quarantine_id).map_err(|_| StoreError::DatabaseCorruption)?; + let original_payload_len = + usize::try_from(stored.original_payload_len).map_err(|_| StoreError::DatabaseCorruption)?; if !stored .schema_version .as_deref() .is_none_or(schema_version_is_safe) || stored.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + || stored.bounded_payload.len() != original_payload_len.min(MAX_BOUNDED_PAYLOAD_BYTES) { return Err(StoreError::DatabaseCorruption); } let payload_digest = Sha256Digest::parse(&stored.payload_digest).map_err(|_| StoreError::DatabaseCorruption)?; + let retained_payload_digest = Sha256Digest::parse(&stored.retained_payload_digest) + .map_err(|_| StoreError::DatabaseCorruption)?; let reason = QuarantineReasonCode::parse(&stored.reason)?; - if stored.bounded_payload.len() < MAX_BOUNDED_PAYLOAD_BYTES { - let reconstructed = - RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); - if reconstructed.payload_digest != payload_digest - || reconstructed.schema_version != stored.schema_version - { - return Err(StoreError::DatabaseCorruption); - } + let reconstructed = + RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); + if reconstructed.retained_payload_digest() != retained_payload_digest { + return Err(StoreError::DatabaseCorruption); + } + if original_payload_len <= MAX_BOUNDED_PAYLOAD_BYTES + && (reconstructed.payload_digest != payload_digest + || reconstructed.schema_version != stored.schema_version) + { + return Err(StoreError::DatabaseCorruption); } let discovered_at = parse_canonical_utc(&stored.discovered_at)?; @@ -725,6 +759,8 @@ fn validate_stored(stored: StoredQuarantineRecord) -> Result QuarantineId { quarantine_id } +fn complete_64_kib_payload() -> Vec { + let empty = r#"{"schema_version":"psyche.future.v1","padding":""}"#; + let padding = "x".repeat(64 * 1024 - empty.len()); + let bytes = + format!(r#"{{"schema_version":"psyche.future.v1","padding":"{padding}"}}"#).into_bytes(); + assert_eq!(bytes.len(), 64 * 1024); + bytes +} + fn resolution(code: QuarantineResolutionCode, resolved_at: OffsetDateTime) -> QuarantineResolution { QuarantineResolution { code, resolved_at } } @@ -136,6 +145,22 @@ fn assert_database_corruption(result: Result) assert!(error.source().is_none()); } +fn assert_quarantine_paths_detect_corruption( + store: &mut Store, + id: &QuarantineId, + discovered_at: OffsetDateTime, +) { + assert_database_corruption(store.quarantine_record(id)); + assert_database_corruption(store.resolve_quarantine( + id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + )); + assert_database_corruption(store.prune(discovered_at + Duration::days(1))); +} + #[test] fn quarantine_id_constructor_parser_and_serde_round_trip() { let generated = QuarantineId::new(); @@ -217,6 +242,257 @@ fn quarantine_is_bounded_idempotent_and_reason_sensitive() { assert_ne!(other_id, id); } +#[test] +fn quarantine_rejects_forged_exactly_64_kib_digest() { + let (mut store, _dir, _path) = test_store(); + let mut rejected = + RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::TooLarge); + rejected.payload_digest = fixture_digest('a'); + + let error = store.quarantine(rejected).unwrap_err(); + assert!(matches!(error, StoreError::InvalidQuarantineRecord)); + assert_eq!(error.to_string(), "quarantine record is invalid"); + assert_eq!( + format!("{error:?}"), + "StoreError(quarantine record is invalid)" + ); + assert!(error.source().is_none()); +} + +#[test] +fn quarantine_rejects_exactly_64_kib_public_field_mutations() { + let (mut store, _dir, _path) = test_store(); + let original = + RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::UnknownSchema); + + let mut schema_mutated = original.clone(); + schema_mutated.schema_version = Some("psyche.other.v1".to_owned()); + let mut digest_mutated = original.clone(); + digest_mutated.payload_digest = fixture_digest('b'); + let mut payload_mutated = original.clone(); + payload_mutated.bounded_payload[0] = b'y'; + let mut reason_mutated = original; + reason_mutated.reason = RejectionReason::TooLarge; + + for rejected in [ + schema_mutated, + digest_mutated, + payload_mutated, + reason_mutated, + ] { + let error = store.quarantine(rejected).unwrap_err(); + assert!(matches!(error, StoreError::InvalidQuarantineRecord)); + assert_eq!(error.to_string(), "quarantine record is invalid"); + assert!(error.source().is_none()); + } +} + +#[test] +fn complete_64_kib_quarantine_integrity_tampering_fails_closed() { + for tamper in [ + "bounded_payload", + "retained_payload_digest", + "original_payload_len", + "negative_original_payload_len", + "payload_digest", + "schema_version", + ] { + let (mut store, _dir, path) = test_store(); + let payload = complete_64_kib_payload(); + let id = store + .quarantine(RejectedDocument::from_bytes( + &payload, + RejectionReason::UnknownSchema, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let connection = raw_connection(&path); + match tamper { + "bounded_payload" => { + let mut mutated = payload; + let index = mutated.iter().rposition(|byte| *byte == b'x').unwrap(); + mutated[index] = b'y'; + connection + .execute( + "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", + params![mutated, id.as_str()], + ) + .unwrap(); + } + "retained_payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('d').as_str(), id.as_str()], + ) + .unwrap(); + } + "original_payload_len" => { + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = ?1 WHERE quarantine_id = ?2", + params![64 * 1024 - 1, id.as_str()], + ) + .unwrap(); + } + "negative_original_payload_len" => { + connection + .execute_batch("PRAGMA ignore_check_constraints = ON;") + .unwrap(); + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = -1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('e').as_str(), id.as_str()], + ) + .unwrap(); + } + "schema_version" => { + connection + .execute( + "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); + } +} + +#[test] +fn short_quarantine_integrity_tampering_fails_closed() { + for tamper in [ + "bounded_payload", + "retained_payload_digest", + "original_payload_len", + "payload_digest", + "schema_version", + ] { + let (mut store, _dir, path) = test_store(); + let payload = br#"{"schema_version":"psyche.future.v1"}"#; + let id = store + .quarantine(RejectedDocument::from_bytes( + payload, + RejectionReason::UnknownSchema, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let connection = raw_connection(&path); + match tamper { + "bounded_payload" => { + connection + .execute( + "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", + params![b"corrupted payload", id.as_str()], + ) + .unwrap(); + } + "retained_payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('d').as_str(), id.as_str()], + ) + .unwrap(); + } + "original_payload_len" => { + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('e').as_str(), id.as_str()], + ) + .unwrap(); + } + "schema_version" => { + connection + .execute( + "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); + } +} + +#[test] +fn oversized_quarantine_integrity_metadata_round_trips_after_reopen() { + let (mut store, _dir, path) = test_store(); + let mut payload = vec![b'x'; 64 * 1024]; + payload.extend_from_slice(b"tail-not-retained"); + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let expected_payload_digest = rejected.payload_digest.clone(); + let expected_retained_digest = rejected.retained_payload_digest(); + let id = store.quarantine(rejected.clone()).unwrap(); + drop(store); + + let mut reopened = Store::open(&path).unwrap(); + let record = reopened.quarantine_record(&id).unwrap().unwrap(); + assert_eq!(record.original_payload_len, payload.len()); + assert_eq!(record.retained_payload_digest, expected_retained_digest); + assert_eq!(record.payload_digest, expected_payload_digest); + assert_eq!(record.bounded_payload, payload[..64 * 1024]); + assert_eq!(reopened.quarantine(rejected).unwrap(), id); +} + +#[test] +fn dedupe_equality_includes_integrity_metadata() { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 17]; + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let id = store.quarantine(rejected.clone()).unwrap(); + raw_connection(&path) + .execute( + "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + + assert!(matches!( + store.quarantine(rejected), + Err(StoreError::QuarantineConflict { .. }) + )); +} + +#[test] +fn quarantine_replay_validates_persisted_integrity_metadata() { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 1]; + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let id = store.quarantine(rejected.clone()).unwrap(); + raw_connection(&path) + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('f').as_str(), id.as_str()], + ) + .unwrap(); + + assert_database_corruption(store.quarantine(rejected)); +} + #[test] fn complete_persisted_quarantine_rejects_valid_format_payload_tampering() { let (mut store, _dir, path) = test_store(); @@ -259,38 +535,6 @@ fn complete_persisted_quarantine_rejects_valid_format_schema_tampering() { assert_database_corruption(store.quarantine_record(&id)); } -#[test] -fn fully_bounded_persisted_quarantine_retains_shape_only_validation() { - for raw_len in [64 * 1024, 64 * 1024 + 1] { - let (mut store, _dir, path) = test_store(); - let id = store - .quarantine(RejectedDocument::from_bytes( - &vec![b'x'; raw_len], - RejectionReason::UnknownSchema, - )) - .unwrap(); - raw_connection(&path) - .execute( - " - UPDATE quarantine_records - SET payload_digest = ?1, schema_version = ?2 - WHERE quarantine_id = ?3 - ", - params![ - fixture_digest('e').as_str(), - "psyche.future.v1", - id.as_str() - ], - ) - .unwrap(); - - let record = store.quarantine_record(&id).unwrap().unwrap(); - assert_eq!(record.bounded_payload.len(), 64 * 1024); - assert_eq!(record.payload_digest, fixture_digest('e')); - assert_eq!(record.schema_version.as_deref(), Some("psyche.future.v1")); - } -} - #[test] fn quarantine_resolution_is_durable_and_idempotent() { let (mut store, _dir, path) = test_store(); @@ -644,6 +888,8 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { let (mut store, _dir, path) = test_store(); let id = quarantined_fixture(&mut store); let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let empty_payload = + RejectedDocument::from_bytes(b"", RejectionReason::UnknownSchema).payload_digest; store .resolve_quarantine( &id, @@ -657,12 +903,12 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { .execute( " INSERT INTO quarantine_records ( - quarantine_id, schema_version, payload_digest, bounded_payload, - reason, discovered_at - ) VALUES ('bad-id', NULL, ?1, X'', 'unknown_schema', ?2) + quarantine_id, schema_version, payload_digest, original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at + ) VALUES ('bad-id', NULL, ?1, 0, ?1, X'', 'unknown_schema', ?2) ", params![ - fixture_digest('b').as_str(), + empty_payload.as_str(), discovered_at.format(&Rfc3339).unwrap() ], ) diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index aba1ff2..a9e24ed 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -146,6 +146,18 @@ pub(super) fn table_exists(path: &Path, name: &str) -> bool { .unwrap() } +pub(super) fn table_columns(path: &Path, name: &str) -> Vec { + let connection = Connection::open(path).unwrap(); + let mut statement = connection + .prepare(&format!("PRAGMA table_info({name})")) + .unwrap(); + statement + .query_map([], |row| row.get(1)) + .unwrap() + .collect::>>() + .unwrap() +} + pub(super) fn scalar_text(path: &Path, sql: &str) -> String { let connection = Connection::open(path).unwrap(); connection.query_row(sql, [], |row| row.get(0)).unwrap() @@ -206,6 +218,8 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, + original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), + retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, From 1a6da58be75bf42202c09d8e7b9884e252fd4052 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:59:04 -0500 Subject: [PATCH 35/66] fix(store): preserve frozen quarantine schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/mod.rs | 132 +------- crates/psyche-core/tests/decode.rs | 11 - .../migrations/001_foundation.sql | 2 - crates/psyche-store/src/quarantine.rs | 68 +--- crates/psyche-store/tests/migrations.rs | 50 +-- crates/psyche-store/tests/retention.rs | 318 ++---------------- crates/psyche-store/tests/support/mod.rs | 14 - 7 files changed, 61 insertions(+), 534 deletions(-) diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index d390887..0adb979 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -642,37 +642,23 @@ pub struct RejectedDocument { pub bounded_payload: Vec, /// Payload-light rejection classification. pub reason: RejectionReason, - attestation: RejectedDocumentAttestation, -} - -#[derive(Clone, PartialEq, Eq)] -struct RejectedDocumentAttestation { - original_len: usize, - material_digest: Sha256Digest, } impl RejectedDocument { /// Builds quarantine input without requiring valid UTF-8 or valid JSON. pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { - let schema_version = retained_schema_version_from_bytes(bytes); - let payload_digest = Sha256Digest::from_raw_bytes(bytes); - let bounded_payload = bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(); - let attestation = RejectedDocumentAttestation { - original_len: bytes.len(), - material_digest: rejected_document_attestation( - bytes.len(), - &schema_version, - &payload_digest, - &bounded_payload, - &reason, - ), + let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { + strict_json(bytes) + .ok() + .and_then(|value| retained_schema_version(&value)) + } else { + None }; Self { schema_version, - payload_digest, - bounded_payload, + payload_digest: Sha256Digest::from_raw_bytes(bytes), + bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), reason, - attestation, } } @@ -707,40 +693,6 @@ impl RejectedDocument { }; Self::from_bytes(bytes, reason) } - - /// Returns the complete raw-input length captured by the constructor. - pub fn original_payload_len(&self) -> usize { - self.attestation.original_len - } - - /// Returns the SHA-256 digest of the retained payload bytes. - pub fn retained_payload_digest(&self) -> Sha256Digest { - Sha256Digest::from_raw_bytes(&self.bounded_payload) - } - - /// Returns whether the public fields still match constructor-attested input. - pub fn is_authentic(&self) -> bool { - let expected_bounded_len = self - .attestation - .original_len - .min(MAX_REJECTED_PAYLOAD_BYTES); - if self.bounded_payload.len() != expected_bounded_len { - return false; - } - if self.attestation.original_len <= MAX_REJECTED_PAYLOAD_BYTES - && (Sha256Digest::from_raw_bytes(&self.bounded_payload) != self.payload_digest - || retained_schema_version_from_bytes(&self.bounded_payload) != self.schema_version) - { - return false; - } - rejected_document_attestation( - self.attestation.original_len, - &self.schema_version, - &self.payload_digest, - &self.bounded_payload, - &self.reason, - ) == self.attestation.material_digest - } } impl fmt::Debug for RejectedDocument { @@ -753,74 +705,6 @@ impl fmt::Debug for RejectedDocument { } } -fn retained_schema_version_from_bytes(bytes: &[u8]) -> Option { - if bytes.len() > MAX_DOCUMENT_BYTES { - return None; - } - strict_json(bytes) - .ok() - .and_then(|value| retained_schema_version(&value)) -} - -fn rejected_document_attestation( - original_len: usize, - schema_version: &Option, - payload_digest: &Sha256Digest, - bounded_payload: &[u8], - reason: &RejectionReason, -) -> Sha256Digest { - let mut material = b"psyche.rejected-document.attestation.v1".to_vec(); - append_attestation_frame(&mut material, b"original_len", &original_len.to_be_bytes()); - match schema_version { - Some(schema_version) => { - append_attestation_frame(&mut material, b"schema_present", b"true"); - append_attestation_frame(&mut material, b"schema_version", schema_version.as_bytes()); - } - None => append_attestation_frame(&mut material, b"schema_present", b"false"), - } - append_attestation_frame( - &mut material, - b"payload_digest", - payload_digest.as_str().as_bytes(), - ); - append_attestation_frame(&mut material, b"bounded_payload", bounded_payload); - append_rejection_reason_attestation(&mut material, reason); - Sha256Digest::from_raw_bytes(&material) -} - -fn append_rejection_reason_attestation(material: &mut Vec, reason: &RejectionReason) { - match reason { - RejectionReason::TooLarge => { - append_attestation_frame(material, b"reason", b"too_large"); - } - RejectionReason::UnknownSchema => { - append_attestation_frame(material, b"reason", b"unknown_schema"); - } - RejectionReason::UnsupportedMajor { found, supported } => { - append_attestation_frame(material, b"reason", b"unsupported_major"); - append_attestation_frame(material, b"found", &found.to_be_bytes()); - append_attestation_frame(material, b"supported", &supported.to_be_bytes()); - } - RejectionReason::UnknownEnumValue { schema, field } => { - append_attestation_frame(material, b"reason", b"unknown_enum_value"); - append_attestation_frame(material, b"schema", schema.name().as_bytes()); - append_attestation_frame(material, b"field", field.as_bytes()); - } - RejectionReason::InvalidShape { schema, field } => { - append_attestation_frame(material, b"reason", b"invalid_shape"); - append_attestation_frame(material, b"schema", schema.name().as_bytes()); - append_attestation_frame(material, b"field", field.as_bytes()); - } - } -} - -fn append_attestation_frame(material: &mut Vec, label: &[u8], value: &[u8]) { - material.extend_from_slice(&label.len().to_be_bytes()); - material.extend_from_slice(label); - material.extend_from_slice(&value.len().to_be_bytes()); - material.extend_from_slice(value); -} - /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs index 4543f54..0648100 100644 --- a/crates/psyche-core/tests/decode.rs +++ b/crates/psyche-core/tests/decode.rs @@ -306,9 +306,6 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { small.payload_digest.as_str(), "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); - assert_eq!(small.original_payload_len(), 3); - assert_eq!(small.retained_payload_digest(), small.payload_digest); - assert!(small.is_authentic()); let mut left = vec![b'a'; 64 * 1024 + 1]; let mut right = left.clone(); @@ -334,14 +331,6 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { assert_eq!(right.bounded_payload.len(), 64 * 1024); assert_eq!(left.bounded_payload, right.bounded_payload); assert_ne!(left.payload_digest, right.payload_digest); - assert_eq!(left.original_payload_len(), 64 * 1024 + 1); - assert_eq!(right.original_payload_len(), 64 * 1024 + 1); - assert_eq!( - left.retained_payload_digest(), - right.retained_payload_digest() - ); - assert!(left.is_authentic()); - assert!(right.is_authentic()); } #[test] diff --git a/crates/psyche-store/migrations/001_foundation.sql b/crates/psyche-store/migrations/001_foundation.sql index 5593060..b500bc1 100644 --- a/crates/psyche-store/migrations/001_foundation.sql +++ b/crates/psyche-store/migrations/001_foundation.sql @@ -45,8 +45,6 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, - original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), - retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index 20189d5..a2d491d 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -203,10 +203,6 @@ pub struct QuarantineRecord { pub schema_version: Option, /// SHA-256 digest over the complete raw input. pub payload_digest: Sha256Digest, - /// Complete raw-input length before the retained payload was bounded. - pub original_payload_len: usize, - /// SHA-256 digest over exactly the retained payload bytes. - pub retained_payload_digest: Sha256Digest, /// At most 64 KiB retained from the beginning of the raw input. pub bounded_payload: Vec, /// Stable payload-free rejection classification. @@ -228,8 +224,6 @@ impl fmt::Debug for QuarantineRecord { .field("quarantine_id", &self.quarantine_id) .field("schema_version", &self.schema_version) .field("payload_digest", &self.payload_digest) - .field("original_payload_len", &self.original_payload_len) - .field("retained_payload_digest", &self.retained_payload_digest) .field("bounded_payload_bytes", &self.bounded_payload.len()) .field("reason", &self.reason) .field("discovered_at", &self.discovered_at) @@ -259,8 +253,6 @@ struct StoredQuarantineRecord { quarantine_id: String, schema_version: Option, payload_digest: String, - original_payload_len: i64, - retained_payload_digest: String, bounded_payload: Vec, reason: String, discovered_at: String, @@ -304,10 +296,6 @@ impl Store { pub fn quarantine(&mut self, rejected: RejectedDocument) -> Result { validate_rejected(&rejected)?; let reason = QuarantineReasonCode::from(&rejected.reason); - let original_payload_len = rejected.original_payload_len(); - let stored_original_payload_len = - i64::try_from(original_payload_len).map_err(|_| StoreError::InvalidQuarantineRecord)?; - let retained_payload_digest = rejected.retained_payload_digest(); let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -324,8 +312,6 @@ impl Store { validate_record_audit(&transaction, &record)?; if record.schema_version == rejected.schema_version && record.payload_digest == rejected.payload_digest - && record.original_payload_len == original_payload_len - && record.retained_payload_digest == retained_payload_digest && record.bounded_payload == rejected.bounded_payload && record.reason == reason { @@ -347,20 +333,16 @@ impl Store { quarantine_id, schema_version, payload_digest, - original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ", params![ quarantine_id.as_str(), rejected.schema_version.as_deref(), rejected.payload_digest.as_str(), - stored_original_payload_len, - retained_payload_digest.as_str(), rejected.bounded_payload, reason.as_str(), discovered_at, @@ -557,8 +539,7 @@ fn validate_typed_id(id: &QuarantineId) -> Result<(), StoreError> { } fn validate_rejected(rejected: &RejectedDocument) -> Result<(), StoreError> { - if !rejected.is_authentic() - || rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + if rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES || !rejected .schema_version .as_deref() @@ -599,8 +580,6 @@ fn stored_by_id( quarantine_id, schema_version, payload_digest, - original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at, @@ -628,8 +607,6 @@ fn stored_by_digest_and_reason( quarantine_id, schema_version, payload_digest, - original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at, @@ -654,8 +631,6 @@ fn load_all_stored(connection: &Connection) -> Result) -> rusqlite::Result Result { let quarantine_id = QuarantineId::parse(&stored.quarantine_id).map_err(|_| StoreError::DatabaseCorruption)?; - let original_payload_len = - usize::try_from(stored.original_payload_len).map_err(|_| StoreError::DatabaseCorruption)?; if !stored .schema_version .as_deref() .is_none_or(schema_version_is_safe) || stored.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES - || stored.bounded_payload.len() != original_payload_len.min(MAX_BOUNDED_PAYLOAD_BYTES) { return Err(StoreError::DatabaseCorruption); } let payload_digest = Sha256Digest::parse(&stored.payload_digest).map_err(|_| StoreError::DatabaseCorruption)?; - let retained_payload_digest = Sha256Digest::parse(&stored.retained_payload_digest) - .map_err(|_| StoreError::DatabaseCorruption)?; let reason = QuarantineReasonCode::parse(&stored.reason)?; - let reconstructed = - RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); - if reconstructed.retained_payload_digest() != retained_payload_digest { - return Err(StoreError::DatabaseCorruption); - } - if original_payload_len <= MAX_BOUNDED_PAYLOAD_BYTES - && (reconstructed.payload_digest != payload_digest - || reconstructed.schema_version != stored.schema_version) - { - return Err(StoreError::DatabaseCorruption); + if stored.bounded_payload.len() < MAX_BOUNDED_PAYLOAD_BYTES { + let reconstructed = + RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); + if reconstructed.payload_digest != payload_digest + || reconstructed.schema_version != stored.schema_version + { + return Err(StoreError::DatabaseCorruption); + } } let discovered_at = parse_canonical_utc(&stored.discovered_at)?; @@ -759,8 +725,6 @@ fn validate_stored(stored: StoredQuarantineRecord) -> Result QuarantineId { quarantine_id } -fn complete_64_kib_payload() -> Vec { - let empty = r#"{"schema_version":"psyche.future.v1","padding":""}"#; - let padding = "x".repeat(64 * 1024 - empty.len()); - let bytes = - format!(r#"{{"schema_version":"psyche.future.v1","padding":"{padding}"}}"#).into_bytes(); - assert_eq!(bytes.len(), 64 * 1024); - bytes -} - fn resolution(code: QuarantineResolutionCode, resolved_at: OffsetDateTime) -> QuarantineResolution { QuarantineResolution { code, resolved_at } } @@ -145,22 +136,6 @@ fn assert_database_corruption(result: Result) assert!(error.source().is_none()); } -fn assert_quarantine_paths_detect_corruption( - store: &mut Store, - id: &QuarantineId, - discovered_at: OffsetDateTime, -) { - assert_database_corruption(store.quarantine_record(id)); - assert_database_corruption(store.resolve_quarantine( - id, - &resolution( - QuarantineResolutionCode::ConfirmedInvalid, - discovered_at + Duration::seconds(1), - ), - )); - assert_database_corruption(store.prune(discovered_at + Duration::days(1))); -} - #[test] fn quarantine_id_constructor_parser_and_serde_round_trip() { let generated = QuarantineId::new(); @@ -242,257 +217,6 @@ fn quarantine_is_bounded_idempotent_and_reason_sensitive() { assert_ne!(other_id, id); } -#[test] -fn quarantine_rejects_forged_exactly_64_kib_digest() { - let (mut store, _dir, _path) = test_store(); - let mut rejected = - RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::TooLarge); - rejected.payload_digest = fixture_digest('a'); - - let error = store.quarantine(rejected).unwrap_err(); - assert!(matches!(error, StoreError::InvalidQuarantineRecord)); - assert_eq!(error.to_string(), "quarantine record is invalid"); - assert_eq!( - format!("{error:?}"), - "StoreError(quarantine record is invalid)" - ); - assert!(error.source().is_none()); -} - -#[test] -fn quarantine_rejects_exactly_64_kib_public_field_mutations() { - let (mut store, _dir, _path) = test_store(); - let original = - RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::UnknownSchema); - - let mut schema_mutated = original.clone(); - schema_mutated.schema_version = Some("psyche.other.v1".to_owned()); - let mut digest_mutated = original.clone(); - digest_mutated.payload_digest = fixture_digest('b'); - let mut payload_mutated = original.clone(); - payload_mutated.bounded_payload[0] = b'y'; - let mut reason_mutated = original; - reason_mutated.reason = RejectionReason::TooLarge; - - for rejected in [ - schema_mutated, - digest_mutated, - payload_mutated, - reason_mutated, - ] { - let error = store.quarantine(rejected).unwrap_err(); - assert!(matches!(error, StoreError::InvalidQuarantineRecord)); - assert_eq!(error.to_string(), "quarantine record is invalid"); - assert!(error.source().is_none()); - } -} - -#[test] -fn complete_64_kib_quarantine_integrity_tampering_fails_closed() { - for tamper in [ - "bounded_payload", - "retained_payload_digest", - "original_payload_len", - "negative_original_payload_len", - "payload_digest", - "schema_version", - ] { - let (mut store, _dir, path) = test_store(); - let payload = complete_64_kib_payload(); - let id = store - .quarantine(RejectedDocument::from_bytes( - &payload, - RejectionReason::UnknownSchema, - )) - .unwrap(); - let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; - let connection = raw_connection(&path); - match tamper { - "bounded_payload" => { - let mut mutated = payload; - let index = mutated.iter().rposition(|byte| *byte == b'x').unwrap(); - mutated[index] = b'y'; - connection - .execute( - "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", - params![mutated, id.as_str()], - ) - .unwrap(); - } - "retained_payload_digest" => { - connection - .execute( - "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", - params![fixture_digest('d').as_str(), id.as_str()], - ) - .unwrap(); - } - "original_payload_len" => { - connection - .execute( - "UPDATE quarantine_records SET original_payload_len = ?1 WHERE quarantine_id = ?2", - params![64 * 1024 - 1, id.as_str()], - ) - .unwrap(); - } - "negative_original_payload_len" => { - connection - .execute_batch("PRAGMA ignore_check_constraints = ON;") - .unwrap(); - connection - .execute( - "UPDATE quarantine_records SET original_payload_len = -1 WHERE quarantine_id = ?1", - [id.as_str()], - ) - .unwrap(); - } - "payload_digest" => { - connection - .execute( - "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", - params![fixture_digest('e').as_str(), id.as_str()], - ) - .unwrap(); - } - "schema_version" => { - connection - .execute( - "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", - [id.as_str()], - ) - .unwrap(); - } - _ => unreachable!(), - } - drop(connection); - - assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); - } -} - -#[test] -fn short_quarantine_integrity_tampering_fails_closed() { - for tamper in [ - "bounded_payload", - "retained_payload_digest", - "original_payload_len", - "payload_digest", - "schema_version", - ] { - let (mut store, _dir, path) = test_store(); - let payload = br#"{"schema_version":"psyche.future.v1"}"#; - let id = store - .quarantine(RejectedDocument::from_bytes( - payload, - RejectionReason::UnknownSchema, - )) - .unwrap(); - let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; - let connection = raw_connection(&path); - match tamper { - "bounded_payload" => { - connection - .execute( - "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", - params![b"corrupted payload", id.as_str()], - ) - .unwrap(); - } - "retained_payload_digest" => { - connection - .execute( - "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", - params![fixture_digest('d').as_str(), id.as_str()], - ) - .unwrap(); - } - "original_payload_len" => { - connection - .execute( - "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", - [id.as_str()], - ) - .unwrap(); - } - "payload_digest" => { - connection - .execute( - "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", - params![fixture_digest('e').as_str(), id.as_str()], - ) - .unwrap(); - } - "schema_version" => { - connection - .execute( - "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", - [id.as_str()], - ) - .unwrap(); - } - _ => unreachable!(), - } - drop(connection); - - assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); - } -} - -#[test] -fn oversized_quarantine_integrity_metadata_round_trips_after_reopen() { - let (mut store, _dir, path) = test_store(); - let mut payload = vec![b'x'; 64 * 1024]; - payload.extend_from_slice(b"tail-not-retained"); - let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); - let expected_payload_digest = rejected.payload_digest.clone(); - let expected_retained_digest = rejected.retained_payload_digest(); - let id = store.quarantine(rejected.clone()).unwrap(); - drop(store); - - let mut reopened = Store::open(&path).unwrap(); - let record = reopened.quarantine_record(&id).unwrap().unwrap(); - assert_eq!(record.original_payload_len, payload.len()); - assert_eq!(record.retained_payload_digest, expected_retained_digest); - assert_eq!(record.payload_digest, expected_payload_digest); - assert_eq!(record.bounded_payload, payload[..64 * 1024]); - assert_eq!(reopened.quarantine(rejected).unwrap(), id); -} - -#[test] -fn dedupe_equality_includes_integrity_metadata() { - let (mut store, _dir, path) = test_store(); - let payload = vec![b'x'; 64 * 1024 + 17]; - let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); - let id = store.quarantine(rejected.clone()).unwrap(); - raw_connection(&path) - .execute( - "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", - [id.as_str()], - ) - .unwrap(); - - assert!(matches!( - store.quarantine(rejected), - Err(StoreError::QuarantineConflict { .. }) - )); -} - -#[test] -fn quarantine_replay_validates_persisted_integrity_metadata() { - let (mut store, _dir, path) = test_store(); - let payload = vec![b'x'; 64 * 1024 + 1]; - let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); - let id = store.quarantine(rejected.clone()).unwrap(); - raw_connection(&path) - .execute( - "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", - params![fixture_digest('f').as_str(), id.as_str()], - ) - .unwrap(); - - assert_database_corruption(store.quarantine(rejected)); -} - #[test] fn complete_persisted_quarantine_rejects_valid_format_payload_tampering() { let (mut store, _dir, path) = test_store(); @@ -535,6 +259,38 @@ fn complete_persisted_quarantine_rejects_valid_format_schema_tampering() { assert_database_corruption(store.quarantine_record(&id)); } +#[test] +fn fully_bounded_persisted_quarantine_retains_shape_only_validation() { + for raw_len in [64 * 1024, 64 * 1024 + 1] { + let (mut store, _dir, path) = test_store(); + let id = store + .quarantine(RejectedDocument::from_bytes( + &vec![b'x'; raw_len], + RejectionReason::UnknownSchema, + )) + .unwrap(); + raw_connection(&path) + .execute( + " + UPDATE quarantine_records + SET payload_digest = ?1, schema_version = ?2 + WHERE quarantine_id = ?3 + ", + params![ + fixture_digest('e').as_str(), + "psyche.future.v1", + id.as_str() + ], + ) + .unwrap(); + + let record = store.quarantine_record(&id).unwrap().unwrap(); + assert_eq!(record.bounded_payload.len(), 64 * 1024); + assert_eq!(record.payload_digest, fixture_digest('e')); + assert_eq!(record.schema_version.as_deref(), Some("psyche.future.v1")); + } +} + #[test] fn quarantine_resolution_is_durable_and_idempotent() { let (mut store, _dir, path) = test_store(); @@ -888,8 +644,6 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { let (mut store, _dir, path) = test_store(); let id = quarantined_fixture(&mut store); let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; - let empty_payload = - RejectedDocument::from_bytes(b"", RejectionReason::UnknownSchema).payload_digest; store .resolve_quarantine( &id, @@ -903,12 +657,12 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { .execute( " INSERT INTO quarantine_records ( - quarantine_id, schema_version, payload_digest, original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at - ) VALUES ('bad-id', NULL, ?1, 0, ?1, X'', 'unknown_schema', ?2) + quarantine_id, schema_version, payload_digest, bounded_payload, + reason, discovered_at + ) VALUES ('bad-id', NULL, ?1, X'', 'unknown_schema', ?2) ", params![ - empty_payload.as_str(), + fixture_digest('b').as_str(), discovered_at.format(&Rfc3339).unwrap() ], ) diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index a9e24ed..aba1ff2 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -146,18 +146,6 @@ pub(super) fn table_exists(path: &Path, name: &str) -> bool { .unwrap() } -pub(super) fn table_columns(path: &Path, name: &str) -> Vec { - let connection = Connection::open(path).unwrap(); - let mut statement = connection - .prepare(&format!("PRAGMA table_info({name})")) - .unwrap(); - statement - .query_map([], |row| row.get(1)) - .unwrap() - .collect::>>() - .unwrap() -} - pub(super) fn scalar_text(path: &Path, sql: &str) -> String { let connection = Connection::open(path).unwrap(); connection.query_row(sql, [], |row| row.get(0)).unwrap() @@ -218,8 +206,6 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, - original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), - retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, From dcb8d4a95df0945e3120baf8dba3b217baf6195e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:03:43 -0500 Subject: [PATCH 36/66] Revert "fix(store): preserve frozen quarantine schema" This reverts commit f846ff0f64360ce0657cac2c2cf0a2bbee7e18ed. --- crates/psyche-core/src/contracts/mod.rs | 132 +++++++- crates/psyche-core/tests/decode.rs | 11 + .../migrations/001_foundation.sql | 2 + crates/psyche-store/src/quarantine.rs | 68 +++- crates/psyche-store/tests/migrations.rs | 50 ++- crates/psyche-store/tests/retention.rs | 318 ++++++++++++++++-- crates/psyche-store/tests/support/mod.rs | 14 + 7 files changed, 534 insertions(+), 61 deletions(-) diff --git a/crates/psyche-core/src/contracts/mod.rs b/crates/psyche-core/src/contracts/mod.rs index 0adb979..d390887 100644 --- a/crates/psyche-core/src/contracts/mod.rs +++ b/crates/psyche-core/src/contracts/mod.rs @@ -642,23 +642,37 @@ pub struct RejectedDocument { pub bounded_payload: Vec, /// Payload-light rejection classification. pub reason: RejectionReason, + attestation: RejectedDocumentAttestation, +} + +#[derive(Clone, PartialEq, Eq)] +struct RejectedDocumentAttestation { + original_len: usize, + material_digest: Sha256Digest, } impl RejectedDocument { /// Builds quarantine input without requiring valid UTF-8 or valid JSON. pub fn from_bytes(bytes: &[u8], reason: RejectionReason) -> Self { - let schema_version = if bytes.len() <= MAX_DOCUMENT_BYTES { - strict_json(bytes) - .ok() - .and_then(|value| retained_schema_version(&value)) - } else { - None + let schema_version = retained_schema_version_from_bytes(bytes); + let payload_digest = Sha256Digest::from_raw_bytes(bytes); + let bounded_payload = bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(); + let attestation = RejectedDocumentAttestation { + original_len: bytes.len(), + material_digest: rejected_document_attestation( + bytes.len(), + &schema_version, + &payload_digest, + &bounded_payload, + &reason, + ), }; Self { schema_version, - payload_digest: Sha256Digest::from_raw_bytes(bytes), - bounded_payload: bytes[..bytes.len().min(MAX_REJECTED_PAYLOAD_BYTES)].to_vec(), + payload_digest, + bounded_payload, reason, + attestation, } } @@ -693,6 +707,40 @@ impl RejectedDocument { }; Self::from_bytes(bytes, reason) } + + /// Returns the complete raw-input length captured by the constructor. + pub fn original_payload_len(&self) -> usize { + self.attestation.original_len + } + + /// Returns the SHA-256 digest of the retained payload bytes. + pub fn retained_payload_digest(&self) -> Sha256Digest { + Sha256Digest::from_raw_bytes(&self.bounded_payload) + } + + /// Returns whether the public fields still match constructor-attested input. + pub fn is_authentic(&self) -> bool { + let expected_bounded_len = self + .attestation + .original_len + .min(MAX_REJECTED_PAYLOAD_BYTES); + if self.bounded_payload.len() != expected_bounded_len { + return false; + } + if self.attestation.original_len <= MAX_REJECTED_PAYLOAD_BYTES + && (Sha256Digest::from_raw_bytes(&self.bounded_payload) != self.payload_digest + || retained_schema_version_from_bytes(&self.bounded_payload) != self.schema_version) + { + return false; + } + rejected_document_attestation( + self.attestation.original_len, + &self.schema_version, + &self.payload_digest, + &self.bounded_payload, + &self.reason, + ) == self.attestation.material_digest + } } impl fmt::Debug for RejectedDocument { @@ -705,6 +753,74 @@ impl fmt::Debug for RejectedDocument { } } +fn retained_schema_version_from_bytes(bytes: &[u8]) -> Option { + if bytes.len() > MAX_DOCUMENT_BYTES { + return None; + } + strict_json(bytes) + .ok() + .and_then(|value| retained_schema_version(&value)) +} + +fn rejected_document_attestation( + original_len: usize, + schema_version: &Option, + payload_digest: &Sha256Digest, + bounded_payload: &[u8], + reason: &RejectionReason, +) -> Sha256Digest { + let mut material = b"psyche.rejected-document.attestation.v1".to_vec(); + append_attestation_frame(&mut material, b"original_len", &original_len.to_be_bytes()); + match schema_version { + Some(schema_version) => { + append_attestation_frame(&mut material, b"schema_present", b"true"); + append_attestation_frame(&mut material, b"schema_version", schema_version.as_bytes()); + } + None => append_attestation_frame(&mut material, b"schema_present", b"false"), + } + append_attestation_frame( + &mut material, + b"payload_digest", + payload_digest.as_str().as_bytes(), + ); + append_attestation_frame(&mut material, b"bounded_payload", bounded_payload); + append_rejection_reason_attestation(&mut material, reason); + Sha256Digest::from_raw_bytes(&material) +} + +fn append_rejection_reason_attestation(material: &mut Vec, reason: &RejectionReason) { + match reason { + RejectionReason::TooLarge => { + append_attestation_frame(material, b"reason", b"too_large"); + } + RejectionReason::UnknownSchema => { + append_attestation_frame(material, b"reason", b"unknown_schema"); + } + RejectionReason::UnsupportedMajor { found, supported } => { + append_attestation_frame(material, b"reason", b"unsupported_major"); + append_attestation_frame(material, b"found", &found.to_be_bytes()); + append_attestation_frame(material, b"supported", &supported.to_be_bytes()); + } + RejectionReason::UnknownEnumValue { schema, field } => { + append_attestation_frame(material, b"reason", b"unknown_enum_value"); + append_attestation_frame(material, b"schema", schema.name().as_bytes()); + append_attestation_frame(material, b"field", field.as_bytes()); + } + RejectionReason::InvalidShape { schema, field } => { + append_attestation_frame(material, b"reason", b"invalid_shape"); + append_attestation_frame(material, b"schema", schema.name().as_bytes()); + append_attestation_frame(material, b"field", field.as_bytes()); + } + } +} + +fn append_attestation_frame(material: &mut Vec, label: &[u8], value: &[u8]) { + material.extend_from_slice(&label.len().to_be_bytes()); + material.extend_from_slice(label); + material.extend_from_slice(&value.len().to_be_bytes()); + material.extend_from_slice(value); +} + /// Every canonical document accepted by this build. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] diff --git a/crates/psyche-core/tests/decode.rs b/crates/psyche-core/tests/decode.rs index 0648100..4543f54 100644 --- a/crates/psyche-core/tests/decode.rs +++ b/crates/psyche-core/tests/decode.rs @@ -306,6 +306,9 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { small.payload_digest.as_str(), "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); + assert_eq!(small.original_payload_len(), 3); + assert_eq!(small.retained_payload_digest(), small.payload_digest); + assert!(small.is_authentic()); let mut left = vec![b'a'; 64 * 1024 + 1]; let mut right = left.clone(); @@ -331,6 +334,14 @@ fn rejected_document_hashes_full_raw_bytes_and_bounds_retained_payload() { assert_eq!(right.bounded_payload.len(), 64 * 1024); assert_eq!(left.bounded_payload, right.bounded_payload); assert_ne!(left.payload_digest, right.payload_digest); + assert_eq!(left.original_payload_len(), 64 * 1024 + 1); + assert_eq!(right.original_payload_len(), 64 * 1024 + 1); + assert_eq!( + left.retained_payload_digest(), + right.retained_payload_digest() + ); + assert!(left.is_authentic()); + assert!(right.is_authentic()); } #[test] diff --git a/crates/psyche-store/migrations/001_foundation.sql b/crates/psyche-store/migrations/001_foundation.sql index b500bc1..5593060 100644 --- a/crates/psyche-store/migrations/001_foundation.sql +++ b/crates/psyche-store/migrations/001_foundation.sql @@ -45,6 +45,8 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, + original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), + retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index a2d491d..20189d5 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -203,6 +203,10 @@ pub struct QuarantineRecord { pub schema_version: Option, /// SHA-256 digest over the complete raw input. pub payload_digest: Sha256Digest, + /// Complete raw-input length before the retained payload was bounded. + pub original_payload_len: usize, + /// SHA-256 digest over exactly the retained payload bytes. + pub retained_payload_digest: Sha256Digest, /// At most 64 KiB retained from the beginning of the raw input. pub bounded_payload: Vec, /// Stable payload-free rejection classification. @@ -224,6 +228,8 @@ impl fmt::Debug for QuarantineRecord { .field("quarantine_id", &self.quarantine_id) .field("schema_version", &self.schema_version) .field("payload_digest", &self.payload_digest) + .field("original_payload_len", &self.original_payload_len) + .field("retained_payload_digest", &self.retained_payload_digest) .field("bounded_payload_bytes", &self.bounded_payload.len()) .field("reason", &self.reason) .field("discovered_at", &self.discovered_at) @@ -253,6 +259,8 @@ struct StoredQuarantineRecord { quarantine_id: String, schema_version: Option, payload_digest: String, + original_payload_len: i64, + retained_payload_digest: String, bounded_payload: Vec, reason: String, discovered_at: String, @@ -296,6 +304,10 @@ impl Store { pub fn quarantine(&mut self, rejected: RejectedDocument) -> Result { validate_rejected(&rejected)?; let reason = QuarantineReasonCode::from(&rejected.reason); + let original_payload_len = rejected.original_payload_len(); + let stored_original_payload_len = + i64::try_from(original_payload_len).map_err(|_| StoreError::InvalidQuarantineRecord)?; + let retained_payload_digest = rejected.retained_payload_digest(); let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -312,6 +324,8 @@ impl Store { validate_record_audit(&transaction, &record)?; if record.schema_version == rejected.schema_version && record.payload_digest == rejected.payload_digest + && record.original_payload_len == original_payload_len + && record.retained_payload_digest == retained_payload_digest && record.bounded_payload == rejected.bounded_payload && record.reason == reason { @@ -333,16 +347,20 @@ impl Store { quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ", params![ quarantine_id.as_str(), rejected.schema_version.as_deref(), rejected.payload_digest.as_str(), + stored_original_payload_len, + retained_payload_digest.as_str(), rejected.bounded_payload, reason.as_str(), discovered_at, @@ -539,7 +557,8 @@ fn validate_typed_id(id: &QuarantineId) -> Result<(), StoreError> { } fn validate_rejected(rejected: &RejectedDocument) -> Result<(), StoreError> { - if rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + if !rejected.is_authentic() + || rejected.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES || !rejected .schema_version .as_deref() @@ -580,6 +599,8 @@ fn stored_by_id( quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at, @@ -607,6 +628,8 @@ fn stored_by_digest_and_reason( quarantine_id, schema_version, payload_digest, + original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at, @@ -631,6 +654,8 @@ fn load_all_stored(connection: &Connection) -> Result) -> rusqlite::Result Result { let quarantine_id = QuarantineId::parse(&stored.quarantine_id).map_err(|_| StoreError::DatabaseCorruption)?; + let original_payload_len = + usize::try_from(stored.original_payload_len).map_err(|_| StoreError::DatabaseCorruption)?; if !stored .schema_version .as_deref() .is_none_or(schema_version_is_safe) || stored.bounded_payload.len() > MAX_BOUNDED_PAYLOAD_BYTES + || stored.bounded_payload.len() != original_payload_len.min(MAX_BOUNDED_PAYLOAD_BYTES) { return Err(StoreError::DatabaseCorruption); } let payload_digest = Sha256Digest::parse(&stored.payload_digest).map_err(|_| StoreError::DatabaseCorruption)?; + let retained_payload_digest = Sha256Digest::parse(&stored.retained_payload_digest) + .map_err(|_| StoreError::DatabaseCorruption)?; let reason = QuarantineReasonCode::parse(&stored.reason)?; - if stored.bounded_payload.len() < MAX_BOUNDED_PAYLOAD_BYTES { - let reconstructed = - RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); - if reconstructed.payload_digest != payload_digest - || reconstructed.schema_version != stored.schema_version - { - return Err(StoreError::DatabaseCorruption); - } + let reconstructed = + RejectedDocument::from_bytes(&stored.bounded_payload, reason.rejection_reason()); + if reconstructed.retained_payload_digest() != retained_payload_digest { + return Err(StoreError::DatabaseCorruption); + } + if original_payload_len <= MAX_BOUNDED_PAYLOAD_BYTES + && (reconstructed.payload_digest != payload_digest + || reconstructed.schema_version != stored.schema_version) + { + return Err(StoreError::DatabaseCorruption); } let discovered_at = parse_canonical_utc(&stored.discovered_at)?; @@ -725,6 +759,8 @@ fn validate_stored(stored: StoredQuarantineRecord) -> Result QuarantineId { quarantine_id } +fn complete_64_kib_payload() -> Vec { + let empty = r#"{"schema_version":"psyche.future.v1","padding":""}"#; + let padding = "x".repeat(64 * 1024 - empty.len()); + let bytes = + format!(r#"{{"schema_version":"psyche.future.v1","padding":"{padding}"}}"#).into_bytes(); + assert_eq!(bytes.len(), 64 * 1024); + bytes +} + fn resolution(code: QuarantineResolutionCode, resolved_at: OffsetDateTime) -> QuarantineResolution { QuarantineResolution { code, resolved_at } } @@ -136,6 +145,22 @@ fn assert_database_corruption(result: Result) assert!(error.source().is_none()); } +fn assert_quarantine_paths_detect_corruption( + store: &mut Store, + id: &QuarantineId, + discovered_at: OffsetDateTime, +) { + assert_database_corruption(store.quarantine_record(id)); + assert_database_corruption(store.resolve_quarantine( + id, + &resolution( + QuarantineResolutionCode::ConfirmedInvalid, + discovered_at + Duration::seconds(1), + ), + )); + assert_database_corruption(store.prune(discovered_at + Duration::days(1))); +} + #[test] fn quarantine_id_constructor_parser_and_serde_round_trip() { let generated = QuarantineId::new(); @@ -217,6 +242,257 @@ fn quarantine_is_bounded_idempotent_and_reason_sensitive() { assert_ne!(other_id, id); } +#[test] +fn quarantine_rejects_forged_exactly_64_kib_digest() { + let (mut store, _dir, _path) = test_store(); + let mut rejected = + RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::TooLarge); + rejected.payload_digest = fixture_digest('a'); + + let error = store.quarantine(rejected).unwrap_err(); + assert!(matches!(error, StoreError::InvalidQuarantineRecord)); + assert_eq!(error.to_string(), "quarantine record is invalid"); + assert_eq!( + format!("{error:?}"), + "StoreError(quarantine record is invalid)" + ); + assert!(error.source().is_none()); +} + +#[test] +fn quarantine_rejects_exactly_64_kib_public_field_mutations() { + let (mut store, _dir, _path) = test_store(); + let original = + RejectedDocument::from_bytes(&vec![b'x'; 64 * 1024], RejectionReason::UnknownSchema); + + let mut schema_mutated = original.clone(); + schema_mutated.schema_version = Some("psyche.other.v1".to_owned()); + let mut digest_mutated = original.clone(); + digest_mutated.payload_digest = fixture_digest('b'); + let mut payload_mutated = original.clone(); + payload_mutated.bounded_payload[0] = b'y'; + let mut reason_mutated = original; + reason_mutated.reason = RejectionReason::TooLarge; + + for rejected in [ + schema_mutated, + digest_mutated, + payload_mutated, + reason_mutated, + ] { + let error = store.quarantine(rejected).unwrap_err(); + assert!(matches!(error, StoreError::InvalidQuarantineRecord)); + assert_eq!(error.to_string(), "quarantine record is invalid"); + assert!(error.source().is_none()); + } +} + +#[test] +fn complete_64_kib_quarantine_integrity_tampering_fails_closed() { + for tamper in [ + "bounded_payload", + "retained_payload_digest", + "original_payload_len", + "negative_original_payload_len", + "payload_digest", + "schema_version", + ] { + let (mut store, _dir, path) = test_store(); + let payload = complete_64_kib_payload(); + let id = store + .quarantine(RejectedDocument::from_bytes( + &payload, + RejectionReason::UnknownSchema, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let connection = raw_connection(&path); + match tamper { + "bounded_payload" => { + let mut mutated = payload; + let index = mutated.iter().rposition(|byte| *byte == b'x').unwrap(); + mutated[index] = b'y'; + connection + .execute( + "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", + params![mutated, id.as_str()], + ) + .unwrap(); + } + "retained_payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('d').as_str(), id.as_str()], + ) + .unwrap(); + } + "original_payload_len" => { + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = ?1 WHERE quarantine_id = ?2", + params![64 * 1024 - 1, id.as_str()], + ) + .unwrap(); + } + "negative_original_payload_len" => { + connection + .execute_batch("PRAGMA ignore_check_constraints = ON;") + .unwrap(); + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = -1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('e').as_str(), id.as_str()], + ) + .unwrap(); + } + "schema_version" => { + connection + .execute( + "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); + } +} + +#[test] +fn short_quarantine_integrity_tampering_fails_closed() { + for tamper in [ + "bounded_payload", + "retained_payload_digest", + "original_payload_len", + "payload_digest", + "schema_version", + ] { + let (mut store, _dir, path) = test_store(); + let payload = br#"{"schema_version":"psyche.future.v1"}"#; + let id = store + .quarantine(RejectedDocument::from_bytes( + payload, + RejectionReason::UnknownSchema, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let connection = raw_connection(&path); + match tamper { + "bounded_payload" => { + connection + .execute( + "UPDATE quarantine_records SET bounded_payload = ?1 WHERE quarantine_id = ?2", + params![b"corrupted payload", id.as_str()], + ) + .unwrap(); + } + "retained_payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('d').as_str(), id.as_str()], + ) + .unwrap(); + } + "original_payload_len" => { + connection + .execute( + "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + "payload_digest" => { + connection + .execute( + "UPDATE quarantine_records SET payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('e').as_str(), id.as_str()], + ) + .unwrap(); + } + "schema_version" => { + connection + .execute( + "UPDATE quarantine_records SET schema_version = 'psyche.other.v1' WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + } + _ => unreachable!(), + } + drop(connection); + + assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); + } +} + +#[test] +fn oversized_quarantine_integrity_metadata_round_trips_after_reopen() { + let (mut store, _dir, path) = test_store(); + let mut payload = vec![b'x'; 64 * 1024]; + payload.extend_from_slice(b"tail-not-retained"); + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let expected_payload_digest = rejected.payload_digest.clone(); + let expected_retained_digest = rejected.retained_payload_digest(); + let id = store.quarantine(rejected.clone()).unwrap(); + drop(store); + + let mut reopened = Store::open(&path).unwrap(); + let record = reopened.quarantine_record(&id).unwrap().unwrap(); + assert_eq!(record.original_payload_len, payload.len()); + assert_eq!(record.retained_payload_digest, expected_retained_digest); + assert_eq!(record.payload_digest, expected_payload_digest); + assert_eq!(record.bounded_payload, payload[..64 * 1024]); + assert_eq!(reopened.quarantine(rejected).unwrap(), id); +} + +#[test] +fn dedupe_equality_includes_integrity_metadata() { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 17]; + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let id = store.quarantine(rejected.clone()).unwrap(); + raw_connection(&path) + .execute( + "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 WHERE quarantine_id = ?1", + [id.as_str()], + ) + .unwrap(); + + assert!(matches!( + store.quarantine(rejected), + Err(StoreError::QuarantineConflict { .. }) + )); +} + +#[test] +fn quarantine_replay_validates_persisted_integrity_metadata() { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 1]; + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let id = store.quarantine(rejected.clone()).unwrap(); + raw_connection(&path) + .execute( + "UPDATE quarantine_records SET retained_payload_digest = ?1 WHERE quarantine_id = ?2", + params![fixture_digest('f').as_str(), id.as_str()], + ) + .unwrap(); + + assert_database_corruption(store.quarantine(rejected)); +} + #[test] fn complete_persisted_quarantine_rejects_valid_format_payload_tampering() { let (mut store, _dir, path) = test_store(); @@ -259,38 +535,6 @@ fn complete_persisted_quarantine_rejects_valid_format_schema_tampering() { assert_database_corruption(store.quarantine_record(&id)); } -#[test] -fn fully_bounded_persisted_quarantine_retains_shape_only_validation() { - for raw_len in [64 * 1024, 64 * 1024 + 1] { - let (mut store, _dir, path) = test_store(); - let id = store - .quarantine(RejectedDocument::from_bytes( - &vec![b'x'; raw_len], - RejectionReason::UnknownSchema, - )) - .unwrap(); - raw_connection(&path) - .execute( - " - UPDATE quarantine_records - SET payload_digest = ?1, schema_version = ?2 - WHERE quarantine_id = ?3 - ", - params![ - fixture_digest('e').as_str(), - "psyche.future.v1", - id.as_str() - ], - ) - .unwrap(); - - let record = store.quarantine_record(&id).unwrap().unwrap(); - assert_eq!(record.bounded_payload.len(), 64 * 1024); - assert_eq!(record.payload_digest, fixture_digest('e')); - assert_eq!(record.schema_version.as_deref(), Some("psyche.future.v1")); - } -} - #[test] fn quarantine_resolution_is_durable_and_idempotent() { let (mut store, _dir, path) = test_store(); @@ -644,6 +888,8 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { let (mut store, _dir, path) = test_store(); let id = quarantined_fixture(&mut store); let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let empty_payload = + RejectedDocument::from_bytes(b"", RejectionReason::UnknownSchema).payload_digest; store .resolve_quarantine( &id, @@ -657,12 +903,12 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { .execute( " INSERT INTO quarantine_records ( - quarantine_id, schema_version, payload_digest, bounded_payload, - reason, discovered_at - ) VALUES ('bad-id', NULL, ?1, X'', 'unknown_schema', ?2) + quarantine_id, schema_version, payload_digest, original_payload_len, + retained_payload_digest, bounded_payload, reason, discovered_at + ) VALUES ('bad-id', NULL, ?1, 0, ?1, X'', 'unknown_schema', ?2) ", params![ - fixture_digest('b').as_str(), + empty_payload.as_str(), discovered_at.format(&Rfc3339).unwrap() ], ) diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index aba1ff2..a9e24ed 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -146,6 +146,18 @@ pub(super) fn table_exists(path: &Path, name: &str) -> bool { .unwrap() } +pub(super) fn table_columns(path: &Path, name: &str) -> Vec { + let connection = Connection::open(path).unwrap(); + let mut statement = connection + .prepare(&format!("PRAGMA table_info({name})")) + .unwrap(); + statement + .query_map([], |row| row.get(1)) + .unwrap() + .collect::>>() + .unwrap() +} + pub(super) fn scalar_text(path: &Path, sql: &str) -> String { let connection = Connection::open(path).unwrap(); connection.query_row(sql, [], |row| row.get(0)).unwrap() @@ -206,6 +218,8 @@ CREATE TABLE quarantine_records ( quarantine_id TEXT PRIMARY KEY, schema_version TEXT, payload_digest TEXT NOT NULL, + original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), + retained_payload_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, From c6afa772afa1ded061ebabd52c7d47c1fc4bbe3f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:03:43 -0500 Subject: [PATCH 37/66] Revert "fix(store): retain approved database path policy" This reverts commit 696b5fa535b318a6e010c6e97beecaf871b90b0a. --- crates/psyche-store/src/connection.rs | 246 ++++++++++++++++++++-- crates/psyche-store/src/lib.rs | 21 +- crates/psyche-store/tests/migrations.rs | 249 ++++++++++++++++++++++- crates/psyche-store/tests/support/mod.rs | 40 +++- 4 files changed, 528 insertions(+), 28 deletions(-) diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 6c98d86..2968871 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -1,6 +1,6 @@ use std::{ - fs::{self, OpenOptions}, - io::ErrorKind, + fs::{self, File, OpenOptions}, + io::{BufReader, ErrorKind, Read}, path::{Path, PathBuf}, thread, time::{Duration, Instant}, @@ -13,19 +13,65 @@ use crate::StoreError; const BUSY_TIMEOUT: Duration = Duration::from_millis(5_000); const CONFIGURATION_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(50); const CONFIGURATION_RETRY_DELAY: Duration = Duration::from_millis(10); +const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0"; +const SQLITE_HEADER_SIZE: usize = 100; +const WAL_HEADER_SIZE: usize = 32; +const WAL_FRAME_HEADER_SIZE: usize = 24; +const WAL_FORMAT_VERSION: u32 = 3_007_000; +const WAL_MAGIC: u32 = 0x377f_0682; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DatabaseFileState { + Existing, + Created, +} -pub(crate) fn open(path: &Path) -> Result<(Connection, PathBuf), StoreError> { +pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { validate_path(path)?; prepare_parent_directory(path)?; - prepare_database_file(path)?; + let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; + Ok((open_path, state)) +} +pub(crate) fn open_read_only(path: &Path) -> Result { + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = Connection::open_with_flags(path, flags)?; + connection.busy_timeout(BUSY_TIMEOUT)?; + Ok(connection) +} + +pub(crate) fn open_read_write(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; - let connection = Connection::open_with_flags(&open_path, flags)?; + let connection = Connection::open_with_flags(path, flags)?; connection.busy_timeout(BUSY_TIMEOUT)?; - Ok((connection, open_path)) + Ok(connection) +} + +pub(crate) fn file_user_version(path: &Path) -> Result, StoreError> { + // A read-only WAL query may update reader marks in `-shm`. Read committed + // page-one frames first so a future schema can be rejected without that. + let [journal_path, wal_path, _] = sqlite_sidecar_paths(path); + if existing_file_len(&journal_path)?.is_some_and(|len| len > 0) { + return Ok(None); + } + + let Some((main_version, page_size)) = main_file_header(path)? else { + return Ok(None); + }; + if page_size == 0 { + return Ok(Some(main_version)); + } + + match wal_file_user_version(&wal_path, page_size)? { + WalFileVersion::Absent => Ok(Some(main_version)), + WalFileVersion::Invalid => Ok(None), + WalFileVersion::Valid(version) => Ok(Some(version.unwrap_or(main_version))), + } } pub(crate) fn enforce_database_permissions(path: &Path) -> Result<(), StoreError> { @@ -149,6 +195,162 @@ fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 3] { }) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WalFileVersion { + Absent, + Invalid, + Valid(Option), +} + +fn existing_file_len(path: &Path) -> Result, StoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + Ok(Some(metadata.len())) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn main_file_header(path: &Path) -> Result, StoreError> { + let mut file = File::open(path).map_err(StoreError::file_operation)?; + let len = file.metadata().map_err(StoreError::file_operation)?.len(); + if len == 0 { + return Ok(Some((0, 0))); + } + if len < SQLITE_HEADER_SIZE as u64 { + return Ok(None); + } + + let mut header = [0_u8; SQLITE_HEADER_SIZE]; + file.read_exact(&mut header) + .map_err(StoreError::file_operation)?; + if &header[..SQLITE_HEADER.len()] != SQLITE_HEADER { + return Ok(None); + } + + let encoded_page_size = u16::from_be_bytes([header[16], header[17]]); + let page_size = if encoded_page_size == 1 { + 65_536 + } else { + u32::from(encoded_page_size) + }; + if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() { + return Ok(None); + } + + Ok(Some((read_u32_be(&header[60..64]), page_size))) +} + +fn wal_file_user_version( + path: &Path, + expected_page_size: u32, +) -> Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(WalFileVersion::Absent); + } + Err(error) => return Err(StoreError::file_operation(error)), + }; + if file.metadata().map_err(StoreError::file_operation)?.len() < WAL_HEADER_SIZE as u64 { + return Ok(WalFileVersion::Absent); + } + + let mut reader = BufReader::new(file); + let mut header = [0_u8; WAL_HEADER_SIZE]; + reader + .read_exact(&mut header) + .map_err(StoreError::file_operation)?; + let magic = read_u32_be(&header[..4]); + let page_size = read_u32_be(&header[8..12]); + if magic & !1 != WAL_MAGIC + || read_u32_be(&header[4..8]) != WAL_FORMAT_VERSION + || page_size != expected_page_size + { + return Ok(WalFileVersion::Invalid); + } + + let checksum_big_endian = magic & 1 == 1; + let mut checksum = [0_u32; 2]; + extend_wal_checksum(&header[..24], checksum_big_endian, &mut checksum); + if checksum != [read_u32_be(&header[24..28]), read_u32_be(&header[28..])] { + return Ok(WalFileVersion::Invalid); + } + + let salt = &header[16..24]; + let mut frame_header = [0_u8; WAL_FRAME_HEADER_SIZE]; + let mut page = vec![0_u8; page_size as usize]; + let mut pending_page_one = None; + let mut committed_page_one = None; + loop { + if !read_exact_frame_part(&mut reader, &mut frame_header)? + || !read_exact_frame_part(&mut reader, &mut page)? + { + break; + } + if read_u32_be(&frame_header[..4]) == 0 || &frame_header[8..16] != salt { + break; + } + + let mut frame_checksum = checksum; + extend_wal_checksum(&frame_header[..8], checksum_big_endian, &mut frame_checksum); + extend_wal_checksum(&page, checksum_big_endian, &mut frame_checksum); + if frame_checksum + != [ + read_u32_be(&frame_header[16..20]), + read_u32_be(&frame_header[20..]), + ] + { + break; + } + checksum = frame_checksum; + + if read_u32_be(&frame_header[..4]) == 1 { + pending_page_one = Some(read_u32_be(&page[60..64])); + } + if read_u32_be(&frame_header[4..8]) != 0 { + if let Some(version) = pending_page_one.take() { + committed_page_one = Some(version); + } + } + } + + Ok(WalFileVersion::Valid(committed_page_one)) +} + +fn read_exact_frame_part(reader: &mut impl Read, buffer: &mut [u8]) -> Result { + match reader.read_exact(buffer) { + Ok(()) => Ok(true), + Err(error) if error.kind() == ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(StoreError::file_operation(error)), + } +} + +fn extend_wal_checksum(bytes: &[u8], big_endian: bool, checksum: &mut [u32; 2]) { + debug_assert_eq!(bytes.len() % 8, 0); + for words in bytes.chunks_exact(8) { + let first = read_checksum_word(&words[..4], big_endian); + checksum[0] = checksum[0].wrapping_add(first).wrapping_add(checksum[1]); + let second = read_checksum_word(&words[4..], big_endian); + checksum[1] = checksum[1].wrapping_add(second).wrapping_add(checksum[0]); + } +} + +fn read_checksum_word(bytes: &[u8], big_endian: bool) -> u32 { + let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]]; + if big_endian { + u32::from_be_bytes(bytes) + } else { + u32::from_le_bytes(bytes) + } +} + +fn read_u32_be(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + fn enforce_existing_sidecar_permissions(path: &Path) -> Result<(), StoreError> { match fs::symlink_metadata(path) { Ok(metadata) => validate_database_metadata(&metadata)?, @@ -213,6 +415,16 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(StoreError::InvalidDatabasePath); } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(StoreError::InvalidDatabasePath); + } + } + Ok(()) } @@ -234,21 +446,24 @@ fn create_parent_directory(parent: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_database_file(path: &Path) -> Result<(), StoreError> { - match fs::symlink_metadata(path) { - Ok(metadata) => validate_database_metadata(&metadata)?, +fn prepare_database_file(path: &Path) -> Result { + let state = match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_database_metadata(&metadata)?; + DatabaseFileState::Existing + } Err(error) if error.kind() == ErrorKind::NotFound => match create_database_file(path) { - Ok(()) => {} - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Ok(()) => DatabaseFileState::Created, + Err(error) if error.kind() == ErrorKind::AlreadyExists => DatabaseFileState::Existing, Err(error) => return Err(StoreError::file_operation(error)), }, Err(error) => return Err(StoreError::file_operation(error)), - } + }; let metadata = fs::symlink_metadata(path).map_err(StoreError::file_operation)?; validate_database_metadata(&metadata)?; - Ok(()) + Ok(state) } fn database_open_path(path: &Path) -> Result { @@ -287,14 +502,15 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open}; + use super::{configure, open_read_write, prepare}; #[test] fn configure_sets_every_required_pragma() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("private").join("psyche.sqlite3"); - let (connection, _) = open(&path).unwrap(); + let (path, _) = prepare(&path).unwrap(); + let connection = open_read_write(&path).unwrap(); configure(&connection).unwrap(); assert_eq!( diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index d2d8211..824a282 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -39,20 +39,31 @@ impl Store { pub fn open(path: &Path) -> Result { let initialization_lock = INITIALIZATION_LOCK.get_or_init(|| Mutex::new(())); let _initialization_guard = initialization_guard(initialization_lock)?; - let (mut connection, database_path) = connection::open(path)?; + let (database_path, file_state) = connection::prepare(path)?; + connection::validate_sidecars(&database_path)?; - let found = - match connection.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) { + if file_state == connection::DatabaseFileState::Existing { + let preflight = connection::open_read_only(&database_path)?; + if let Some(found) = connection::file_user_version(&database_path)? { + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } + } + let found = match preflight + .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + { Ok(found) => found, Err(error) => { connection::validate_sidecars(&database_path)?; return Err(error.into()); } }; - if found > CURRENT_DATABASE_VERSION { - return Err(StoreError::UnsupportedDatabaseVersion { found }); + if found > CURRENT_DATABASE_VERSION { + return Err(StoreError::UnsupportedDatabaseVersion { found }); + } } + let mut connection = connection::open_read_write(&database_path)?; connection::enforce_database_permissions(&database_path)?; connection::validate_sidecars(&database_path)?; connection::configure(&connection)?; diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 299e5b3..f7f6e45 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -168,6 +168,121 @@ fn future_database_version_fails_before_any_migration() { assert_eq!(sqlite_sidecar_state(&path), original_sidecars); } +#[cfg(unix)] +#[test] +fn crash_left_wal_future_version_is_rejected_without_mutating_any_database_file() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + + run_crash_helper(&path, "wal-v99"); + + let sidecars = sqlite_sidecar_paths(&path); + let wal_path = &sidecars[1]; + let shm_path = &sidecars[2]; + assert!(wal_path.exists()); + assert!(shm_path.exists()); + assert_eq!(database_header_user_version(&path), 1); + + for file in [&path, wal_path, shm_path] { + set_mode(file, 0o644); + } + let before = [ + snapshot_file(&path), + snapshot_file(wal_path), + snapshot_file(shm_path), + ]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!( + &error, + StoreError::UnsupportedDatabaseVersion { found: 99, .. } + ), + "unexpected error: {error:?}" + ); + assert_eq!( + error.to_string(), + "unsupported database version 99; maximum supported version is 1" + ); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("WAL", &before[1], &snapshot_file(wal_path)); + assert_snapshot_unchanged("shared memory", &before[2], &snapshot_file(shm_path)); +} + +#[cfg(unix)] +#[test] +fn hot_journal_read_only_failure_does_not_recover_or_open_read_write() { + use std::error::Error; + + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version0); + execute_batch( + &path, + " + CREATE TABLE hot_journal_seed ( + id INTEGER PRIMARY KEY, + payload BLOB NOT NULL + ) STRICT; + WITH RECURSIVE counter(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM counter WHERE value < 256 + ) + INSERT INTO hot_journal_seed (id, payload) + SELECT value, zeroblob(4096) FROM counter; + ", + ); + + run_crash_helper(&path, "hot-journal"); + + let sidecars = sqlite_sidecar_paths(&path); + let journal_path = &sidecars[0]; + let journal_contents = std::fs::read(journal_path).unwrap(); + assert!(journal_contents.len() > 512); + assert!(journal_contents[..8].iter().any(|byte| *byte != 0)); + set_mode(&path, 0o644); + set_mode(journal_path, 0o644); + let before = [snapshot_file(&path), snapshot_file(journal_path)]; + + let error = Store::open(&path).unwrap_err(); + + assert!( + matches!(&error, StoreError::DatabaseOperation), + "unexpected error: {error:?}" + ); + assert_eq!(error.to_string(), "store database operation failed"); + assert_eq!( + format!("{error:?}"), + "StoreError(store database operation failed)" + ); + assert!(error.source().is_none()); + assert_snapshot_unchanged("database", &before[0], &snapshot_file(&path)); + assert_snapshot_unchanged("rollback journal", &before[1], &snapshot_file(journal_path)); +} + +#[test] +fn partially_applied_v1_transaction_rolls_back_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::PartiallyAppliedV1); + + assert_eq!(user_version(&path), 0); + assert!(!table_exists(&path, "schema_migrations")); + assert!(!table_exists(&path, "canonical_records")); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), 1); + drop(store); + + assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); + assert_eq!(schema_migrations(&path).len(), 1); + + let reopened = Store::open(&path).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), 1); + drop(reopened); + assert_eq!(schema_migrations(&path).len(), 1); +} + #[test] fn production_migration_failure_rolls_back_and_recovers() { let dir = tempfile::tempdir().unwrap(); @@ -236,6 +351,8 @@ fn concurrent_first_open_applies_migration_once() { const THREADS: usize = 8; let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + set_mode(dir.path(), 0o700); for round in 0..ROUNDS { let path = Arc::new(dir.path().join(format!("psyche-{round}.sqlite3"))); let barrier = Arc::new(Barrier::new(THREADS)); @@ -375,24 +492,43 @@ fn future_database_sidecar_permissions_are_unchanged() { #[cfg(unix)] #[test] -fn existing_shared_parent_permissions_are_preserved() { +fn existing_shared_parent_is_rejected_without_changes() { let dir = tempfile::tempdir().unwrap(); let parent = dir.path().join("existing"); let path = parent.join("psyche.sqlite3"); std::fs::create_dir(&parent).unwrap(); - std::fs::write(&path, []).unwrap(); + std::fs::write(&path, b"not-a-database").unwrap(); set_mode(&parent, 0o755); set_mode(&path, 0o755); - drop(Store::open(&path).unwrap()); + assert_invalid_database_path(&path); assert_eq!(mode(&parent), 0o755); + assert_eq!(mode(&path), 0o755); + assert_eq!(std::fs::read(&path).unwrap(), b"not-a-database"); +} + +#[cfg(unix)] +#[test] +fn existing_private_parent_is_accepted_without_changing_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("existing"); + let path = parent.join("psyche.sqlite3"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(&path, []).unwrap(); + set_mode(&parent, 0o700); + set_mode(&path, 0o600); + + drop(Store::open(&path).unwrap()); + + assert_eq!(mode(&parent), 0o700); assert_private_file(&path); + assert_eq!(user_version(&path), CURRENT_DATABASE_VERSION); } #[cfg(unix)] #[test] -fn relative_filename_preserves_current_directory_permissions() { +fn relative_filename_rejects_shared_current_directory_without_changes() { use std::process::Command; let dir = tempfile::tempdir().unwrap(); @@ -411,7 +547,7 @@ fn relative_filename_preserves_current_directory_permissions() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(mode(dir.path()), 0o755); - assert_private_file(&dir.path().join("psyche.sqlite3")); + assert!(!dir.path().join("psyche.sqlite3").exists()); } #[cfg(unix)] @@ -421,7 +557,55 @@ fn relative_filename_open_helper() { return; } - drop(Store::open(Path::new("psyche.sqlite3")).unwrap()); + assert_invalid_database_path(Path::new("psyche.sqlite3")); +} + +#[cfg(unix)] +#[test] +fn crash_left_database_helper() { + let Some(helper) = std::env::var_os("PSYCHE_STORE_CRASH_HELPER") else { + return; + }; + let path = PathBuf::from( + std::env::var_os("PSYCHE_STORE_CRASH_HELPER_PATH") + .unwrap_or_else(|| panic!("crash helper database path is missing")), + ); + let connection = Connection::open(&path).unwrap(); + + match helper.to_str() { + Some("wal-v99") => connection + .execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + PRAGMA synchronous = FULL; + BEGIN IMMEDIATE; + CREATE TABLE wal_future_marker ( + value TEXT NOT NULL + ) STRICT; + INSERT INTO wal_future_marker (value) VALUES ('future-in-wal'); + PRAGMA user_version = 99; + COMMIT; + ", + ) + .unwrap(), + Some("hot-journal") => connection + .execute_batch( + " + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA cache_size = 1; + PRAGMA cache_spill = ON; + BEGIN IMMEDIATE; + UPDATE hot_journal_seed + SET payload = randomblob(4096); + ", + ) + .unwrap(), + _ => panic!("unknown crash helper mode"), + } + + std::process::exit(0); } #[cfg(unix)] @@ -447,6 +631,7 @@ fn symlink_database_is_rejected_without_mutating_its_target() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); + set_mode(dir.path(), 0o700); let target = dir.path().join("target.sqlite3"); let path = dir.path().join("linked.sqlite3"); std::fs::write(&target, []).unwrap(); @@ -462,6 +647,58 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } +#[cfg(unix)] +fn run_crash_helper(path: &Path, helper: &str) { + use std::process::Command; + + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "crash_left_database_helper", "--nocapture"]) + .env("PSYCHE_STORE_CRASH_HELPER", helper) + .env("PSYCHE_STORE_CRASH_HELPER_PATH", path) + .output() + .unwrap(); + assert!( + output.status.success(), + "crash helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[derive(Debug, Eq, PartialEq)] +struct FileSnapshot { + contents: Vec, + len: u64, + modified: std::time::SystemTime, + mode: u32, +} + +#[cfg(unix)] +fn snapshot_file(path: &Path) -> FileSnapshot { + let contents = std::fs::read(path).unwrap(); + let metadata = std::fs::metadata(path).unwrap(); + FileSnapshot { + contents, + len: metadata.len(), + modified: metadata.modified().unwrap(), + mode: mode(path), + } +} + +#[cfg(unix)] +fn assert_snapshot_unchanged(label: &str, before: &FileSnapshot, after: &FileSnapshot) { + assert_eq!(after.len, before.len, "{label} length changed"); + assert_eq!(after.modified, before.modified, "{label} mtime changed"); + assert_eq!(after.mode, before.mode, "{label} mode changed"); + assert_eq!(after.contents, before.contents, "{label} contents changed"); +} + +#[cfg(unix)] +fn database_header_user_version(path: &Path) -> u32 { + let contents = std::fs::read(path).unwrap(); + u32::from_be_bytes(contents[60..64].try_into().unwrap()) +} + fn sqlite_sidecar_state(path: &Path) -> Vec<(String, Option>)> { sqlite_sidecar_paths(path) .into_iter() diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index a9e24ed..15820f2 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::Connection; +use rusqlite::{Connection, TransactionBehavior}; pub(super) const FOUNDATION_TABLES: [&str; 6] = [ "audit_events", @@ -15,18 +15,27 @@ pub(super) enum Fixture { Version0, Version1, Version99, + PartiallyAppliedV1, MigrationConflictV1, } pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let name = match fixture { Fixture::Version0 => "version-v0.sqlite3", Fixture::Version1 => "version-v1.sqlite3", Fixture::Version99 => "future-v99.sqlite3", + Fixture::PartiallyAppliedV1 => "partial-v1.sqlite3", Fixture::MigrationConflictV1 => "migration-conflict-v1.sqlite3", }; let path = root.join(name); - let connection = Connection::open(&path).unwrap(); + let mut connection = Connection::open(&path).unwrap(); match fixture { Fixture::Version0 => connection @@ -64,6 +73,33 @@ pub(super) fn fixture_db(root: &Path, fixture: Fixture) -> PathBuf { ", ) .unwrap(), + Fixture::PartiallyAppliedV1 => { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Exclusive) + .unwrap(); + transaction + .execute_batch( + " + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) STRICT; + CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) + ) STRICT; + PRAGMA user_version = 1; + ", + ) + .unwrap(); + drop(transaction); + } Fixture::MigrationConflictV1 => connection .execute_batch( " From c0992ba0a474adddc34f3d9138df9fe8e33ac71f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:05:54 -0500 Subject: [PATCH 38/66] fix(store): bind immutable quarantine metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../migrations/001_foundation.sql | 1 + crates/psyche-store/src/quarantine.rs | 79 +++++++++++++++++-- crates/psyche-store/tests/migrations.rs | 7 +- crates/psyche-store/tests/retention.rs | 61 ++++++++++++-- crates/psyche-store/tests/support/mod.rs | 1 + 5 files changed, 134 insertions(+), 15 deletions(-) diff --git a/crates/psyche-store/migrations/001_foundation.sql b/crates/psyche-store/migrations/001_foundation.sql index 5593060..cad8ec3 100644 --- a/crates/psyche-store/migrations/001_foundation.sql +++ b/crates/psyche-store/migrations/001_foundation.sql @@ -47,6 +47,7 @@ CREATE TABLE quarantine_records ( payload_digest TEXT NOT NULL, original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), retained_payload_digest TEXT NOT NULL, + integrity_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index 20189d5..48e6ba9 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -261,6 +261,7 @@ struct StoredQuarantineRecord { payload_digest: String, original_payload_len: i64, retained_payload_digest: String, + integrity_digest: String, bounded_payload: Vec, reason: String, discovered_at: String, @@ -287,6 +288,18 @@ struct ResolutionDigestInput<'a> { resolved_at: time::OffsetDateTime, } +#[derive(serde::Serialize)] +struct QuarantineIntegrityInput<'a> { + domain: &'static str, + quarantine_id: &'a QuarantineId, + schema_version: &'a Option, + payload_digest: &'a Sha256Digest, + original_payload_len: String, + retained_payload_digest: &'a Sha256Digest, + reason: QuarantineReasonCode, + discovered_at: &'a str, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct QuarantineResolvedAuditDetails { @@ -341,6 +354,16 @@ impl Store { let discovered_at = time::OffsetDateTime::now_utc() .format(&Rfc3339) .map_err(|_| StoreError::InvalidQuarantineRecord)?; + let integrity_digest = compute_quarantine_integrity_digest( + &quarantine_id, + &rejected.schema_version, + &rejected.payload_digest, + original_payload_len, + &retained_payload_digest, + reason, + &discovered_at, + ) + .map_err(StoreError::from)?; transaction.execute( " INSERT INTO quarantine_records ( @@ -349,11 +372,12 @@ impl Store { payload_digest, original_payload_len, retained_payload_digest, + integrity_digest, bounded_payload, reason, discovered_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ", params![ quarantine_id.as_str(), @@ -361,6 +385,7 @@ impl Store { rejected.payload_digest.as_str(), stored_original_payload_len, retained_payload_digest.as_str(), + integrity_digest.as_str(), rejected.bounded_payload, reason.as_str(), discovered_at, @@ -601,6 +626,7 @@ fn stored_by_id( payload_digest, original_payload_len, retained_payload_digest, + integrity_digest, bounded_payload, reason, discovered_at, @@ -630,6 +656,7 @@ fn stored_by_digest_and_reason( payload_digest, original_payload_len, retained_payload_digest, + integrity_digest, bounded_payload, reason, discovered_at, @@ -656,6 +683,7 @@ fn load_all_stored(connection: &Connection) -> Result) -> rusqlite::Result Result Result Result, + payload_digest: &Sha256Digest, + original_payload_len: usize, + retained_payload_digest: &Sha256Digest, + reason: QuarantineReasonCode, + discovered_at: &str, +) -> Result { + digest(&QuarantineIntegrityInput { + domain: "psyche.quarantine-integrity.v1", + quarantine_id, + schema_version, + payload_digest, + original_payload_len: original_payload_len.to_string(), + retained_payload_digest, + reason, + discovered_at, + }) +} + fn compute_resolution_digest( quarantine_id: &QuarantineId, payload_digest: &Sha256Digest, diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index f7f6e45..53ce1bf 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -73,6 +73,8 @@ fn existing_v1_fixture_opens_without_reapplying_migration() { #[test] fn v1_quarantine_schema_contains_durable_integrity_metadata() { let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + set_mode(dir.path(), 0o700); let fresh_path = dir.path().join("fresh.sqlite3"); let store = Store::open(&fresh_path).unwrap(); drop(store); @@ -83,6 +85,7 @@ fn v1_quarantine_schema_contains_durable_integrity_metadata() { "payload_digest", "original_payload_len", "retained_payload_digest", + "integrity_digest", "bounded_payload", "reason", "discovered_at", @@ -100,13 +103,15 @@ fn v1_quarantine_schema_contains_durable_integrity_metadata() { " INSERT INTO quarantine_records ( quarantine_id, schema_version, payload_digest, original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at + retained_payload_digest, integrity_digest, bounded_payload, reason, + discovered_at ) VALUES ( 'qua_01J00000000000000000000000', NULL, 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', -1, 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', X'', 'unknown_schema', '2026-08-08T00:00:00Z' diff --git a/crates/psyche-store/tests/retention.rs b/crates/psyche-store/tests/retention.rs index da657c2..2a77cde 100644 --- a/crates/psyche-store/tests/retention.rs +++ b/crates/psyche-store/tests/retention.rs @@ -459,7 +459,57 @@ fn oversized_quarantine_integrity_metadata_round_trips_after_reopen() { } #[test] -fn dedupe_equality_includes_integrity_metadata() { +fn oversized_quarantine_immutable_metadata_tampering_fails_closed() { + for mutation in [ + "payload_digest", + "schema_version", + "original_payload_len", + "reason", + "discovered_at", + ] { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 17]; + let id = store + .quarantine(RejectedDocument::from_bytes( + &payload, + RejectionReason::TooLarge, + )) + .unwrap(); + let discovered_at = store.quarantine_record(&id).unwrap().unwrap().discovered_at; + let connection = raw_connection(&path); + let sql = match mutation { + "payload_digest" => { + "UPDATE quarantine_records SET payload_digest = \ + 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' \ + WHERE quarantine_id = ?1" + } + "schema_version" => { + "UPDATE quarantine_records SET schema_version = 'psyche.future.v1' \ + WHERE quarantine_id = ?1" + } + "original_payload_len" => { + "UPDATE quarantine_records SET original_payload_len = original_payload_len + 1 \ + WHERE quarantine_id = ?1" + } + "reason" => { + "UPDATE quarantine_records SET reason = 'unknown_schema' \ + WHERE quarantine_id = ?1" + } + "discovered_at" => { + "UPDATE quarantine_records SET discovered_at = '2026-08-08T00:00:00Z' \ + WHERE quarantine_id = ?1" + } + _ => unreachable!(), + }; + connection.execute(sql, [id.as_str()]).unwrap(); + drop(connection); + + assert_quarantine_paths_detect_corruption(&mut store, &id, discovered_at); + } +} + +#[test] +fn dedupe_rejects_corrupt_integrity_metadata() { let (mut store, _dir, path) = test_store(); let payload = vec![b'x'; 64 * 1024 + 17]; let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); @@ -471,10 +521,7 @@ fn dedupe_equality_includes_integrity_metadata() { ) .unwrap(); - assert!(matches!( - store.quarantine(rejected), - Err(StoreError::QuarantineConflict { .. }) - )); + assert_database_corruption(store.quarantine(rejected)); } #[test] @@ -904,8 +951,8 @@ fn malformed_persisted_quarantine_id_fails_prune_before_deleting_valid_rows() { " INSERT INTO quarantine_records ( quarantine_id, schema_version, payload_digest, original_payload_len, - retained_payload_digest, bounded_payload, reason, discovered_at - ) VALUES ('bad-id', NULL, ?1, 0, ?1, X'', 'unknown_schema', ?2) + retained_payload_digest, integrity_digest, bounded_payload, reason, discovered_at + ) VALUES ('bad-id', NULL, ?1, 0, ?1, ?1, X'', 'unknown_schema', ?2) ", params![ empty_payload.as_str(), diff --git a/crates/psyche-store/tests/support/mod.rs b/crates/psyche-store/tests/support/mod.rs index 15820f2..9b2d095 100644 --- a/crates/psyche-store/tests/support/mod.rs +++ b/crates/psyche-store/tests/support/mod.rs @@ -256,6 +256,7 @@ CREATE TABLE quarantine_records ( payload_digest TEXT NOT NULL, original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), retained_payload_digest TEXT NOT NULL, + integrity_digest TEXT NOT NULL, bounded_payload BLOB NOT NULL, reason TEXT NOT NULL, discovered_at TEXT NOT NULL, From ebc84e1638b8afafd7ef41171c40c02a714217b5 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:16:53 -0500 Subject: [PATCH 39/66] fix(store): validate quarantine before dedupe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-store/src/quarantine.rs | 46 ++++---------------------- crates/psyche-store/tests/retention.rs | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/crates/psyche-store/src/quarantine.rs b/crates/psyche-store/src/quarantine.rs index 48e6ba9..03a3120 100644 --- a/crates/psyche-store/src/quarantine.rs +++ b/crates/psyche-store/src/quarantine.rs @@ -324,17 +324,16 @@ impl Store { let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; - let existing = stored_by_digest_and_reason( - &transaction, - rejected.payload_digest.as_str(), - reason.as_str(), - )?; + let existing = all_records(&transaction)? + .into_iter() + .filter(|record| { + record.payload_digest == rejected.payload_digest && record.reason == reason + }) + .collect::>(); if existing.len() > 1 { return Err(StoreError::DatabaseCorruption); } - if let Some(stored) = existing.into_iter().next() { - let record = validate_stored(stored)?; - validate_record_audit(&transaction, &record)?; + if let Some(record) = existing.into_iter().next() { if record.schema_version == rejected.schema_version && record.payload_digest == rejected.payload_digest && record.original_payload_len == original_payload_len @@ -643,37 +642,6 @@ fn stored_by_id( .map_err(|_| StoreError::DatabaseCorruption) } -fn stored_by_digest_and_reason( - connection: &Connection, - payload_digest: &str, - reason: &str, -) -> Result, StoreError> { - let mut statement = connection.prepare( - " - SELECT - quarantine_id, - schema_version, - payload_digest, - original_payload_len, - retained_payload_digest, - integrity_digest, - bounded_payload, - reason, - discovered_at, - resolved_at, - resolution_code, - resolution_digest - FROM quarantine_records - WHERE payload_digest = ?1 AND reason = ?2 - ORDER BY quarantine_id - ", - )?; - statement - .query_map(params![payload_digest, reason], stored_quarantine_from_row)? - .collect::>>() - .map_err(|_| StoreError::DatabaseCorruption) -} - fn load_all_stored(connection: &Connection) -> Result, StoreError> { let mut statement = connection.prepare( " diff --git a/crates/psyche-store/tests/retention.rs b/crates/psyche-store/tests/retention.rs index 2a77cde..d7e706c 100644 --- a/crates/psyche-store/tests/retention.rs +++ b/crates/psyche-store/tests/retention.rs @@ -524,6 +524,40 @@ fn dedupe_rejects_corrupt_integrity_metadata() { assert_database_corruption(store.quarantine(rejected)); } +#[test] +fn dedupe_rejects_corrupt_lookup_keys_before_inserting() { + for column in ["payload_digest", "reason"] { + let (mut store, _dir, path) = test_store(); + let payload = vec![b'x'; 64 * 1024 + 17]; + let rejected = RejectedDocument::from_bytes(&payload, RejectionReason::TooLarge); + let id = store.quarantine(rejected.clone()).unwrap(); + let connection = raw_connection(&path); + let sql = match column { + "payload_digest" => { + "UPDATE quarantine_records SET payload_digest = \ + 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' \ + WHERE quarantine_id = ?1" + } + "reason" => { + "UPDATE quarantine_records SET reason = 'unknown_schema' \ + WHERE quarantine_id = ?1" + } + _ => unreachable!(), + }; + connection.execute(sql, [id.as_str()]).unwrap(); + drop(connection); + + assert_database_corruption(store.quarantine(rejected)); + assert_eq!( + raw_connection(&path) + .query_row("SELECT COUNT(*) FROM quarantine_records", [], |row| row + .get::<_, u64>(0)) + .unwrap(), + 1 + ); + } +} + #[test] fn quarantine_replay_validates_persisted_integrity_metadata() { let (mut store, _dir, path) = test_store(); From c3afb364a8d56ae63b4e7e7bbac0385e197f173b Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:39:48 -0500 Subject: [PATCH 40/66] feat(ports): add deterministic behavior boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 38 + crates/psyche-coven/Cargo.toml | 12 + crates/psyche-coven/src/error.rs | 115 ++ crates/psyche-coven/src/lib.rs | 13 + crates/psyche-coven/src/port.rs | 1373 +++++++++++++++++ crates/psyche-coven/tests/bindings.rs | 528 +++++++ .../fixtures/execution-request-input.json | 1 + .../fixtures/execution-request-launch.json | 1 + .../tests/fixtures/result-bundle.json | 1 + crates/psyche-coven/tests/request_digest.rs | 95 ++ crates/psyche-surfaces/Cargo.toml | 5 + crates/psyche-surfaces/src/error.rs | 31 + crates/psyche-surfaces/src/lib.rs | 6 + crates/psyche-surfaces/src/port.rs | 69 + crates/psyche-test-support/Cargo.toml | 14 + crates/psyche-test-support/src/coven.rs | 1091 +++++++++++++ crates/psyche-test-support/src/lib.rs | 12 + crates/psyche-test-support/src/surface.rs | 303 ++++ crates/psyche-test-support/tests/fakes.rs | 1364 ++++++++++++++++ 19 files changed, 5072 insertions(+) create mode 100644 crates/psyche-coven/src/error.rs create mode 100644 crates/psyche-coven/src/port.rs create mode 100644 crates/psyche-coven/tests/bindings.rs create mode 100644 crates/psyche-coven/tests/fixtures/execution-request-input.json create mode 100644 crates/psyche-coven/tests/fixtures/execution-request-launch.json create mode 100644 crates/psyche-coven/tests/fixtures/result-bundle.json create mode 100644 crates/psyche-coven/tests/request_digest.rs create mode 100644 crates/psyche-surfaces/src/error.rs create mode 100644 crates/psyche-surfaces/src/port.rs create mode 100644 crates/psyche-test-support/src/coven.rs create mode 100644 crates/psyche-test-support/src/surface.rs create mode 100644 crates/psyche-test-support/tests/fakes.rs diff --git a/Cargo.lock b/Cargo.lock index 55769a3..eef1366 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -654,6 +665,16 @@ dependencies = [ [[package]] name = "psyche-coven" version = "0.0.0" +dependencies = [ + "async-trait", + "psyche-core", + "serde", + "serde_json", + "sha2", + "thiserror", + "time", + "tokio", +] [[package]] name = "psyche-runtime" @@ -683,10 +704,27 @@ dependencies = [ [[package]] name = "psyche-surfaces" version = "0.0.0" +dependencies = [ + "async-trait", + "psyche-core", + "thiserror", +] [[package]] name = "psyche-test-support" version = "0.0.0" +dependencies = [ + "async-trait", + "psyche-core", + "psyche-coven", + "psyche-store", + "psyche-surfaces", + "serde_json", + "tempfile", + "thiserror", + "time", + "tokio", +] [[package]] name = "quick-error" diff --git a/crates/psyche-coven/Cargo.toml b/crates/psyche-coven/Cargo.toml index 0ce63e0..d9bae3f 100644 --- a/crates/psyche-coven/Cargo.toml +++ b/crates/psyche-coven/Cargo.toml @@ -7,5 +7,17 @@ license.workspace = true repository.workspace = true publish.workspace = true +[dependencies] +async-trait = { workspace = true } +psyche-core = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tokio = { workspace = true } + [lints] workspace = true diff --git a/crates/psyche-coven/src/error.rs b/crates/psyche-coven/src/error.rs new file mode 100644 index 0000000..c49751d --- /dev/null +++ b/crates/psyche-coven/src/error.rs @@ -0,0 +1,115 @@ +//! Payload-free failures at the Coven behavior boundary. + +use std::fmt; + +use psyche_core::contracts::ContractError; + +/// A stable, redacted Coven boundary failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PortError { + /// The requested behavior contract is not supported. + #[error("Coven behavior contract is unsupported")] + ContractUnsupported {}, + /// A required negotiated capability is unavailable. + #[error("required Coven capability is unavailable")] + CapabilityMissing {}, + /// A typed request failed boundary validation. + #[error("Coven request is invalid")] + InvalidRequest, + /// The claimed request digest did not attest the complete typed request. + #[error("Coven request digest does not match")] + RequestDigestMismatch, + /// A stable identity was reused for different intent. + #[error("Coven request conflicts with durable intent")] + IntentConflict, + /// Returned evidence did not echo its complete correlation. + #[error("Coven response correlation does not match")] + CorrelationMismatch, + /// The requested durable entity does not exist. + #[error("Coven entity was not found")] + NotFound, + /// Coven authoritatively denied the operation. + #[error("Coven policy denied the operation")] + PolicyDenied, + /// No authoritative outcome was available. + #[error("Coven outcome is unavailable")] + Unavailable, + /// A deterministic test operation reached a scripted stall. + #[error("Coven operation stalled")] + Stalled, + /// A deterministic fake received a call not present in its script. + #[error("Coven call was not scripted")] + UnexpectedCall, + /// Coven returned an invalid typed result. + #[error("Coven response is invalid")] + InvalidResponse, +} + +impl From for PortError { + fn from(_error: ContractError) -> Self { + Self::InvalidRequest + } +} + +/// Failure returned by a termination persistence implementation. +pub enum TerminationPersistenceFailure { + /// The candidate would fork, gap, or rewrite durable revision history. + Conflict(E), + /// Durability could not be established. + Write(E), +} + +impl fmt::Debug for TerminationPersistenceFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Conflict(_) => formatter.write_str("TerminationPersistenceFailure::Conflict"), + Self::Write(_) => formatter.write_str("TerminationPersistenceFailure::Write"), + } + } +} + +/// Phase-specific failure from the persist-then-terminate coordinator. +pub enum TerminationDispatchError { + /// The candidate binding violated the owned execution contract. + Contract(ContractError), + /// The termination-request revision did not become durable. + RequestPersistence(E), + /// Persistence returned bytes other than the requested canonical bytes. + PersistedBindingMismatch, + /// The behavior port failed after request persistence. + Port(PortError), + /// Returned acknowledgement or unresolved evidence did not match. + OutcomeEvidenceMismatch, + /// Coven responded but outcome durability is indeterminate. + OutcomePersistenceIndeterminate(E), + /// A revision fork, gap, rewrite, or divergent replay was detected. + RevisionConflict(E), + /// Persistence returned bytes other than the outcome canonical bytes. + PersistedOutcomeMismatch, +} + +impl fmt::Display for TerminationDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Contract(_) => "termination request contract validation failed", + Self::RequestPersistence(_) => "termination request persistence failed", + Self::PersistedBindingMismatch => "persisted termination request bytes do not match", + Self::Port(_) => "termination port call failed", + Self::OutcomeEvidenceMismatch => "termination outcome evidence does not match", + Self::OutcomePersistenceIndeterminate(_) => { + "termination outcome persistence is indeterminate" + } + Self::RevisionConflict(_) => "termination revision conflicts with durable history", + Self::PersistedOutcomeMismatch => "persisted termination outcome bytes do not match", + }) + } +} + +impl fmt::Debug for TerminationDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "TerminationDispatchError({self})") + } +} + +impl std::error::Error for TerminationDispatchError {} diff --git a/crates/psyche-coven/src/lib.rs b/crates/psyche-coven/src/lib.rs index 2b0fbf1..5452944 100644 --- a/crates/psyche-coven/src/lib.rs +++ b/crates/psyche-coven/src/lib.rs @@ -1 +1,14 @@ //! Behavior-level Coven execution boundary. + +pub mod error; +pub mod port; + +pub use error::{PortError, TerminationDispatchError, TerminationPersistenceFailure}; +pub use port::{ + AdoptionDisposition, AdoptionRequest, ArtifactReference, Capability, CapabilityProfile, + ContentAddressedReference, CovenEvent, CovenPort, EventCursor, EventPage, + ExecutionArtifactBinding, ExecutionCorrelation, ExecutionRequestInput, NegotiateRequest, + ReconciliationDisposition, ReconciliationRequest, ResultBundle, SessionSnapshot, + TerminationDisposition, TerminationPersistence, TerminationRequest, + derive_termination_outcome_revision, persist_then_terminate, +}; diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs new file mode 100644 index 0000000..15b7090 --- /dev/null +++ b/crates/psyche-coven/src/port.rs @@ -0,0 +1,1373 @@ +//! Typed behavior-level Coven operations. + +use std::collections::{BTreeSet, HashSet}; + +use psyche_core::contracts::execution::{ + CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, CancellationState, + CancellationUnresolvedEvidence, ExecutionBinding, +}; +use psyche_core::contracts::{ContractError, RecordKind}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::{RecordId, RequestId}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::error::{PortError, TerminationDispatchError, TerminationPersistenceFailure}; + +const EXECUTION_REQUEST_SCHEMA: &str = "psyche.execution_request.v1"; +const MAX_STRING_BYTES: usize = 255; +const MAX_ARTIFACTS: usize = 1024; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +/// A capability that a Coven implementation may advertise. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Stable digest-bound request adoption. + StableAdoption, + /// Durable ambiguity reconciliation and fencing. + AmbiguityFence, + /// Ordered event pages with durable cursors. + OrderedEvents, + /// O5-authoritative termination evidence. + AuthoritativeTermination, + /// Content-addressed result and artifact references. + ContentAddressedResults, +} + +impl Capability { + /// Stable wire spelling used during negotiation. + pub const fn as_str(self) -> &'static str { + match self { + Self::StableAdoption => "stable_adoption", + Self::AmbiguityFence => "ambiguity_fence", + Self::OrderedEvents => "ordered_events", + Self::AuthoritativeTermination => "authoritative_termination", + Self::ContentAddressedResults => "content_addressed_results", + } + } +} + +/// Required Coven API version and behaviors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiateRequest { + /// Exact behavior API version. + pub required_api_version: String, + /// Stable required capability spellings. + pub required_capabilities: BTreeSet, +} + +impl NegotiateRequest { + /// Builds a request for an exact API version with no optional capabilities. + pub fn new(required_api_version: impl Into) -> Self { + Self { + required_api_version: required_api_version.into(), + required_capabilities: BTreeSet::new(), + } + } + + /// Adds one typed required capability. + #[must_use] + pub fn requiring(mut self, capability: Capability) -> Self { + self.required_capabilities + .insert(capability.as_str().to_owned()); + self + } + + /// Validates bounded canonical contract and capability spellings. + pub fn validate(&self) -> Result<(), PortError> { + bounded(&self.required_api_version)?; + if self.required_capabilities.len() > 64 + || self + .required_capabilities + .iter() + .any(|value| !stable_token(value, MAX_STRING_BYTES)) + { + return Err(PortError::InvalidRequest); + } + Ok(()) + } +} + +/// Negotiated Coven behavior profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityProfile { + /// Exact supported behavior API version. + pub api_version: String, + /// Stable supported capability spellings. + pub capabilities: BTreeSet, +} + +impl CapabilityProfile { + /// Validates the bounded profile. + pub fn validate(&self) -> Result<(), PortError> { + NegotiateRequest { + required_api_version: self.api_version.clone(), + required_capabilities: self.capabilities.clone(), + } + .validate() + } +} + +/// One immutable content binding required by an execution request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionArtifactBinding { + /// Stable artifact identity. + pub artifact_id: String, + /// Digest of the exact artifact bytes. + pub digest: Sha256Digest, + /// Strict lowercase media type. + pub media_type: String, + /// Exact payload length. + pub size: u64, +} + +impl ExecutionArtifactBinding { + fn validate(&self) -> Result<(), PortError> { + bounded(&self.artifact_id)?; + validate_media_type(&self.media_type)?; + if self.size == 0 || self.size > MAX_SAFE_INTEGER { + return Err(PortError::InvalidRequest); + } + Ok(()) + } +} + +/// Canonical digest input for launch and input adoption. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExecutionRequestInput { + /// Launches a new supervised session. + Launch { + /// Exact schema string. + schema_version: String, + /// Stable operation identity. + request_id: RequestId, + /// Owning graph. + graph_id: RecordId, + /// Owning graph node. + node_id: RecordId, + /// Owning execution attempt. + attempt_id: RecordId, + /// Principal whose authority admitted the request. + principal_id: String, + /// Pinned familiar identity snapshot. + familiar_snapshot_id: RecordId, + /// Stable project identity. + project_id: String, + /// Canonical absolute project root. + project_root: String, + /// Canonical absolute working directory within the project root. + cwd: String, + /// Supported harness spelling. + harness: String, + /// Context manifest digest. + context_manifest_digest: Sha256Digest, + /// Optional delegation digest. + delegation_digest: Option, + /// Budget digest. + budget_digest: Sha256Digest, + /// Ordered, unique required artifacts. + required_artifact_bindings: Vec, + /// Digest of the typed execution payload. + payload_digest: Sha256Digest, + /// Correlation creation time. + #[serde(with = "time::serde::rfc3339")] + created_at: time::OffsetDateTime, + /// Correlation deadline. + #[serde(with = "time::serde::rfc3339")] + valid_until: time::OffsetDateTime, + }, + /// Sends input to an already adopted session. + Input { + /// Exact schema string. + schema_version: String, + /// Stable operation identity. + request_id: RequestId, + /// Owning graph. + graph_id: RecordId, + /// Owning graph node. + node_id: RecordId, + /// Owning execution attempt. + attempt_id: RecordId, + /// Principal whose authority admitted the request. + principal_id: String, + /// Pinned familiar identity snapshot. + familiar_snapshot_id: RecordId, + /// Stable project identity. + project_id: String, + /// Adopted Coven session. + session_id: String, + /// Digest of the exact input. + input_digest: Sha256Digest, + /// Context manifest digest. + context_manifest_digest: Sha256Digest, + /// Ordered, unique required artifacts. + required_artifact_bindings: Vec, + /// Digest of the typed execution payload. + payload_digest: Sha256Digest, + /// Correlation creation time. + #[serde(with = "time::serde::rfc3339")] + created_at: time::OffsetDateTime, + /// Correlation deadline. + #[serde(with = "time::serde::rfc3339")] + valid_until: time::OffsetDateTime, + }, +} + +impl ExecutionRequestInput { + /// Validates every typed request field before digesting or dispatch. + pub fn validate(&self) -> Result<(), PortError> { + let ( + schema, + request_id, + graph_id, + node_id, + attempt_id, + principal_id, + familiar_snapshot_id, + project_id, + artifacts, + created_at, + valid_until, + ) = match self { + Self::Launch { + schema_version, + request_id, + graph_id, + node_id, + attempt_id, + principal_id, + familiar_snapshot_id, + project_id, + project_root, + cwd, + harness, + required_artifact_bindings, + created_at, + valid_until, + .. + } => { + validate_absolute_path(project_root)?; + validate_absolute_path(cwd)?; + if !path_is_within(project_root, cwd) || harness != "codex" { + return Err(PortError::InvalidRequest); + } + ( + schema_version, + request_id, + graph_id, + node_id, + attempt_id, + principal_id, + familiar_snapshot_id, + project_id, + required_artifact_bindings, + created_at, + valid_until, + ) + } + Self::Input { + schema_version, + request_id, + graph_id, + node_id, + attempt_id, + principal_id, + familiar_snapshot_id, + project_id, + session_id, + required_artifact_bindings, + created_at, + valid_until, + .. + } => { + bounded(session_id)?; + ( + schema_version, + request_id, + graph_id, + node_id, + attempt_id, + principal_id, + familiar_snapshot_id, + project_id, + required_artifact_bindings, + created_at, + valid_until, + ) + } + }; + + if schema != EXECUTION_REQUEST_SCHEMA + || request_id.as_str().is_empty() + || graph_id.kind() != RecordKind::Graph + || node_id.kind() != RecordKind::GraphNode + || attempt_id.kind() != RecordKind::Attempt + || familiar_snapshot_id.kind() != RecordKind::IdentitySnapshot + { + return Err(PortError::InvalidRequest); + } + bounded(principal_id)?; + bounded(project_id)?; + validate_window(*created_at, *valid_until)?; + validate_artifact_bindings(artifacts) + } + + fn request_id(&self) -> &RequestId { + match self { + Self::Launch { request_id, .. } | Self::Input { request_id, .. } => request_id, + } + } + + fn correlation_fields( + &self, + ) -> ( + &RecordId, + &RecordId, + &RecordId, + &RecordId, + &str, + time::OffsetDateTime, + time::OffsetDateTime, + ) { + match self { + Self::Launch { + graph_id, + node_id, + attempt_id, + familiar_snapshot_id, + project_id, + created_at, + valid_until, + .. + } + | Self::Input { + graph_id, + node_id, + attempt_id, + familiar_snapshot_id, + project_id, + created_at, + valid_until, + .. + } => ( + graph_id, + node_id, + attempt_id, + familiar_snapshot_id, + project_id, + *created_at, + *valid_until, + ), + } + } +} + +/// A digest-attested execution adoption request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdoptionRequest { + input: ExecutionRequestInput, + request_digest: Sha256Digest, +} + +impl AdoptionRequest { + /// Validates and digests a complete execution request. + pub fn new(input: ExecutionRequestInput) -> Result { + input.validate()?; + let request_digest = digest(&input)?; + Ok(Self { + input, + request_digest, + }) + } + + /// Complete canonical digest input. + pub fn input(&self) -> &ExecutionRequestInput { + &self.input + } + + /// Claimed digest carried by the wire envelope. + pub fn request_digest(&self) -> &Sha256Digest { + &self.request_digest + } + + /// Correlation derived from the same request and digest. + pub fn correlation(&self) -> ExecutionCorrelation { + let ( + graph_id, + node_id, + attempt_id, + familiar_snapshot_id, + project_id, + created_at, + valid_until, + ) = self.input.correlation_fields(); + ExecutionCorrelation { + request_id: self.input.request_id().clone(), + request_digest: self.request_digest.clone(), + familiar_snapshot_id: familiar_snapshot_id.clone(), + project_id: project_id.to_owned(), + graph_id: graph_id.clone(), + node_id: node_id.clone(), + attempt_id: attempt_id.clone(), + created_at, + valid_until, + } + } + + /// Recomputes the digest from the complete typed input. + pub fn recompute_digest(&self) -> Result { + digest(&self.input).map_err(Into::into) + } + + /// Validates the claimed digest with a constant-time comparison. + pub fn validate_digest(&self) -> Result<(), PortError> { + let recomputed = self.recompute_digest()?; + if !constant_time_equal( + recomputed.as_str().as_bytes(), + self.request_digest.as_str().as_bytes(), + ) { + return Err(PortError::RequestDigestMismatch); + } + self.input.validate() + } +} + +/// Complete immutable request correlation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExecutionCorrelation { + /// Stable execution request identity. + pub request_id: RequestId, + /// Digest of the complete typed execution request. + pub request_digest: Sha256Digest, + /// Pinned familiar identity. + pub familiar_snapshot_id: RecordId, + /// Stable project identity. + pub project_id: String, + /// Owning graph. + pub graph_id: RecordId, + /// Owning graph node. + pub node_id: RecordId, + /// Owning attempt. + pub attempt_id: RecordId, + /// Correlation creation time. + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, + /// Correlation deadline. + #[serde(with = "time::serde::rfc3339")] + pub valid_until: time::OffsetDateTime, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExecutionCorrelationWire { + request_id: RequestId, + request_digest: Sha256Digest, + familiar_snapshot_id: RecordId, + project_id: String, + graph_id: RecordId, + node_id: RecordId, + attempt_id: RecordId, + #[serde(with = "time::serde::rfc3339")] + created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + valid_until: time::OffsetDateTime, +} + +impl TryFrom for ExecutionCorrelation { + type Error = PortError; + + fn try_from(wire: ExecutionCorrelationWire) -> Result { + let value = Self { + request_id: wire.request_id, + request_digest: wire.request_digest, + familiar_snapshot_id: wire.familiar_snapshot_id, + project_id: wire.project_id, + graph_id: wire.graph_id, + node_id: wire.node_id, + attempt_id: wire.attempt_id, + created_at: wire.created_at, + valid_until: wire.valid_until, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for ExecutionCorrelation { + fn deserialize>(deserializer: D) -> Result { + ExecutionCorrelationWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl ExecutionCorrelation { + /// Validates all field kinds, bounds, and the canonical UTC lifetime. + pub fn validate(&self) -> Result<(), PortError> { + if self.graph_id.kind() != RecordKind::Graph + || self.node_id.kind() != RecordKind::GraphNode + || self.attempt_id.kind() != RecordKind::Attempt + || self.familiar_snapshot_id.kind() != RecordKind::IdentitySnapshot + { + return Err(PortError::InvalidRequest); + } + bounded(&self.project_id)?; + validate_window(self.created_at, self.valid_until) + } +} + +/// Result of stable adoption or lookup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdoptionDisposition { + /// One durable Coven session owns the request. + Adopted { + /// Stable opaque session identity. + session_id: String, + }, + /// Coven durably proved that no adoption occurred. + ProvenNotAdopted, + /// Adoption remains ambiguous. + Unknown, +} + +impl AdoptionDisposition { + /// Validates response bounds. + pub fn validate(&self) -> Result<(), PortError> { + if let Self::Adopted { session_id } = self { + bounded(session_id).map_err(|_| PortError::InvalidResponse)?; + } + Ok(()) + } +} + +/// Correlation-bound ambiguity reconciliation request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconciliationRequest { + /// Complete immutable execution correlation. + pub correlation: ExecutionCorrelation, + /// Digest of the durable ambiguity evidence. + pub ambiguity_digest: Sha256Digest, + /// Stable bounded reason. + pub reason_code: String, +} + +impl ReconciliationRequest { + /// Validates correlation and reason. + pub fn validate(&self) -> Result<(), PortError> { + self.correlation.validate()?; + if !reason_code(&self.reason_code) { + return Err(PortError::InvalidRequest); + } + Ok(()) + } +} + +/// Durable reconciliation outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReconciliationDisposition { + /// The adopted execution was authoritatively found. + Returned { + /// Durable disposition identity. + disposition_id: String, + /// Existing session. + session_id: String, + /// Exact request correlation. + correlation: ExecutionCorrelation, + /// Exact ambiguity digest. + ambiguity_digest: Sha256Digest, + /// Durable disposition time. + recorded_at: time::OffsetDateTime, + }, + /// Every resource capable of satisfying the correlation was fenced. + Fenced { + /// Durable disposition identity. + disposition_id: String, + /// Opaque fence token. + fence_token: String, + /// Exact request correlation. + correlation: ExecutionCorrelation, + /// Exact ambiguity digest. + ambiguity_digest: Sha256Digest, + /// Durable disposition time. + recorded_at: time::OffsetDateTime, + }, + /// No authoritative reconciliation outcome exists. + Unresolved, +} + +impl ReconciliationDisposition { + /// Validates an outcome against its exact request. + pub fn validate_for(&self, request: &ReconciliationRequest) -> Result<(), PortError> { + let (disposition_id, opaque, correlation, ambiguity_digest, recorded_at) = match self { + Self::Returned { + disposition_id, + session_id, + correlation, + ambiguity_digest, + recorded_at, + } => ( + disposition_id, + session_id, + correlation, + ambiguity_digest, + recorded_at, + ), + Self::Fenced { + disposition_id, + fence_token, + correlation, + ambiguity_digest, + recorded_at, + } => ( + disposition_id, + fence_token, + correlation, + ambiguity_digest, + recorded_at, + ), + Self::Unresolved => return Ok(()), + }; + bounded(disposition_id).map_err(|_| PortError::InvalidResponse)?; + bounded(opaque).map_err(|_| PortError::InvalidResponse)?; + if correlation != &request.correlation + || ambiguity_digest != &request.ambiguity_digest + || !utc(*recorded_at) + || *recorded_at < request.correlation.created_at + || *recorded_at > request.correlation.valid_until + { + return Err(PortError::CorrelationMismatch); + } + Ok(()) + } +} + +/// Current session state with complete execution correlation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSnapshot { + /// Stable opaque session identity. + pub session_id: String, + /// Exact adoption correlation. + pub correlation: ExecutionCorrelation, + /// Optional terminal ledger state, never cancellation evidence. + pub terminal_state: Option, +} + +impl SessionSnapshot { + /// Validates bounded snapshot metadata. + pub fn validate(&self) -> Result<(), PortError> { + bounded(&self.session_id).map_err(|_| PortError::InvalidResponse)?; + self.correlation + .validate() + .map_err(|_| PortError::InvalidResponse)?; + if self + .terminal_state + .as_deref() + .is_some_and(|value| bounded(value).is_err()) + { + return Err(PortError::InvalidResponse); + } + Ok(()) + } +} + +/// Cursor for ordered session events. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventCursor { + /// Stable opaque session identity. + pub session_id: String, + /// Last durably consumed sequence. + pub after_sequence: u64, +} + +impl EventCursor { + /// Validates session and safe-integer bounds. + pub fn validate(&self) -> Result<(), PortError> { + bounded(&self.session_id)?; + if self.after_sequence > MAX_SAFE_INTEGER { + return Err(PortError::InvalidRequest); + } + Ok(()) + } +} + +/// One ordered Coven event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CovenEvent { + /// Monotonic sequence. + pub sequence: u64, + /// Digest of the complete event. + pub event_digest: Sha256Digest, + /// Optional raw terminal ledger state. + pub terminal_state: Option, +} + +/// One ordered event page and its continuation cursor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventPage { + /// Strictly ordered events after the input cursor. + pub events: Vec, + /// Cursor after the returned page. + pub next_cursor: EventCursor, +} + +impl EventPage { + /// Validates ordering and cursor consistency. + pub fn validate_for(&self, cursor: &EventCursor) -> Result<(), PortError> { + self.next_cursor + .validate() + .map_err(|_| PortError::InvalidResponse)?; + if self.next_cursor.session_id != cursor.session_id || self.events.len() > MAX_ARTIFACTS { + return Err(PortError::CorrelationMismatch); + } + let mut previous = cursor.after_sequence; + for event in &self.events { + if event.sequence <= previous || event.sequence > MAX_SAFE_INTEGER { + return Err(PortError::InvalidResponse); + } + if event + .terminal_state + .as_deref() + .is_some_and(|value| bounded(value).is_err()) + { + return Err(PortError::InvalidResponse); + } + previous = event.sequence; + } + if self.next_cursor.after_sequence != previous { + return Err(PortError::CorrelationMismatch); + } + Ok(()) + } +} + +/// Content-addressed payload metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ContentAddressedReference { + /// SHA-256 digest of exact bytes. + pub digest: Sha256Digest, + /// Strict lowercase media type. + pub media_type: String, + /// Exact nonzero payload size. + pub size_bytes: u64, + /// Last instant at which content may be retrieved. + #[serde(with = "time::serde::rfc3339")] + pub expires_at: time::OffsetDateTime, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ContentAddressedReferenceWire { + digest: Sha256Digest, + media_type: String, + size_bytes: u64, + #[serde(with = "time::serde::rfc3339")] + expires_at: time::OffsetDateTime, +} + +impl TryFrom for ContentAddressedReference { + type Error = PortError; + + fn try_from(wire: ContentAddressedReferenceWire) -> Result { + let value = Self { + digest: wire.digest, + media_type: wire.media_type, + size_bytes: wire.size_bytes, + expires_at: wire.expires_at, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for ContentAddressedReference { + fn deserialize>(deserializer: D) -> Result { + ContentAddressedReferenceWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl ContentAddressedReference { + /// Constructs metadata from exact payload bytes. + pub fn for_bytes( + media_type: impl Into, + bytes: &[u8], + expires_at: time::OffsetDateTime, + ) -> Result { + let size_bytes = u64::try_from(bytes.len()).map_err(|_| PortError::InvalidRequest)?; + let value = Self { + digest: raw_digest(bytes), + media_type: media_type.into(), + size_bytes, + expires_at, + }; + value.validate()?; + Ok(value) + } + + /// Validates metadata only; this does not attest payload bytes. + pub fn validate(&self) -> Result<(), PortError> { + validate_media_type(&self.media_type)?; + if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { + return Err(PortError::InvalidRequest); + } + Ok(()) + } + + /// Attests exact payload length and digest. + pub fn validate_payload(&self, bytes: &[u8]) -> Result<(), PortError> { + self.validate()?; + if usize::try_from(self.size_bytes).ok() != Some(bytes.len()) + || !constant_time_equal( + raw_digest(bytes).as_str().as_bytes(), + self.digest.as_str().as_bytes(), + ) + { + return Err(PortError::InvalidRequest); + } + Ok(()) + } + + /// Attests payload bytes and rejects retrieval after expiry. + pub fn validate_payload_at( + &self, + bytes: &[u8], + at: time::OffsetDateTime, + ) -> Result<(), PortError> { + self.validate_payload(bytes)?; + if !utc(at) || at > self.expires_at { + return Err(PortError::InvalidRequest); + } + Ok(()) + } +} + +/// One result artifact bound to a session and execution correlation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ArtifactReference { + /// Unique bounded artifact identity. + pub artifact_id: String, + /// Exact result session. + pub session_id: String, + /// Exact adoption correlation. + pub correlation: ExecutionCorrelation, + /// Content-addressed bytes. + pub content: ContentAddressedReference, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ArtifactReferenceWire { + artifact_id: String, + session_id: String, + correlation: ExecutionCorrelation, + content: ContentAddressedReference, +} + +impl TryFrom for ArtifactReference { + type Error = PortError; + + fn try_from(wire: ArtifactReferenceWire) -> Result { + let value = Self { + artifact_id: wire.artifact_id, + session_id: wire.session_id, + correlation: wire.correlation, + content: wire.content, + }; + bounded(&value.artifact_id)?; + bounded(&value.session_id)?; + value.correlation.validate()?; + value.content.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for ArtifactReference { + fn deserialize>(deserializer: D) -> Result { + ArtifactReferenceWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +/// Complete content-addressed result and artifact references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResultBundle { + /// Exact Coven session. + pub session_id: String, + /// Exact adoption correlation. + pub correlation: ExecutionCorrelation, + /// Primary result bytes. + pub result: ContentAddressedReference, + /// Ordered unique artifact references. + pub artifacts: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResultBundleWire { + session_id: String, + correlation: ExecutionCorrelation, + result: ContentAddressedReference, + artifacts: Vec, +} + +impl TryFrom for ResultBundle { + type Error = PortError; + + fn try_from(wire: ResultBundleWire) -> Result { + let value = Self { + session_id: wire.session_id, + correlation: wire.correlation, + result: wire.result, + artifacts: wire.artifacts, + }; + value.validate()?; + Ok(value) + } +} + +impl<'de> Deserialize<'de> for ResultBundle { + fn deserialize>(deserializer: D) -> Result { + ResultBundleWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl ResultBundle { + /// Validates complete correlation, association, uniqueness, and lifetimes. + pub fn validate(&self) -> Result<(), PortError> { + bounded(&self.session_id)?; + self.correlation.validate()?; + self.result.validate()?; + if self.result.expires_at <= self.correlation.created_at + || self.result.expires_at > self.correlation.valid_until + || self.artifacts.len() > MAX_ARTIFACTS + { + return Err(PortError::InvalidRequest); + } + let mut artifact_ids = HashSet::with_capacity(self.artifacts.len()); + for artifact in &self.artifacts { + bounded(&artifact.artifact_id)?; + if !artifact_ids.insert(artifact.artifact_id.as_str()) { + return Err(PortError::InvalidRequest); + } + if artifact.session_id != self.session_id + || artifact.correlation != self.correlation + || artifact.content.expires_at <= self.correlation.created_at + || artifact.content.expires_at > self.result.expires_at + || artifact.content.expires_at > self.correlation.valid_until + { + return Err(PortError::CorrelationMismatch); + } + artifact.content.validate()?; + } + Ok(()) + } +} + +/// Construction-closed termination request proven durable by the coordinator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminationRequest { + persisted_binding: ExecutionBinding, + reason_code: String, +} + +impl TerminationRequest { + fn from_persisted_binding(persisted_binding: ExecutionBinding) -> Result { + validate_termination_requested_candidate(&persisted_binding)?; + let reason_code = persisted_binding + .termination_reason_code + .clone() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + Ok(Self { + persisted_binding, + reason_code, + }) + } + + /// Exact persisted termination-requested binding. + pub fn binding(&self) -> &ExecutionBinding { + &self.persisted_binding + } + + /// Validated stable termination reason. + pub fn reason_code(&self) -> &str { + &self.reason_code + } +} + +/// Validated authoritative termination response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminationDisposition { + /// O5 authority acknowledged termination or prior authoritative completion. + Acknowledged { + /// Core-owned authority evidence. + evidence: CancellationAcknowledgementEvidence, + }, + /// O5 could not provide authoritative acknowledgement. + Unresolved { + /// Core-owned durable unresolved evidence. + evidence: CancellationUnresolvedEvidence, + }, +} + +/// Narrow durable boundary used by termination coordination. +pub trait TerminationPersistence { + /// Adapter-owned payload-free failure. + type Error; + + /// Durably persists or exactly replays the requested revision. + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure>; + + /// Durably persists or exactly replays the validated outcome revision. + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure>; +} + +/// Persists a request before dispatch and its validated outcome before success. +pub async fn persist_then_terminate( + persistence: &mut S, + port: &P, + requested: ExecutionBinding, +) -> Result> +where + S: TerminationPersistence, + P: CovenPort + ?Sized, +{ + validate_termination_requested_candidate(&requested) + .map_err(TerminationDispatchError::Contract)?; + let expected_bytes = canonical_bytes(&requested).map_err(TerminationDispatchError::Contract)?; + let persisted_bytes = persistence + .persist_requested(requested.clone()) + .map_err(|failure| match failure { + TerminationPersistenceFailure::Conflict(error) => { + TerminationDispatchError::RevisionConflict(error) + } + TerminationPersistenceFailure::Write(error) => { + TerminationDispatchError::RequestPersistence(error) + } + })?; + if persisted_bytes != expected_bytes { + return Err(TerminationDispatchError::PersistedBindingMismatch); + } + let request = TerminationRequest::from_persisted_binding(requested.clone()) + .map_err(TerminationDispatchError::Contract)?; + let disposition = port + .terminate(request) + .await + .map_err(TerminationDispatchError::Port)?; + let outcome = + derive_termination_outcome_revision(&requested, &disposition).map_err(|error| { + if error == ContractError::CancellationEvidenceMismatch { + TerminationDispatchError::OutcomeEvidenceMismatch + } else { + TerminationDispatchError::Contract(error) + } + })?; + let expected_outcome_bytes = + canonical_bytes(&outcome).map_err(TerminationDispatchError::Contract)?; + let persisted_outcome_bytes = + persistence + .persist_outcome(outcome) + .map_err(|failure| match failure { + TerminationPersistenceFailure::Conflict(error) => { + TerminationDispatchError::RevisionConflict(error) + } + TerminationPersistenceFailure::Write(error) => { + TerminationDispatchError::OutcomePersistenceIndeterminate(error) + } + })?; + if persisted_outcome_bytes != expected_outcome_bytes { + return Err(TerminationDispatchError::PersistedOutcomeMismatch); + } + Ok(disposition) +} + +/// Validates response evidence and derives the sole legal next outcome revision. +pub fn derive_termination_outcome_revision( + persisted_requested: &ExecutionBinding, + disposition: &TerminationDisposition, +) -> Result { + validate_termination_requested_candidate(persisted_requested)?; + let termination = persisted_requested + .termination_request + .as_ref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + let session = persisted_requested + .coven_session_id + .as_deref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + let mut outcome = persisted_requested.clone(); + outcome.revision = outcome + .revision + .checked_add(1) + .ok_or(ContractError::CancellationEvidenceMismatch)?; + outcome.previous_revision_digest = Some(digest(persisted_requested)?); + let evidence_at = match disposition { + TerminationDisposition::Acknowledged { evidence } => { + evidence + .validate() + .map_err(|_| ContractError::CancellationEvidenceMismatch)?; + if evidence.termination_request_id != termination.termination_request_id + || evidence.session_id != session + || evidence.execution_request_id != persisted_requested.request_id + || evidence.execution_request_digest != persisted_requested.request_digest + || digest_is_zero(&evidence.authority_evidence_digest) + || !utc(evidence.acknowledged_at) + || evidence.acknowledged_at < termination.created_at + || evidence.acknowledged_at > termination.valid_until + { + return Err(ContractError::CancellationEvidenceMismatch); + } + outcome.cancellation_state = match evidence.kind { + CancellationAcknowledgementKind::Terminated => { + CancellationState::AcknowledgedTerminated + } + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal => { + CancellationState::AcknowledgedAlreadyTerminal + } + }; + outcome.cancellation_acknowledgement = Some(evidence.clone()); + outcome.cancellation_unresolved = None; + evidence.acknowledged_at + } + TerminationDisposition::Unresolved { evidence } => { + evidence + .validate() + .map_err(|_| ContractError::CancellationEvidenceMismatch)?; + if evidence.termination_request_id != termination.termination_request_id + || evidence.session_id != session + || evidence.execution_request_id != persisted_requested.request_id + || evidence.execution_request_digest != persisted_requested.request_digest + || !utc(evidence.recorded_at) + || evidence.recorded_at < termination.created_at + || evidence.recorded_at > termination.valid_until + { + return Err(ContractError::CancellationEvidenceMismatch); + } + outcome.cancellation_state = CancellationState::TerminationUnknown; + outcome.cancellation_acknowledgement = None; + outcome.cancellation_unresolved = Some(evidence.clone()); + evidence.recorded_at + } + }; + let minimum_revision_time = persisted_requested + .revision_created_at + .checked_add(time::Duration::nanoseconds(1)) + .ok_or(ContractError::CancellationEvidenceMismatch)?; + outcome.revision_created_at = evidence_at.max(minimum_revision_time); + outcome + .validate() + .map_err(|_| ContractError::CancellationEvidenceMismatch)?; + Ok(outcome) +} + +fn validate_termination_requested_candidate( + requested: &ExecutionBinding, +) -> Result<(), ContractError> { + requested.validate()?; + let termination = requested + .termination_request + .as_ref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + let session = requested + .coven_session_id + .as_deref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + let reason = requested + .termination_reason_code + .as_deref() + .ok_or(ContractError::CancellationEvidenceMismatch)?; + if requested.revision < 2 + || requested.previous_revision_digest.is_none() + || requested.cancellation_state != CancellationState::TerminationRequested + || !bounded_string(session, MAX_STRING_BYTES) + || !reason_code(reason) + || termination.termination_request_id == requested.request_id + || termination.created_at < requested.request_created_at + || !utc(requested.revision_created_at) + || !utc(requested.request_created_at) + || !utc(requested.request_valid_until) + || !utc(termination.created_at) + || !utc(termination.valid_until) + { + return Err(ContractError::CancellationEvidenceMismatch); + } + Ok(()) +} + +/// Behavior-level Coven execution boundary. +#[async_trait::async_trait] +pub trait CovenPort: Send + Sync { + /// Negotiates an exact behavior contract. + async fn negotiate(&self, request: NegotiateRequest) -> Result; + /// Stably adopts a complete digest-attested request. + async fn adopt(&self, request: AdoptionRequest) -> Result; + /// Looks up the durable disposition for a stable request identity. + async fn lookup(&self, request_id: &RequestId) -> Result; + /// Reconciles and fences ambiguous adoption. + async fn reconcile( + &self, + request: ReconciliationRequest, + ) -> Result; + /// Inspects a session snapshot. + async fn inspect(&self, session_id: &str) -> Result; + /// Reads one ordered event page. + async fn events(&self, cursor: EventCursor) -> Result; + /// Reads content-addressed result metadata. + async fn result(&self, session_id: &str) -> Result; + /// Requests termination using a construction-closed durable request. + async fn terminate( + &self, + request: TerminationRequest, + ) -> Result; +} + +fn validate_artifact_bindings(bindings: &[ExecutionArtifactBinding]) -> Result<(), PortError> { + if bindings.len() > MAX_ARTIFACTS { + return Err(PortError::InvalidRequest); + } + let mut ids = HashSet::with_capacity(bindings.len()); + for binding in bindings { + binding.validate()?; + if !ids.insert(binding.artifact_id.as_str()) { + return Err(PortError::InvalidRequest); + } + } + Ok(()) +} + +fn validate_window( + created_at: time::OffsetDateTime, + valid_until: time::OffsetDateTime, +) -> Result<(), PortError> { + if !utc(created_at) || !utc(valid_until) || valid_until <= created_at { + Err(PortError::InvalidRequest) + } else { + Ok(()) + } +} + +fn validate_absolute_path(path: &str) -> Result<(), PortError> { + if path == "/" { + return Ok(()); + } + if !bounded_string(path, 4096) + || !path.starts_with('/') + || path.contains('\0') + || path.contains("//") + || (path.len() > 1 && path.ends_with('/')) + || path + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + Err(PortError::InvalidRequest) + } else { + Ok(()) + } +} + +fn path_is_within(root: &str, candidate: &str) -> bool { + root == "/" + || candidate == root + || candidate + .strip_prefix(root) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn validate_media_type(media_type: &str) -> Result<(), PortError> { + let mut parts = media_type.split('/'); + let Some(major) = parts.next() else { + return Err(PortError::InvalidRequest); + }; + let Some(minor) = parts.next() else { + return Err(PortError::InvalidRequest); + }; + let valid = |component: &str| { + !component.is_empty() + && component.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"!#$&^_.+-".contains(&byte) + }) + }; + if media_type.len() > MAX_STRING_BYTES + || parts.next().is_some() + || !valid(major) + || !valid(minor) + { + Err(PortError::InvalidRequest) + } else { + Ok(()) + } +} + +fn bounded(value: &str) -> Result<(), PortError> { + if bounded_string(value, MAX_STRING_BYTES) { + Ok(()) + } else { + Err(PortError::InvalidRequest) + } +} + +fn bounded_string(value: &str, maximum: usize) -> bool { + !value.is_empty() && value.len() <= maximum +} + +fn stable_token(value: &str, maximum: usize) -> bool { + bounded_string(value, maximum) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn reason_code(value: &str) -> bool { + value.len() <= 128 + && value.split('_').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) +} + +fn utc(value: time::OffsetDateTime) -> bool { + value.offset() == time::UtcOffset::UTC +} + +fn raw_digest(bytes: &[u8]) -> Sha256Digest { + let digest = Sha256::digest(bytes); + let value = format!("sha256:{digest:x}"); + match Sha256Digest::parse(&value) { + Ok(value) => value, + Err(_) => unreachable!("SHA-256 formatting is canonical"), + } +} + +fn digest_is_zero(digest: &Sha256Digest) -> bool { + digest.as_str().as_bytes()[7..] + .iter() + .all(|byte| *byte == b'0') +} + +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs new file mode 100644 index 0000000..ef6c0a9 --- /dev/null +++ b/crates/psyche-coven/tests/bindings.rs @@ -0,0 +1,528 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use psyche_core::contracts::execution::{ + AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, + CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, + TerminationRequestCorrelation, +}; +use psyche_core::contracts::{RecordKind, SchemaVersion}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, CapabilityProfile, ContentAddressedReference, CovenPort, + EventCursor, EventPage, ExecutionRequestInput, NegotiateRequest, PortError, + ReconciliationDisposition, ReconciliationRequest, ResultBundle, SessionSnapshot, + TerminationDispatchError, TerminationDisposition, TerminationPersistence, + TerminationPersistenceFailure, TerminationRequest, persist_then_terminate, +}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +const RESULT_GOLDEN: &[u8] = include_bytes!("fixtures/result-bundle.json"); +const LAUNCH_GOLDEN: &[u8] = include_bytes!("fixtures/execution-request-launch.json"); + +fn at(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn digest_of(character: char) -> Sha256Digest { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() +} + +fn record_id(kind: RecordKind, suffix: &str) -> RecordId { + RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())).unwrap() +} + +#[test] +fn result_bundle_fixture_round_trips_complete_content_references() { + let bundle: ResultBundle = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + bundle.validate().unwrap(); + assert_eq!(canonical_bytes(&bundle).unwrap(), RESULT_GOLDEN); + assert_eq!(bundle.session_id, "session-1"); + assert_eq!( + bundle.result.digest.as_str(), + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert_eq!(bundle.result.media_type, "application/json"); + assert_eq!(bundle.result.size_bytes, 2); + assert_eq!(bundle.result.expires_at, at("2026-08-05T14:04:00Z")); + assert_eq!(bundle.artifacts.len(), 1); + assert_eq!(bundle.artifacts[0].artifact_id, "artifact-1"); + assert_eq!( + bundle.artifacts[0].content.digest.as_str(), + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + assert_eq!(bundle.artifacts[0].content.media_type, "text/plain"); + assert_eq!(bundle.artifacts[0].content.size_bytes, 5); + assert_eq!( + bundle.artifacts[0].content.expires_at, + at("2026-08-05T14:03:00Z") + ); + + let mut missing: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + missing.as_object_mut().unwrap().remove("result"); + assert!(serde_json::from_value::(missing).is_err()); + let mut unknown: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + unknown["future"] = serde_json::json!(true); + assert!(serde_json::from_value::(unknown).is_err()); + + let mut missing_nested: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + missing_nested["result"] + .as_object_mut() + .unwrap() + .remove("digest"); + assert!(serde_json::from_value::(missing_nested).is_err()); + let mut unknown_nested: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + unknown_nested["artifacts"][0]["content"]["future"] = serde_json::json!(true); + assert!(serde_json::from_value::(unknown_nested).is_err()); +} + +#[test] +fn result_bundle_fixture_uses_launch_request_correlation() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let expected = AdoptionRequest::new(input).unwrap().correlation(); + let bundle: ResultBundle = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + assert_eq!(bundle.correlation, expected); + assert_eq!(bundle.artifacts[0].correlation, expected); +} + +#[test] +fn result_bundle_accepts_unique_artifacts_in_wire_order() { + let mut ordered: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + let mut second = ordered["artifacts"][0].clone(); + second["artifact_id"] = serde_json::json!("artifact-2"); + second["content"]["digest"] = serde_json::json!(format!("sha256:{}", "d".repeat(64))); + ordered["artifacts"].as_array_mut().unwrap().push(second); + let ordered: ResultBundle = serde_json::from_value(ordered.clone()).unwrap(); + + let mut reversed = serde_json::to_value(ordered).unwrap(); + reversed["artifacts"].as_array_mut().unwrap().reverse(); + let reversed: ResultBundle = serde_json::from_value(reversed).unwrap(); + + assert_eq!(reversed.artifacts[0].artifact_id, "artifact-2"); + assert_eq!(reversed.artifacts[1].artifact_id, "artifact-1"); +} + +#[test] +fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { + let expires = at("2030-08-05T14:04:00Z"); + let reference = + ContentAddressedReference::for_bytes("application/json", b"{}", expires).unwrap(); + reference.validate().unwrap(); + reference.validate_payload(b"{}").unwrap(); + assert!(reference.validate_payload(b"{]").is_err()); + + let mut wrong_size = reference.clone(); + wrong_size.size_bytes += 1; + assert!(wrong_size.validate_payload(b"{}").is_err()); + let mut wrong_digest = reference.clone(); + wrong_digest.digest = digest_of('a'); + assert!(wrong_digest.validate_payload(b"{}").is_err()); + for media_type in [ + "", + "TEXT/PLAIN", + "text", + "text/plain; charset=utf-8", + "text/ plain", + "text//plain", + ] { + assert!( + ContentAddressedReference::for_bytes(media_type, b"x", expires).is_err(), + "{media_type:?}" + ); + } + let mut zero = reference.clone(); + zero.size_bytes = 0; + assert!(zero.validate().is_err()); + let mut oversized = reference.clone(); + oversized.size_bytes = (i64::MAX as u64) + 1; + assert!(oversized.validate().is_err()); + assert!( + reference + .validate_payload_at(b"{}", expires + time::Duration::nanoseconds(1)) + .is_err() + ); + + let bundle: ResultBundle = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + let mut value = serde_json::to_value(&bundle).unwrap(); + value["result"]["expires_at"] = serde_json::json!("2026-08-05T14:05:00.000000001Z"); + assert!(serde_json::from_value::(value).is_err()); + let mut value = serde_json::to_value(&bundle).unwrap(); + value["artifacts"][0]["content"]["expires_at"] = + serde_json::json!("2026-08-05T14:04:00.000000001Z"); + assert!(serde_json::from_value::(value).is_err()); + let mut value = serde_json::to_value(&bundle).unwrap(); + let duplicate = value["artifacts"][0].clone(); + value["artifacts"].as_array_mut().unwrap().push(duplicate); + assert!(serde_json::from_value::(value).is_err()); + + for (pointer, replacement) in [ + ("/artifacts/0/session_id", serde_json::json!("session-2")), + ( + "/artifacts/0/correlation/request_id", + serde_json::json!("req_01J00000000000000000000001"), + ), + ( + "/artifacts/0/correlation/request_digest", + serde_json::json!(format!("sha256:{}", "d".repeat(64))), + ), + ( + "/artifacts/0/correlation/familiar_snapshot_id", + serde_json::json!("ids_01J00000000000000000000001"), + ), + ( + "/artifacts/0/correlation/project_id", + serde_json::json!("project:sha256:def"), + ), + ( + "/artifacts/0/correlation/graph_id", + serde_json::json!("grf_01J00000000000000000000001"), + ), + ( + "/artifacts/0/correlation/node_id", + serde_json::json!("nod_01J00000000000000000000001"), + ), + ( + "/artifacts/0/correlation/attempt_id", + serde_json::json!("att_01J00000000000000000000001"), + ), + ( + "/artifacts/0/correlation/created_at", + serde_json::json!("2026-08-05T14:00:01Z"), + ), + ( + "/artifacts/0/correlation/valid_until", + serde_json::json!("2026-08-05T14:04:59Z"), + ), + ] { + let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + *value.pointer_mut(pointer).unwrap() = replacement; + assert!( + serde_json::from_value::(value).is_err(), + "{pointer}" + ); + } + + for (pointer, replacement) in [ + ("/result/media_type", serde_json::json!("Application/JSON")), + ("/result/size_bytes", serde_json::json!(0)), + ( + "/result/size_bytes", + serde_json::json!((i64::MAX as u64) + 1), + ), + ( + "/artifacts/0/content/media_type", + serde_json::json!("text/plain; charset=utf-8"), + ), + ("/artifacts/0/content/size_bytes", serde_json::json!(0)), + ( + "/artifacts/0/content/size_bytes", + serde_json::json!((i64::MAX as u64) + 1), + ), + ] { + let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + *value.pointer_mut(pointer).unwrap() = replacement; + assert!( + serde_json::from_value::(value).is_err(), + "{pointer}" + ); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PersistenceError { + Unexpected, +} + +#[derive(Default)] +struct RecordingPersistence { + requested_calls: usize, + outcome_calls: usize, +} + +impl TerminationPersistence for RecordingPersistence { + type Error = PersistenceError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.requested_calls += 1; + canonical_bytes(&requested) + .map_err(|_| TerminationPersistenceFailure::Write(PersistenceError::Unexpected)) + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.outcome_calls += 1; + canonical_bytes(&outcome) + .map_err(|_| TerminationPersistenceFailure::Write(PersistenceError::Unexpected)) + } +} + +struct AcknowledgingPort { + calls: AtomicUsize, +} + +impl AcknowledgingPort { + fn new() -> Self { + Self { + calls: AtomicUsize::new(0), + } + } +} + +#[async_trait::async_trait] +impl CovenPort for AcknowledgingPort { + async fn negotiate(&self, _request: NegotiateRequest) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn adopt(&self, _request: AdoptionRequest) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn lookup(&self, _request_id: &RequestId) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn reconcile( + &self, + _request: ReconciliationRequest, + ) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn inspect(&self, _session_id: &str) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn events(&self, _cursor: EventCursor) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn result(&self, _session_id: &str) -> Result { + Err(PortError::UnexpectedCall) + } + + async fn terminate( + &self, + request: TerminationRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let binding = request.binding(); + let correlation = binding.termination_request.as_ref().unwrap(); + Ok(TerminationDisposition::Acknowledged { + evidence: CancellationAcknowledgementEvidence { + acknowledgement_id: "ack-1".to_owned(), + termination_request_id: correlation.termination_request_id.clone(), + session_id: binding.coven_session_id.clone().unwrap(), + execution_request_id: binding.request_id.clone(), + execution_request_digest: binding.request_digest.clone(), + kind: CancellationAcknowledgementKind::Terminated, + authority_evidence_digest: digest_of('d'), + acknowledged_at: correlation.created_at, + }, + }) + } +} + +fn valid_requested() -> ExecutionBinding { + ExecutionBinding { + schema_version: SchemaVersion::parse("psyche.execution_binding.v1").unwrap(), + attempt_id: record_id(RecordKind::Attempt, "01J00000000000000000000000"), + revision: 2, + previous_revision_digest: Some(digest_of('a')), + revision_created_at: at("2026-08-05T14:01:00Z"), + familiar_snapshot_id: record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000000"), + project_id: "project:sha256:abc".to_owned(), + request_id: RequestId::parse("req_01J00000000000000000000000").unwrap(), + request_digest: digest_of('b'), + request_created_at: at("2026-08-05T14:00:00Z"), + request_valid_until: at("2026-08-05T14:05:00Z"), + coven_contract_version: "coven.daemon.v1".to_owned(), + coven_session_id: Some("session-1".to_owned()), + adoption_state: AdoptionState::Adopted, + event_cursor: None, + cancellation_state: CancellationState::TerminationRequested, + termination_request: Some(TerminationRequestCorrelation { + termination_request_id: RequestId::parse("req_01J00000000000000000000001").unwrap(), + created_at: at("2026-08-05T14:01:00Z"), + valid_until: at("2026-08-05T14:03:00Z"), + }), + termination_reason_code: Some("operator_request".to_owned()), + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn acknowledged_binding( + mut value: ExecutionBinding, + kind: CancellationAcknowledgementKind, +) -> ExecutionBinding { + let termination = value.termination_request.as_ref().unwrap(); + let termination_request_id = termination.termination_request_id.clone(); + let acknowledged_at = termination.created_at; + value.cancellation_state = match kind { + CancellationAcknowledgementKind::Terminated => CancellationState::AcknowledgedTerminated, + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal => { + CancellationState::AcknowledgedAlreadyTerminal + } + }; + value.cancellation_acknowledgement = Some(CancellationAcknowledgementEvidence { + acknowledgement_id: "ack-1".to_owned(), + termination_request_id, + session_id: value.coven_session_id.clone().unwrap(), + execution_request_id: value.request_id.clone(), + execution_request_digest: value.request_digest.clone(), + kind, + authority_evidence_digest: digest_of('d'), + acknowledged_at, + }); + value +} + +fn unresolved_binding(mut value: ExecutionBinding) -> ExecutionBinding { + let termination = value.termination_request.as_ref().unwrap(); + let termination_request_id = termination.termination_request_id.clone(); + let recorded_at = termination.created_at; + value.cancellation_state = CancellationState::TerminationUnknown; + value.cancellation_unresolved = Some(CancellationUnresolvedEvidence { + disposition_id: "unresolved-1".to_owned(), + termination_request_id, + session_id: value.coven_session_id.clone().unwrap(), + execution_request_id: value.request_id.clone(), + execution_request_digest: value.request_digest.clone(), + reason_code: "timeout".to_owned(), + recorded_at, + }); + value +} + +#[tokio::test] +async fn termination_dispatch_rejects_invalid_request_before_persistence() { + for accepted in [ + { + let mut value = valid_requested(); + value.termination_request.as_mut().unwrap().created_at = value.request_created_at; + value + }, + { + let mut value = valid_requested(); + value.termination_request.as_mut().unwrap().created_at = + value.request_valid_until + time::Duration::seconds(1); + value.termination_request.as_mut().unwrap().valid_until = + value.request_valid_until + time::Duration::seconds(2); + value + }, + { + let mut value = valid_requested(); + value.termination_reason_code = Some("operator2_request".to_owned()); + value + }, + ] { + let mut persistence = RecordingPersistence::default(); + let port = AcknowledgingPort::new(); + let dyn_port: &dyn CovenPort = &port; + let result = persist_then_terminate(&mut persistence, dyn_port, accepted).await; + assert!(result.is_ok(), "{result:?}"); + assert_eq!(persistence.requested_calls, 1); + assert_eq!(persistence.outcome_calls, 1); + assert_eq!(port.calls.load(Ordering::SeqCst), 1); + } + + let valid = valid_requested(); + let cases: Vec = vec![ + { + let mut value = valid.clone(); + value.coven_session_id = Some(String::new()); + value + }, + { + let mut value = valid.clone(); + value.coven_session_id = Some("s".repeat(256)); + value + }, + { + let mut value = valid.clone(); + value.termination_reason_code = Some(String::new()); + value + }, + { + let mut value = valid.clone(); + value.termination_reason_code = Some("r".repeat(129)); + value + }, + { + let mut value = valid.clone(); + value.termination_reason_code = Some("OperatorRequest".to_owned()); + value + }, + { + let mut value = valid.clone(); + value + .termination_request + .as_mut() + .unwrap() + .termination_request_id = value.request_id.clone(); + value + }, + { + let mut value = valid.clone(); + let termination = value.termination_request.as_mut().unwrap(); + termination.valid_until = termination.created_at; + value + }, + { + let mut value = valid.clone(); + let termination = value.termination_request.as_mut().unwrap(); + termination.valid_until = termination.created_at - time::Duration::nanoseconds(1); + value + }, + { + let mut value = valid.clone(); + value.termination_request.as_mut().unwrap().created_at = + value.request_created_at - time::Duration::nanoseconds(1); + value + }, + { + let mut value = valid.clone(); + value.revision = 1; + value.previous_revision_digest = None; + value + }, + { + let mut value = valid.clone(); + value.cancellation_state = CancellationState::NotRequested; + value.termination_request = None; + value.termination_reason_code = None; + value + }, + acknowledged_binding(valid.clone(), CancellationAcknowledgementKind::Terminated), + acknowledged_binding( + valid.clone(), + CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal, + ), + unresolved_binding(valid), + ]; + + for candidate in cases { + let mut persistence = RecordingPersistence::default(); + let port = AcknowledgingPort::new(); + let result = persist_then_terminate(&mut persistence, &port, candidate).await; + assert!( + matches!(result, Err(TerminationDispatchError::Contract(_))), + "{result:?}" + ); + assert_eq!(persistence.requested_calls, 0); + assert_eq!(persistence.outcome_calls, 0); + assert_eq!(port.calls.load(Ordering::SeqCst), 0); + } + + assert_ne!(digest(&valid_requested()).unwrap(), digest_of('a')); +} diff --git a/crates/psyche-coven/tests/fixtures/execution-request-input.json b/crates/psyche-coven/tests/fixtures/execution-request-input.json new file mode 100644 index 0000000..c252820 --- /dev/null +++ b/crates/psyche-coven/tests/fixtures/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/crates/psyche-coven/tests/fixtures/execution-request-launch.json b/crates/psyche-coven/tests/fixtures/execution-request-launch.json new file mode 100644 index 0000000..63d8061 --- /dev/null +++ b/crates/psyche-coven/tests/fixtures/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/crates/psyche-coven/tests/fixtures/result-bundle.json b/crates/psyche-coven/tests/fixtures/result-bundle.json new file mode 100644 index 0000000..d5823b1 --- /dev/null +++ b/crates/psyche-coven/tests/fixtures/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/crates/psyche-coven/tests/request_digest.rs b/crates/psyche-coven/tests/request_digest.rs new file mode 100644 index 0000000..4259520 --- /dev/null +++ b/crates/psyche-coven/tests/request_digest.rs @@ -0,0 +1,95 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use psyche_core::digest::canonical_bytes; +use psyche_coven::{AdoptionRequest, ExecutionRequestInput}; +use serde::Serialize; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +const LAUNCH_GOLDEN: &[u8] = include_bytes!("fixtures/execution-request-launch.json"); +const INPUT_GOLDEN: &[u8] = include_bytes!("fixtures/execution-request-input.json"); + +#[test] +fn execution_request_launch_matches_golden_bytes_and_digest() { + let decoded: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + decoded.validate().unwrap(); + assert_eq!(canonical_bytes(&decoded).unwrap(), LAUNCH_GOLDEN); + + let request = AdoptionRequest::new(decoded).unwrap(); + assert_eq!( + request.request_digest().as_str(), + "sha256:75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04" + ); + assert_eq!( + request.recompute_digest().unwrap(), + request.request_digest().clone() + ); + + let value: serde_json::Value = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + assert!(value["created_at"].is_string()); + assert!(value["valid_until"].is_string()); + assert_eq!(value["created_at"], "2026-08-05T14:00:00Z"); + assert_eq!(value["valid_until"], "2026-08-05T14:05:00Z"); + + #[derive(Serialize)] + struct Unannotated { + created_at: OffsetDateTime, + } + let unannotated = Unannotated { + created_at: OffsetDateTime::parse("2026-08-05T14:00:00Z", &Rfc3339).unwrap(), + }; + assert_ne!( + canonical_bytes(&unannotated).unwrap(), + br#"{"created_at":"2026-08-05T14:00:00Z"}"# + ); +} + +#[test] +fn execution_request_input_matches_golden_bytes_and_digest() { + let decoded: ExecutionRequestInput = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + decoded.validate().unwrap(); + assert_eq!(canonical_bytes(&decoded).unwrap(), INPUT_GOLDEN); + + let request = AdoptionRequest::new(decoded).unwrap(); + assert_eq!( + request.request_digest().as_str(), + "sha256:c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61" + ); + assert_eq!( + request.recompute_digest().unwrap(), + request.request_digest().clone() + ); + + let value: serde_json::Value = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + assert!(value["created_at"].is_string()); + assert!(value["valid_until"].is_string()); + assert_eq!(value["created_at"], "2026-08-05T14:01:00Z"); + assert_eq!(value["valid_until"], "2026-08-05T14:06:00Z"); +} + +#[test] +fn execution_request_artifact_order_is_digest_bound() { + let mut ordered: serde_json::Value = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + ordered["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "artifact_id": "artifact-2", + "digest": format!("sha256:{}", "a".repeat(64)), + "media_type": "application/json", + "size": 2 + })); + let ordered_request = + AdoptionRequest::new(serde_json::from_value(ordered.clone()).unwrap()).unwrap(); + let mut reversed = ordered; + reversed["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .reverse(); + let reversed_request = AdoptionRequest::new(serde_json::from_value(reversed).unwrap()).unwrap(); + + assert_ne!( + ordered_request.request_digest(), + reversed_request.request_digest() + ); +} diff --git a/crates/psyche-surfaces/Cargo.toml b/crates/psyche-surfaces/Cargo.toml index 89b727f..97c2b2f 100644 --- a/crates/psyche-surfaces/Cargo.toml +++ b/crates/psyche-surfaces/Cargo.toml @@ -7,5 +7,10 @@ license.workspace = true repository.workspace = true publish.workspace = true +[dependencies] +async-trait = { workspace = true } +psyche-core = { workspace = true } +thiserror = { workspace = true } + [lints] workspace = true diff --git a/crates/psyche-surfaces/src/error.rs b/crates/psyche-surfaces/src/error.rs new file mode 100644 index 0000000..21b7f4e --- /dev/null +++ b/crates/psyche-surfaces/src/error.rs @@ -0,0 +1,31 @@ +//! Payload-free failures at a surface behavior boundary. + +/// Stable, redacted surface port failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PortError { + /// A surface event failed its owned contract. + #[error("surface event is invalid")] + InvalidEvent, + /// A surface effect failed its owned contract. + #[error("surface effect is invalid")] + InvalidEffect, + /// A stable identity was reused for different intent. + #[error("surface operation conflicts with durable intent")] + IntentConflict, + /// Surface policy denied the operation. + #[error("surface policy denied the operation")] + PolicyDenied, + /// Delivery outcome is unavailable. + #[error("surface outcome is unavailable")] + Unavailable, + /// A deterministic fake received an unscripted call. + #[error("surface call was not scripted")] + UnexpectedCall, + /// A deterministic test operation reached a scripted stall. + #[error("surface operation stalled")] + Stalled, + /// A surface returned an invalid response. + #[error("surface response is invalid")] + InvalidResponse, +} diff --git a/crates/psyche-surfaces/src/lib.rs b/crates/psyche-surfaces/src/lib.rs index f2c660d..6438c3c 100644 --- a/crates/psyche-surfaces/src/lib.rs +++ b/crates/psyche-surfaces/src/lib.rs @@ -1 +1,7 @@ //! Behavior-level surface acceptance and delivery boundary. + +pub mod error; +pub mod port; + +pub use error::PortError; +pub use port::{DeliveryDisposition, SurfaceAcceptance, SurfacePort}; diff --git a/crates/psyche-surfaces/src/port.rs b/crates/psyche-surfaces/src/port.rs new file mode 100644 index 0000000..a2ceff5 --- /dev/null +++ b/crates/psyche-surfaces/src/port.rs @@ -0,0 +1,69 @@ +//! Typed surface acceptance and delivery operations. + +use psyche_core::contracts::RecordKind; +use psyche_core::contracts::surface::{SurfaceEffect, SurfaceEvent}; +use psyche_core::id::RecordId; + +use crate::PortError; + +/// Durable acceptance of one normalized surface event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceAcceptance { + /// Exact accepted event identity. + pub surface_event_id: RecordId, + /// Whether the event entered the surface-neutral pipeline. + pub accepted: bool, +} + +impl SurfaceAcceptance { + /// Validates the accepted event identity. + pub fn validate(&self) -> Result<(), PortError> { + if self.surface_event_id.kind() == RecordKind::SurfaceEvent { + Ok(()) + } else { + Err(PortError::InvalidResponse) + } + } +} + +/// Durable delivery outcome for one surface effect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeliveryDisposition { + /// The effect was durably applied. + Applied { + /// Stable external delivery identity. + external_id: String, + }, + /// The effect was authoritatively rejected. + Rejected { + /// Stable bounded rejection code. + code: String, + }, + /// Delivery may have occurred and cannot be retried as rejected. + Unknown, +} + +impl DeliveryDisposition { + /// Validates bounded opaque response fields. + pub fn validate(&self) -> Result<(), PortError> { + let value = match self { + Self::Applied { external_id } => Some(external_id.as_str()), + Self::Rejected { code } => Some(code.as_str()), + Self::Unknown => None, + }; + if value.is_some_and(|value| value.is_empty() || value.len() > 255) { + Err(PortError::InvalidResponse) + } else { + Ok(()) + } + } +} + +/// Behavior-level surface acceptance and delivery boundary. +#[async_trait::async_trait] +pub trait SurfacePort: Send + Sync { + /// Accepts a validated surface event. + async fn accept(&self, event: SurfaceEvent) -> Result; + /// Applies a validated surface effect. + async fn apply(&self, effect: SurfaceEffect) -> Result; +} diff --git a/crates/psyche-test-support/Cargo.toml b/crates/psyche-test-support/Cargo.toml index 1701563..25f693f 100644 --- a/crates/psyche-test-support/Cargo.toml +++ b/crates/psyche-test-support/Cargo.toml @@ -7,5 +7,19 @@ license.workspace = true repository.workspace = true publish = false +[dependencies] +async-trait = { workspace = true } +psyche-core = { workspace = true } +psyche-coven = { workspace = true } +psyche-store = { workspace = true } +psyche-surfaces = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } + [lints] workspace = true diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs new file mode 100644 index 0000000..0cae5aa --- /dev/null +++ b/crates/psyche-test-support/src/coven.rs @@ -0,0 +1,1091 @@ +//! Deterministic Coven scripts and Store-backed termination persistence. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fmt; +use std::sync::{Arc, Mutex}; + +use psyche_core::contracts::CanonicalDocument; +use psyche_core::contracts::execution::{CancellationState, ExecutionBinding}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::RequestId; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, Capability, CapabilityProfile, CovenPort, EventCursor, + EventPage, ExecutionCorrelation, NegotiateRequest, PortError, ReconciliationDisposition, + ReconciliationRequest, ResultBundle, SessionSnapshot, TerminationDisposition, + TerminationPersistence, TerminationPersistenceFailure, TerminationRequest, +}; +use psyche_store::{Store, StoreError}; + +/// Redacted Coven operation identity used by scripts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FakeOperation { + /// Contract negotiation. + Negotiate, + /// Stable request adoption. + Adopt, + /// Durable adoption lookup. + Lookup, + /// Ambiguity reconciliation. + Reconcile, + /// Session inspection. + Inspect, + /// Ordered event read. + Events, + /// Result metadata read. + Result, + /// Authoritative termination. + Terminate, +} + +/// Redacted call observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FakeCall { + /// Contract negotiation. + Negotiate, + /// Stable request adoption. + Adopt, + /// Durable adoption lookup. + Lookup, + /// Ambiguity reconciliation. + Reconcile, + /// Session inspection. + Inspect, + /// Ordered event read. + Events, + /// Result metadata read. + Result, + /// Authoritative termination. + Terminate, +} + +impl From for FakeCall { + fn from(value: FakeOperation) -> Self { + match value { + FakeOperation::Negotiate => Self::Negotiate, + FakeOperation::Adopt => Self::Adopt, + FakeOperation::Lookup => Self::Lookup, + FakeOperation::Reconcile => Self::Reconcile, + FakeOperation::Inspect => Self::Inspect, + FakeOperation::Events => Self::Events, + FakeOperation::Result => Self::Result, + FakeOperation::Terminate => Self::Terminate, + } + } +} + +/// Typed response carried by a successful fake script step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CovenScriptReturn { + /// Negotiated capability profile. + Negotiate(CapabilityProfile), + /// Adoption disposition. + Adopt(AdoptionDisposition), + /// Lookup disposition. + Lookup(AdoptionDisposition), + /// Reconciliation disposition. + Reconcile(ReconciliationDisposition), + /// Session snapshot. + Inspect(SessionSnapshot), + /// Event page. + Events(EventPage), + /// Result bundle. + Result(ResultBundle), + /// Termination disposition. + Terminate(TerminationDisposition), +} + +impl CovenScriptReturn { + fn operation(&self) -> FakeOperation { + match self { + Self::Negotiate(_) => FakeOperation::Negotiate, + Self::Adopt(_) => FakeOperation::Adopt, + Self::Lookup(_) => FakeOperation::Lookup, + Self::Reconcile(_) => FakeOperation::Reconcile, + Self::Inspect(_) => FakeOperation::Inspect, + Self::Events(_) => FakeOperation::Events, + Self::Result(_) => FakeOperation::Result, + Self::Terminate(_) => FakeOperation::Terminate, + } + } +} + +/// One deterministic fake outcome or fault. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CovenScriptStep { + /// Returns one typed response. + Return(CovenScriptReturn), + /// Returns one stable payload-free error. + Error { + /// Expected operation. + operation: FakeOperation, + /// Error returned by the operation. + error: PortError, + }, + /// Simulates a disconnect immediately before durable fake state changes. + DisconnectBeforeCommit(FakeOperation), + /// Commits the carried response and then simulates a lost reply. + DisconnectAfterCommit(CovenScriptReturn), + /// Deliberately returns a response that conflicts with a durable replay. + ConflictingReplay(CovenScriptReturn), + /// Leaves durable state unchanged and returns a deterministic stalled error. + Stall(FakeOperation), +} + +impl CovenScriptStep { + fn operation(&self) -> FakeOperation { + match self { + Self::Return(response) + | Self::DisconnectAfterCommit(response) + | Self::ConflictingReplay(response) => response.operation(), + Self::Error { operation, .. } + | Self::DisconnectBeforeCommit(operation) + | Self::Stall(operation) => *operation, + } + } +} + +/// Fake construction failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum FakeBuildError { + /// A capability was advertised without any matching script step. + #[error("advertised capability has no scripted behavior")] + UnscriptedCapability { + /// Typed capability missing a script. + capability: Capability, + }, + /// A configured contract or script is invalid. + #[error("fake Coven configuration is invalid")] + InvalidConfiguration, +} + +/// Payload-free fake runtime failures use the same behavior error contract. +pub type FakeError = PortError; + +/// Deterministic assertion invoked after request persistence and before response. +pub type BeforeTerminate = + Arc Result<(), PortError> + Send + Sync + 'static>; + +#[derive(Default)] +struct FakeState { + script: VecDeque, + calls: Vec, + adoptions: BTreeMap, AdoptionDisposition)>, + sessions: BTreeMap>, + reconciliations: BTreeMap, + results: BTreeMap, + terminations: BTreeMap, +} + +/// Honest, deterministic, thread-safe Coven fake. +#[derive(Clone)] +pub struct FakeCoven { + contract: String, + capabilities: BTreeSet, + current_time: time::OffsetDateTime, + state: Arc>, + before_terminate: Option, +} + +impl fmt::Debug for FakeCoven { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("FakeCoven") + } +} + +impl FakeCoven { + /// Begins deterministic fake construction. + pub fn builder() -> FakeCovenBuilder { + FakeCovenBuilder::default() + } + + /// Returns a redacted call log. + pub fn calls(&self) -> Vec { + self.state + .lock() + .map(|state| state.calls.clone()) + .unwrap_or_default() + } + + /// Number of script steps not yet consumed. + pub fn remaining_steps(&self) -> usize { + self.state + .lock() + .map(|state| state.script.len()) + .unwrap_or_default() + } + + /// Simulates process restart while preserving only fake-owned durable state. + pub fn restart(&self) -> Self { + Self { + contract: self.contract.clone(), + capabilities: self.capabilities.clone(), + current_time: self.current_time, + state: Arc::clone(&self.state), + before_terminate: self.before_terminate.clone(), + } + } + + /// Returns a restarted view with a new deterministic current time. + pub fn at_time(&self, current_time: time::OffsetDateTime) -> Self { + Self { + contract: self.contract.clone(), + capabilities: self.capabilities.clone(), + current_time, + state: Arc::clone(&self.state), + before_terminate: self.before_terminate.clone(), + } + } + + fn record(&self, operation: FakeOperation) -> Result<(), PortError> { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.calls.push(operation.into()); + Ok(()) + } + + fn take(&self, operation: FakeOperation) -> Result { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.calls.push(operation.into()); + let Some(step) = state.script.front() else { + return Err(PortError::UnexpectedCall); + }; + if step.operation() != operation { + return Err(PortError::UnexpectedCall); + } + state.script.pop_front().ok_or(PortError::UnexpectedCall) + } + + fn store_adoption( + &self, + request: &AdoptionRequest, + disposition: &AdoptionDisposition, + ) -> Result { + disposition.validate()?; + let correlation = request.correlation(); + let bytes = canonical_bytes(request.input()).map_err(PortError::from)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let key = correlation.request_id.as_str().to_owned(); + if let Some((stored_digest, stored_bytes, stored_disposition)) = state.adoptions.get(&key) { + if stored_digest == request.request_digest() && stored_bytes == &bytes { + return Ok(stored_disposition.clone()); + } + return Err(PortError::IntentConflict); + } + if let AdoptionDisposition::Adopted { session_id } = disposition { + let correlation = request.correlation(); + let correlations = state.sessions.entry(session_id.clone()).or_default(); + if !correlations.contains(&correlation) { + correlations.push(correlation); + } + } + state.adoptions.insert( + key, + (request.request_digest().clone(), bytes, disposition.clone()), + ); + Ok(disposition.clone()) + } + + fn replay_adoption( + &self, + request: &AdoptionRequest, + ) -> Result, PortError> { + let correlation = request.correlation(); + let bytes = canonical_bytes(request.input()).map_err(PortError::from)?; + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let Some((stored_digest, stored_bytes, stored_disposition)) = + state.adoptions.get(correlation.request_id.as_str()) + else { + return Ok(None); + }; + if stored_digest == request.request_digest() && stored_bytes == &bytes { + Ok(Some(stored_disposition.clone())) + } else { + Err(PortError::IntentConflict) + } + } + + fn lookup_adoption( + &self, + request_id: &RequestId, + scripted: &AdoptionDisposition, + ) -> Result { + scripted.validate()?; + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some((_, _, stored)) = state.adoptions.get(request_id.as_str()) { + if stored == scripted { + Ok(stored.clone()) + } else { + Err(PortError::IntentConflict) + } + } else { + Ok(scripted.clone()) + } + } + + fn store_reconciliation( + &self, + request: &ReconciliationRequest, + disposition: &ReconciliationDisposition, + ) -> Result { + let key = request.correlation.request_id.as_str().to_owned(); + { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some((stored_request, stored_disposition)) = state.reconciliations.get(&key) { + return if stored_request == request && stored_disposition == disposition { + Ok(stored_disposition.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + } + disposition.validate_for(request)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some((stored_request, stored_disposition)) = state.reconciliations.get(&key) { + if stored_request == request && stored_disposition == disposition { + return Ok(stored_disposition.clone()); + } + return Err(PortError::IntentConflict); + } + state + .reconciliations + .insert(key, (request.clone(), disposition.clone())); + Ok(disposition.clone()) + } + + fn replay_reconciliation( + &self, + request: &ReconciliationRequest, + ) -> Result, PortError> { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let Some((stored_request, stored_disposition)) = state + .reconciliations + .get(request.correlation.request_id.as_str()) + else { + return Ok(None); + }; + if stored_request == request { + Ok(Some(stored_disposition.clone())) + } else { + Err(PortError::IntentConflict) + } + } + + fn store_result(&self, bundle: ResultBundle) -> Result { + bundle.validate().map_err(|_| PortError::InvalidResponse)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if !state.sessions.is_empty() + && !state + .sessions + .get(&bundle.session_id) + .is_some_and(|correlations| correlations.contains(&bundle.correlation)) + { + return Err(PortError::CorrelationMismatch); + } + if let Some(stored) = state.results.get(&bundle.session_id) { + return if stored == &bundle { + Ok(stored.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + state + .results + .insert(bundle.session_id.clone(), bundle.clone()); + Ok(bundle) + } + + fn replay_result(&self, session_id: &str) -> Result, PortError> { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + Ok(state.results.get(session_id).cloned()) + } + + fn validate_session_correlation( + &self, + session_id: &str, + correlation: &ExecutionCorrelation, + ) -> Result<(), PortError> { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if !state.sessions.is_empty() + && !state + .sessions + .get(session_id) + .is_some_and(|stored| stored.contains(correlation)) + { + Err(PortError::CorrelationMismatch) + } else { + Ok(()) + } + } + + fn termination_key(request: &TerminationRequest) -> Result { + request + .binding() + .termination_request + .as_ref() + .map(|correlation| correlation.termination_request_id.as_str().to_owned()) + .ok_or(PortError::InvalidRequest) + } + + fn store_termination( + &self, + key: String, + disposition: TerminationDisposition, + ) -> Result { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some(stored) = state.terminations.get(&key) { + return if stored == &disposition { + Ok(stored.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + state.terminations.insert(key, disposition.clone()); + Ok(disposition) + } + + fn replay_termination(&self, key: &str) -> Result, PortError> { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + Ok(state.terminations.get(key).cloned()) + } + + fn scripted_negotiation(&self) -> Result, PortError> { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if state + .script + .front() + .is_some_and(|step| step.operation() == FakeOperation::Negotiate) + { + state + .script + .pop_front() + .map(Some) + .ok_or(PortError::UnexpectedCall) + } else { + Ok(None) + } + } +} + +/// Builder for [`FakeCoven`]. +pub struct FakeCovenBuilder { + contract: String, + capabilities: BTreeSet, + current_time: time::OffsetDateTime, + script: VecDeque, + before_terminate: Option, +} + +impl Default for FakeCovenBuilder { + fn default() -> Self { + Self { + contract: "coven.daemon.v1".to_owned(), + capabilities: BTreeSet::new(), + current_time: time::OffsetDateTime::UNIX_EPOCH, + script: VecDeque::new(), + before_terminate: None, + } + } +} + +impl fmt::Debug for FakeCovenBuilder { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("FakeCovenBuilder") + } +} + +impl FakeCovenBuilder { + /// Sets the exact negotiated contract. + #[must_use] + pub fn contract(mut self, contract: impl Into) -> Self { + self.contract = contract.into(); + self + } + + /// Advertises one capability, which must have a matching script. + #[must_use] + pub fn capability(mut self, capability: Capability) -> Self { + self.capabilities.insert(capability); + self + } + + /// Sets the deterministic clock used for expiration checks. + #[must_use] + pub fn current_time(mut self, current_time: time::OffsetDateTime) -> Self { + self.current_time = current_time; + self + } + + /// Replaces the script with an explicit ordered queue. + #[must_use] + pub fn script(mut self, script: VecDeque) -> Self { + self.script = script; + self + } + + /// Appends one explicit script step. + #[must_use] + pub fn step(mut self, step: CovenScriptStep) -> Self { + self.script.push_back(step); + self + } + + /// Scripts one adoption response. + #[must_use] + pub fn adoption(mut self, disposition: AdoptionDisposition) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Adopt( + disposition, + ))); + self + } + + /// Scripts one lookup response. + #[must_use] + pub fn lookup(mut self, disposition: AdoptionDisposition) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Lookup( + disposition, + ))); + self + } + + /// Scripts one reconciliation response. + #[must_use] + pub fn reconciliation(mut self, disposition: ReconciliationDisposition) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Reconcile( + disposition, + ))); + self + } + + /// Scripts one session snapshot. + #[must_use] + pub fn snapshot(mut self, snapshot: SessionSnapshot) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Inspect( + snapshot, + ))); + self + } + + /// Scripts one event page. + #[must_use] + pub fn event_page(mut self, page: EventPage) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Events(page))); + self + } + + /// Scripts one result bundle. + #[must_use] + pub fn result(mut self, bundle: ResultBundle) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Result(bundle))); + self + } + + /// Scripts one acknowledged termination response. + #[must_use] + pub fn acknowledge_termination( + mut self, + evidence: psyche_core::contracts::execution::CancellationAcknowledgementEvidence, + ) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Terminate( + TerminationDisposition::Acknowledged { evidence }, + ))); + self + } + + /// Scripts one unresolved termination response. + #[must_use] + pub fn unresolved_termination( + mut self, + evidence: psyche_core::contracts::execution::CancellationUnresolvedEvidence, + ) -> Self { + self.script + .push_back(CovenScriptStep::Return(CovenScriptReturn::Terminate( + TerminationDisposition::Unresolved { evidence }, + ))); + self + } + + /// Scripts a deliberately divergent response for coordinator conflict tests. + #[must_use] + pub fn conflicting_termination(mut self, disposition: TerminationDisposition) -> Self { + self.script.push_back(CovenScriptStep::ConflictingReplay( + CovenScriptReturn::Terminate(disposition), + )); + self + } + + /// Installs a deterministic assertion immediately before terminate returns. + #[must_use] + pub fn before_terminate(mut self, assertion: BeforeTerminate) -> Self { + self.before_terminate = Some(assertion); + self + } + + /// Validates honesty and constructs the fake. + pub fn build(self) -> Result { + let negotiation = NegotiateRequest::new(self.contract.clone()); + if negotiation.validate().is_err() { + return Err(FakeBuildError::InvalidConfiguration); + } + if self.current_time.offset() != time::UtcOffset::UTC { + return Err(FakeBuildError::InvalidConfiguration); + } + for capability in &self.capabilities { + let operation = match capability { + Capability::StableAdoption => FakeOperation::Adopt, + Capability::AmbiguityFence => FakeOperation::Reconcile, + Capability::OrderedEvents => FakeOperation::Events, + Capability::AuthoritativeTermination => FakeOperation::Terminate, + Capability::ContentAddressedResults => FakeOperation::Result, + }; + if !self.script.iter().any(|step| step.operation() == operation) { + return Err(FakeBuildError::UnscriptedCapability { + capability: *capability, + }); + } + } + Ok(FakeCoven { + contract: self.contract, + capabilities: self + .capabilities + .into_iter() + .map(|capability| capability.as_str().to_owned()) + .collect(), + current_time: self.current_time, + state: Arc::new(Mutex::new(FakeState { + script: self.script, + ..FakeState::default() + })), + before_terminate: self.before_terminate, + }) + } +} + +#[async_trait::async_trait] +impl CovenPort for FakeCoven { + async fn negotiate(&self, request: NegotiateRequest) -> Result { + request.validate()?; + self.record(FakeOperation::Negotiate)?; + if request.required_api_version != self.contract { + return Err(PortError::ContractUnsupported {}); + } + if !request.required_capabilities.is_subset(&self.capabilities) { + return Err(PortError::CapabilityMissing {}); + } + let configured = CapabilityProfile { + api_version: self.contract.clone(), + capabilities: self.capabilities.clone(), + }; + let Some(step) = self.scripted_negotiation()? else { + return Ok(configured); + }; + match step { + CovenScriptStep::Return(CovenScriptReturn::Negotiate(profile)) => { + profile.validate().map_err(|_| PortError::InvalidResponse)?; + if profile == configured { + Ok(profile) + } else { + Err(PortError::InvalidResponse) + } + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) + | CovenScriptStep::DisconnectAfterCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn adopt(&self, request: AdoptionRequest) -> Result { + request.validate_digest()?; + if let Some(disposition) = self.replay_adoption(&request)? { + self.record(FakeOperation::Adopt)?; + return Ok(disposition); + } + if self.current_time > request.correlation().valid_until { + return Err(PortError::InvalidRequest); + } + match self.take(FakeOperation::Adopt)? { + CovenScriptStep::Return(CovenScriptReturn::Adopt(disposition)) => { + self.store_adoption(&request, &disposition) + } + CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Adopt(disposition)) => { + self.store_adoption(&request, &disposition)?; + Err(PortError::Unavailable) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + CovenScriptStep::ConflictingReplay(_) => Err(PortError::UnexpectedCall), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn lookup(&self, request_id: &RequestId) -> Result { + match self.take(FakeOperation::Lookup)? { + CovenScriptStep::Return(CovenScriptReturn::Lookup(disposition)) => { + self.lookup_adoption(request_id, &disposition) + } + CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Lookup(disposition)) => { + self.lookup_adoption(request_id, &disposition)?; + Err(PortError::Unavailable) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn reconcile( + &self, + request: ReconciliationRequest, + ) -> Result { + request.validate()?; + if let Some(disposition) = self.replay_reconciliation(&request)? { + self.record(FakeOperation::Reconcile)?; + return Ok(disposition); + } + match self.take(FakeOperation::Reconcile)? { + CovenScriptStep::Return(CovenScriptReturn::Reconcile(disposition)) => { + self.store_reconciliation(&request, &disposition) + } + CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Reconcile(disposition)) => { + self.store_reconciliation(&request, &disposition)?; + Err(PortError::Unavailable) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn inspect(&self, session_id: &str) -> Result { + if session_id.is_empty() || session_id.len() > 255 { + return Err(PortError::InvalidRequest); + } + match self.take(FakeOperation::Inspect)? { + CovenScriptStep::Return(CovenScriptReturn::Inspect(snapshot)) => { + snapshot.validate()?; + if snapshot.session_id == session_id { + self.validate_session_correlation(session_id, &snapshot.correlation)?; + Ok(snapshot) + } else { + Err(PortError::CorrelationMismatch) + } + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) + | CovenScriptStep::DisconnectAfterCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn events(&self, cursor: EventCursor) -> Result { + cursor.validate()?; + match self.take(FakeOperation::Events)? { + CovenScriptStep::Return(CovenScriptReturn::Events(page)) => { + page.validate_for(&cursor)?; + Ok(page) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) + | CovenScriptStep::DisconnectAfterCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn result(&self, session_id: &str) -> Result { + if session_id.is_empty() || session_id.len() > 255 { + return Err(PortError::InvalidRequest); + } + if let Some(bundle) = self.replay_result(session_id)? { + self.record(FakeOperation::Result)?; + return Ok(bundle); + } + match self.take(FakeOperation::Result)? { + CovenScriptStep::Return(CovenScriptReturn::Result(bundle)) => { + if bundle.session_id == session_id { + self.store_result(bundle) + } else { + Err(PortError::CorrelationMismatch) + } + } + CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Result(bundle)) => { + if bundle.session_id != session_id { + return Err(PortError::CorrelationMismatch); + } + self.store_result(bundle)?; + Err(PortError::Unavailable) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn terminate( + &self, + request: TerminationRequest, + ) -> Result { + let key = Self::termination_key(&request)?; + if let Some(stored) = self.replay_termination(&key)? { + let scripted = { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + match state.script.front() { + Some(CovenScriptStep::ConflictingReplay(CovenScriptReturn::Terminate(_))) + | Some(CovenScriptStep::Return(CovenScriptReturn::Terminate(_))) => { + state.calls.push(FakeCall::Terminate); + state.script.pop_front() + } + _ => None, + } + }; + if let Some(assertion) = &self.before_terminate { + assertion(&request)?; + } + return match scripted { + Some(CovenScriptStep::ConflictingReplay(CovenScriptReturn::Terminate( + disposition, + ))) => Ok(disposition), + Some(CovenScriptStep::Return(CovenScriptReturn::Terminate(disposition))) => { + if disposition == stored { + Ok(stored) + } else { + Err(PortError::IntentConflict) + } + } + Some(_) => Err(PortError::UnexpectedCall), + None => { + self.record(FakeOperation::Terminate)?; + Ok(stored) + } + }; + } + let step = self.take(FakeOperation::Terminate)?; + if let CovenScriptStep::ConflictingReplay(_) = step { + return Err(PortError::UnexpectedCall); + } + if let Some(assertion) = &self.before_terminate { + assertion(&request)?; + } + match step { + CovenScriptStep::Return(CovenScriptReturn::Terminate(disposition)) => { + self.store_termination(key, disposition) + } + CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Terminate(disposition)) => { + self.store_termination(key, disposition)?; + Err(PortError::Unavailable) + } + CovenScriptStep::Error { error, .. } => Err(error), + CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + CovenScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } +} + +/// Real Store-backed implementation of the narrow termination persistence port. +#[derive(Debug)] +pub struct StoreTerminationPersistence { + store: Store, +} + +impl StoreTerminationPersistence { + /// Wraps an already-open durable Store. + pub fn new(store: Store) -> Self { + Self { store } + } + + /// Returns the wrapped Store. + pub fn into_inner(self) -> Store { + self.store + } + + fn persist( + &mut self, + candidate: ExecutionBinding, + phase: PersistencePhase, + ) -> Result, TerminationPersistenceFailure> { + let expected_bytes = canonical_bytes(&candidate).map_err(|error| { + TerminationPersistenceFailure::Conflict(StoreError::Contract(error)) + })?; + let history = self + .store + .execution_binding_revisions(&candidate.attempt_id) + .map_err(classify_store_error)?; + + if let Some(existing) = history + .iter() + .find(|existing| existing.revision == candidate.revision) + { + let existing_bytes = canonical_bytes(existing).map_err(|error| { + TerminationPersistenceFailure::Write(StoreError::Contract(error)) + })?; + return if existing_bytes == expected_bytes { + validate_persistence_predecessor(&history, &candidate, phase)?; + Ok(existing_bytes) + } else { + Err(TerminationPersistenceFailure::Conflict( + StoreError::ExecutionBindingRevisionConflict { + attempt_id: candidate.attempt_id.clone(), + revision: candidate.revision, + }, + )) + }; + } + + let Some(predecessor) = history.last() else { + return Err(revision_conflict(&candidate)); + }; + if candidate.revision != predecessor.revision.saturating_add(1) { + return Err(revision_conflict(&candidate)); + } + validate_persistence_predecessor(&history, &candidate, phase)?; + + self.store + .insert(&CanonicalDocument::ExecutionBinding(candidate.clone())) + .map_err(classify_store_error)?; + let committed = self + .store + .execution_binding_revisions(&candidate.attempt_id) + .map_err(classify_store_error)?; + let Some(committed) = committed + .iter() + .find(|binding| binding.revision == candidate.revision) + else { + return Err(TerminationPersistenceFailure::Write( + StoreError::DatabaseOperation, + )); + }; + let committed_bytes = canonical_bytes(committed) + .map_err(|error| TerminationPersistenceFailure::Write(StoreError::Contract(error)))?; + if committed_bytes == expected_bytes { + Ok(committed_bytes) + } else { + Err(revision_conflict(&candidate)) + } + } +} + +impl TerminationPersistence for StoreTerminationPersistence { + type Error = StoreError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.persist(requested, PersistencePhase::Requested) + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.persist(outcome, PersistencePhase::Outcome) + } +} + +#[derive(Debug, Clone, Copy)] +enum PersistencePhase { + Requested, + Outcome, +} + +fn validate_persistence_predecessor( + history: &[ExecutionBinding], + candidate: &ExecutionBinding, + phase: PersistencePhase, +) -> Result<(), TerminationPersistenceFailure> { + let Some(predecessor_revision) = candidate.revision.checked_sub(1) else { + return Err(revision_conflict(candidate)); + }; + let Some(predecessor) = history + .iter() + .find(|binding| binding.revision == predecessor_revision) + else { + return Err(revision_conflict(candidate)); + }; + if candidate.previous_revision_digest.as_ref() + != Some( + &digest(predecessor).map_err(|error| { + TerminationPersistenceFailure::Write(StoreError::Contract(error)) + })?, + ) + || !frozen_execution_fields_match(predecessor, candidate) + || predecessor + .coven_session_id + .as_deref() + .is_none_or(str::is_empty) + || predecessor.coven_session_id != candidate.coven_session_id + { + return Err(revision_conflict(candidate)); + } + match phase { + PersistencePhase::Requested => { + if candidate.cancellation_state != CancellationState::TerminationRequested + || predecessor.cancellation_state != CancellationState::NotRequested + { + return Err(revision_conflict(candidate)); + } + } + PersistencePhase::Outcome => { + if predecessor.cancellation_state != CancellationState::TerminationRequested + || !matches!( + candidate.cancellation_state, + CancellationState::AcknowledgedTerminated + | CancellationState::AcknowledgedAlreadyTerminal + | CancellationState::TerminationUnknown + ) + || predecessor.termination_request != candidate.termination_request + || predecessor.termination_reason_code != candidate.termination_reason_code + { + return Err(revision_conflict(candidate)); + } + } + } + Ok(()) +} + +fn frozen_execution_fields_match( + previous: &ExecutionBinding, + candidate: &ExecutionBinding, +) -> bool { + previous.attempt_id == candidate.attempt_id + && previous.familiar_snapshot_id == candidate.familiar_snapshot_id + && previous.project_id == candidate.project_id + && previous.request_id == candidate.request_id + && previous.request_digest == candidate.request_digest + && timestamp_exact(previous.request_created_at, candidate.request_created_at) + && timestamp_exact(previous.request_valid_until, candidate.request_valid_until) + && previous.coven_contract_version == candidate.coven_contract_version +} + +fn timestamp_exact(previous: time::OffsetDateTime, candidate: time::OffsetDateTime) -> bool { + previous == candidate && previous.offset() == candidate.offset() +} + +fn revision_conflict(candidate: &ExecutionBinding) -> TerminationPersistenceFailure { + TerminationPersistenceFailure::Conflict(StoreError::ExecutionBindingRevisionConflict { + attempt_id: candidate.attempt_id.clone(), + revision: candidate.revision, + }) +} + +fn classify_store_error(error: StoreError) -> TerminationPersistenceFailure { + match error { + error @ (StoreError::ExecutionBindingRevisionConflict { .. } | StoreError::Contract(_)) => { + TerminationPersistenceFailure::Conflict(error) + } + error => TerminationPersistenceFailure::Write(error), + } +} diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs index def9f95..4e5c63f 100644 --- a/crates/psyche-test-support/src/lib.rs +++ b/crates/psyche-test-support/src/lib.rs @@ -1 +1,13 @@ //! Deterministic fakes and reusable Psyche conformance fixtures. + +pub mod coven; +pub mod surface; + +pub use coven::{ + BeforeTerminate, CovenScriptReturn, CovenScriptStep, FakeBuildError, FakeCall, FakeCoven, + FakeCovenBuilder, FakeError, FakeOperation, StoreTerminationPersistence, +}; +pub use surface::{ + FakeSurface, FakeSurfaceBuilder, SurfaceFakeBuildError, SurfaceFakeCall, SurfaceScriptReturn, + SurfaceScriptStep, +}; diff --git a/crates/psyche-test-support/src/surface.rs b/crates/psyche-test-support/src/surface.rs new file mode 100644 index 0000000..619f3d7 --- /dev/null +++ b/crates/psyche-test-support/src/surface.rs @@ -0,0 +1,303 @@ +//! Deterministic scripted surface fake. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use psyche_core::contracts::surface::{SurfaceEffect, SurfaceEvent}; +use psyche_core::digest::canonical_bytes; +use psyche_surfaces::{DeliveryDisposition, PortError, SurfaceAcceptance, SurfacePort}; + +/// Redacted surface call observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceFakeCall { + /// Event acceptance. + Accept, + /// Effect application. + Apply, +} + +/// Typed successful surface response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SurfaceScriptReturn { + /// Acceptance response. + Accept(SurfaceAcceptance), + /// Delivery response. + Apply(DeliveryDisposition), +} + +impl SurfaceScriptReturn { + fn call(&self) -> SurfaceFakeCall { + match self { + Self::Accept(_) => SurfaceFakeCall::Accept, + Self::Apply(_) => SurfaceFakeCall::Apply, + } + } +} + +/// One deterministic surface response or fault. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SurfaceScriptStep { + /// Returns one typed response. + Return(SurfaceScriptReturn), + /// Returns one stable error. + Error { + /// Expected call. + call: SurfaceFakeCall, + /// Stable error. + error: PortError, + }, + /// Disconnects before durable mutation. + DisconnectBeforeCommit(SurfaceFakeCall), + /// Commits a response then loses the reply. + DisconnectAfterCommit(SurfaceScriptReturn), + /// Leaves state unchanged and reports a deterministic stall. + Stall(SurfaceFakeCall), +} + +impl SurfaceScriptStep { + fn call(&self) -> SurfaceFakeCall { + match self { + Self::Return(response) | Self::DisconnectAfterCommit(response) => response.call(), + Self::Error { call, .. } | Self::DisconnectBeforeCommit(call) | Self::Stall(call) => { + *call + } + } + } +} + +/// Invalid fake surface construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SurfaceFakeBuildError { + /// No behavior was scripted. + #[error("surface fake has no scripted behavior")] + EmptyScript, +} + +#[derive(Debug, Default)] +struct SurfaceState { + script: VecDeque, + calls: Vec, + acceptances: BTreeMap, SurfaceAcceptance)>, + deliveries: BTreeMap, DeliveryDisposition)>, +} + +/// Honest deterministic surface fake. +#[derive(Debug, Clone)] +pub struct FakeSurface { + state: Arc>, +} + +impl FakeSurface { + /// Begins fake construction. + pub fn builder() -> FakeSurfaceBuilder { + FakeSurfaceBuilder::default() + } + + /// Returns redacted calls. + pub fn calls(&self) -> Vec { + self.state + .lock() + .map(|state| state.calls.clone()) + .unwrap_or_default() + } + + /// Simulates process restart while preserving fake-owned durable outcomes. + pub fn restart(&self) -> Self { + self.clone() + } + + fn record_replay(&self, call: SurfaceFakeCall) -> Result<(), PortError> { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.calls.push(call); + Ok(()) + } + + fn take(&self, call: SurfaceFakeCall) -> Result { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.calls.push(call); + let Some(step) = state.script.front() else { + return Err(PortError::UnexpectedCall); + }; + if step.call() != call { + return Err(PortError::UnexpectedCall); + } + state.script.pop_front().ok_or(PortError::UnexpectedCall) + } + + fn replay_acceptance( + &self, + event: &SurfaceEvent, + ) -> Result, PortError> { + let bytes = canonical_bytes(event).map_err(|_| PortError::InvalidEvent)?; + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let Some((stored_bytes, acceptance)) = + state.acceptances.get(event.surface_event_id.as_str()) + else { + return Ok(None); + }; + if stored_bytes == &bytes { + Ok(Some(acceptance.clone())) + } else { + Err(PortError::IntentConflict) + } + } + + fn commit_acceptance( + &self, + event: &SurfaceEvent, + acceptance: SurfaceAcceptance, + ) -> Result<(), PortError> { + acceptance.validate()?; + if acceptance.surface_event_id != event.surface_event_id { + return Err(PortError::InvalidResponse); + } + let bytes = canonical_bytes(event).map_err(|_| PortError::InvalidEvent)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.acceptances.insert( + event.surface_event_id.as_str().to_owned(), + (bytes, acceptance), + ); + Ok(()) + } + + fn replay_delivery( + &self, + effect: &SurfaceEffect, + ) -> Result, PortError> { + let bytes = canonical_bytes(effect).map_err(|_| PortError::InvalidEffect)?; + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let Some((stored_bytes, disposition)) = + state.deliveries.get(effect.surface_effect_id.as_str()) + else { + return Ok(None); + }; + if stored_bytes == &bytes { + Ok(Some(disposition.clone())) + } else { + Err(PortError::IntentConflict) + } + } + + fn commit_delivery( + &self, + effect: &SurfaceEffect, + disposition: DeliveryDisposition, + ) -> Result<(), PortError> { + disposition.validate()?; + let bytes = canonical_bytes(effect).map_err(|_| PortError::InvalidEffect)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + state.deliveries.insert( + effect.surface_effect_id.as_str().to_owned(), + (bytes, disposition), + ); + Ok(()) + } +} + +/// Builder for [`FakeSurface`]. +#[derive(Debug, Default)] +pub struct FakeSurfaceBuilder { + script: VecDeque, +} + +impl FakeSurfaceBuilder { + /// Replaces the ordered script. + #[must_use] + pub fn script(mut self, script: VecDeque) -> Self { + self.script = script; + self + } + + /// Appends one explicit script step. + #[must_use] + pub fn step(mut self, step: SurfaceScriptStep) -> Self { + self.script.push_back(step); + self + } + + /// Scripts one event acceptance. + #[must_use] + pub fn acceptance(mut self, acceptance: SurfaceAcceptance) -> Self { + self.script + .push_back(SurfaceScriptStep::Return(SurfaceScriptReturn::Accept( + acceptance, + ))); + self + } + + /// Scripts one delivery disposition. + #[must_use] + pub fn delivery(mut self, disposition: DeliveryDisposition) -> Self { + self.script + .push_back(SurfaceScriptStep::Return(SurfaceScriptReturn::Apply( + disposition, + ))); + self + } + + /// Builds a nonempty honest fake. + pub fn build(self) -> Result { + if self.script.is_empty() { + return Err(SurfaceFakeBuildError::EmptyScript); + } + Ok(FakeSurface { + state: Arc::new(Mutex::new(SurfaceState { + script: self.script, + calls: Vec::new(), + acceptances: BTreeMap::new(), + deliveries: BTreeMap::new(), + })), + }) + } +} + +#[async_trait::async_trait] +impl SurfacePort for FakeSurface { + async fn accept(&self, event: SurfaceEvent) -> Result { + event.validate().map_err(|_| PortError::InvalidEvent)?; + if let Some(acceptance) = self.replay_acceptance(&event)? { + self.record_replay(SurfaceFakeCall::Accept)?; + return Ok(acceptance); + } + match self.take(SurfaceFakeCall::Accept)? { + SurfaceScriptStep::Return(SurfaceScriptReturn::Accept(acceptance)) => { + acceptance.validate()?; + if acceptance.surface_event_id == event.surface_event_id { + Ok(acceptance) + } else { + Err(PortError::InvalidResponse) + } + } + SurfaceScriptStep::DisconnectAfterCommit(SurfaceScriptReturn::Accept(acceptance)) => { + self.commit_acceptance(&event, acceptance)?; + Err(PortError::Unavailable) + } + SurfaceScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + SurfaceScriptStep::Error { error, .. } => Err(error), + SurfaceScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } + + async fn apply(&self, effect: SurfaceEffect) -> Result { + effect.validate().map_err(|_| PortError::InvalidEffect)?; + if let Some(disposition) = self.replay_delivery(&effect)? { + self.record_replay(SurfaceFakeCall::Apply)?; + return Ok(disposition); + } + match self.take(SurfaceFakeCall::Apply)? { + SurfaceScriptStep::Return(SurfaceScriptReturn::Apply(disposition)) => { + disposition.validate()?; + Ok(disposition) + } + SurfaceScriptStep::DisconnectAfterCommit(SurfaceScriptReturn::Apply(disposition)) => { + self.commit_delivery(&effect, disposition)?; + Err(PortError::Unavailable) + } + SurfaceScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), + SurfaceScriptStep::Error { error, .. } => Err(error), + SurfaceScriptStep::Stall(_) => Err(PortError::Stalled), + _ => Err(PortError::UnexpectedCall), + } + } +} diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs new file mode 100644 index 0000000..7f2aeb0 --- /dev/null +++ b/crates/psyche-test-support/tests/fakes.rs @@ -0,0 +1,1364 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use psyche_core::contracts::execution::{ + AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, + CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, + TerminationRequestCorrelation, +}; +use psyche_core::contracts::{CanonicalDocument, RecordKind, SchemaVersion}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, Capability, CapabilityProfile, CovenEvent, CovenPort, + EventCursor, EventPage, ExecutionRequestInput, NegotiateRequest, PortError, + ReconciliationDisposition, ReconciliationRequest, ResultBundle, SessionSnapshot, + TerminationDispatchError, TerminationDisposition, TerminationPersistence, + TerminationPersistenceFailure, derive_termination_outcome_revision, persist_then_terminate, +}; +use psyche_store::{Store, StoreError}; +use psyche_surfaces::{DeliveryDisposition, SurfacePort}; +use psyche_test_support::{ + CovenScriptReturn, CovenScriptStep, FakeBuildError, FakeCall, FakeCoven, FakeOperation, + FakeSurface, StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, +}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +const LAUNCH_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-launch.json"); +const INPUT_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-input.json"); +const RESULT_GOLDEN: &[u8] = include_bytes!("../../psyche-coven/tests/fixtures/result-bundle.json"); + +fn at(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn digest_of(character: char) -> Sha256Digest { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() +} + +fn record_id(kind: RecordKind, suffix: &str) -> RecordId { + RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())).unwrap() +} + +fn session_bound() -> ExecutionBinding { + ExecutionBinding { + schema_version: SchemaVersion::parse("psyche.execution_binding.v1").unwrap(), + attempt_id: record_id(RecordKind::Attempt, "01J00000000000000000000000"), + revision: 1, + previous_revision_digest: None, + revision_created_at: at("2026-08-05T14:00:00Z"), + familiar_snapshot_id: record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000000"), + project_id: "project:sha256:abc".to_owned(), + request_id: RequestId::parse("req_01J00000000000000000000000").unwrap(), + request_digest: digest_of('a'), + request_created_at: at("2026-08-05T13:59:00Z"), + request_valid_until: at("2026-08-05T14:05:00Z"), + coven_contract_version: "coven.daemon.v1".to_owned(), + coven_session_id: Some("session-1".to_owned()), + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:1".to_owned()), + cancellation_state: CancellationState::NotRequested, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn requested_after(previous: &ExecutionBinding) -> ExecutionBinding { + let mut requested = previous.clone(); + requested.revision += 1; + requested.previous_revision_digest = Some(digest(previous).unwrap()); + requested.revision_created_at += time::Duration::minutes(1); + requested.cancellation_state = CancellationState::TerminationRequested; + requested.termination_request = Some(TerminationRequestCorrelation { + termination_request_id: RequestId::parse("req_01J00000000000000000000001").unwrap(), + created_at: at("2026-08-05T14:01:00Z"), + valid_until: at("2026-08-05T14:03:00Z"), + }); + requested.termination_reason_code = Some("operator_request".to_owned()); + requested +} + +fn acknowledgement_for(requested: &ExecutionBinding) -> CancellationAcknowledgementEvidence { + CancellationAcknowledgementEvidence { + acknowledgement_id: "ack-1".to_owned(), + termination_request_id: requested + .termination_request + .as_ref() + .unwrap() + .termination_request_id + .clone(), + session_id: requested.coven_session_id.clone().unwrap(), + execution_request_id: requested.request_id.clone(), + execution_request_digest: requested.request_digest.clone(), + kind: CancellationAcknowledgementKind::Terminated, + authority_evidence_digest: digest_of('d'), + acknowledged_at: at("2026-08-05T14:02:00Z"), + } +} + +fn unresolved_for(requested: &ExecutionBinding) -> CancellationUnresolvedEvidence { + CancellationUnresolvedEvidence { + disposition_id: "unresolved-1".to_owned(), + termination_request_id: requested + .termination_request + .as_ref() + .unwrap() + .termination_request_id + .clone(), + session_id: requested.coven_session_id.clone().unwrap(), + execution_request_id: requested.request_id.clone(), + execution_request_digest: requested.request_digest.clone(), + reason_code: "timeout".to_owned(), + recorded_at: at("2026-08-05T14:02:00Z"), + } +} + +fn create_store() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + (dir, path) +} + +fn seed(path: &Path) -> ExecutionBinding { + let initial = session_bound(); + let mut store = Store::open(path).unwrap(); + store + .insert(&CanonicalDocument::ExecutionBinding(initial.clone())) + .unwrap(); + requested_after(&initial) +} + +fn persistence(path: &Path) -> StoreTerminationPersistence { + StoreTerminationPersistence::new(Store::open(path).unwrap()) +} + +fn revisions(path: &Path, attempt_id: &RecordId) -> Vec { + Store::open(path) + .unwrap() + .execution_binding_revisions(attempt_id) + .unwrap() +} + +fn surface_effect() -> psyche_core::contracts::surface::SurfaceEffect { + serde_json::from_value(serde_json::json!({ + "schema_version":"psyche.surface_effect.v1", + "surface_effect_id":"sfx_01J00000000000000000000000", + "intent_id":"int_01J00000000000000000000000", + "graph_id":"grf_01J00000000000000000000000", + "node_id":"nod_01J00000000000000000000000", + "attempt_id":"att_01J00000000000000000000000", + "familiar_snapshot_id":"ids_01J00000000000000000000000", + "project_id":"project:sha256:abc", + "action_class":"send_message", + "account_id":"account-1", + "locator":{}, + "effect":{"text":"hello"}, + "effect_digest":"sha256:cbbbdcd27692344de5dbab3abcaba413fb0f45307267de7081401576df1cb176", + "created_at":"2026-08-05T14:00:00Z" + })) + .unwrap() +} + +fn alternate_utc_spelling(bytes: Vec) -> Vec { + let canonical = String::from_utf8(bytes).unwrap(); + canonical.replacen("Z\"", "+00:00\"", 1).into_bytes() +} + +#[tokio::test] +async fn advertised_adoption_requires_a_scripted_adoption_step() { + let fake = FakeCoven::builder() + .capability(Capability::StableAdoption) + .build(); + assert!(matches!( + fake, + Err(FakeBuildError::UnscriptedCapability { .. }) + )); +} + +#[tokio::test] +async fn unknown_contract_fails_before_adoption() { + let fake = FakeCoven::builder() + .contract("coven.daemon.v1") + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".into(), + }) + .build() + .unwrap(); + let result = fake + .negotiate(NegotiateRequest::new("coven.daemon.v2")) + .await; + assert!(matches!(result, Err(PortError::ContractUnsupported { .. }))); + assert_eq!(fake.calls(), vec![FakeCall::Negotiate]); +} + +#[tokio::test] +async fn explicit_script_steps_are_consumed_in_order() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::Return(CovenScriptReturn::Negotiate( + CapabilityProfile { + api_version: "coven.daemon.v1".to_owned(), + capabilities: std::collections::BTreeSet::new(), + }, + ))) + .adoption(disposition.clone()) + .build() + .unwrap(); + assert_eq!( + fake.negotiate(NegotiateRequest::new("coven.daemon.v1")) + .await + .unwrap() + .api_version, + "coven.daemon.v1" + ); + assert_eq!(fake.adopt(request).await.unwrap(), disposition); + assert_eq!(fake.remaining_steps(), 0); + assert_eq!(fake.calls(), vec![FakeCall::Negotiate, FakeCall::Adopt]); +} + +#[tokio::test] +async fn reconcile_after_commit_replays_and_changed_correlation_conflicts() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let disposition = ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation: correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: at("2026-08-05T14:02:00Z"), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Reconcile(disposition.clone()), + )) + .build() + .unwrap(); + assert!(matches!( + fake.reconcile(request.clone()).await, + Err(PortError::Unavailable) + )); + let restarted = fake.restart(); + assert!(matches!( + restarted.reconcile(request.clone()).await, + Ok(ReconciliationDisposition::Returned { .. }) + )); + let mut changed = request; + changed.correlation.request_digest = digest_of('f'); + assert!(matches!( + restarted.reconcile(changed).await, + Err(PortError::IntentConflict) + )); +} + +#[tokio::test] +async fn before_commit_error_and_stall_never_advertise_success() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectBeforeCommit( + FakeOperation::Adopt, + )) + .step(CovenScriptStep::Stall(FakeOperation::Adopt)) + .adoption(disposition.clone()) + .build() + .unwrap(); + assert!(matches!( + fake.adopt(request.clone()).await, + Err(PortError::Unavailable) + )); + assert!(matches!( + fake.adopt(request.clone()).await, + Err(PortError::Stalled) + )); + assert_eq!(fake.adopt(request).await.unwrap(), disposition); +} + +#[tokio::test] +async fn result_references_survive_after_commit_disconnect_and_restart() { + let bundle: ResultBundle = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Result(bundle.clone()), + )) + .build() + .unwrap(); + assert!(matches!( + fake.result("session-1").await, + Err(PortError::Unavailable) + )); + let restarted = fake.restart(); + assert_eq!(restarted.result("session-1").await.unwrap(), bundle); +} + +#[tokio::test] +async fn invalid_scripted_results_are_response_errors() { + let mut bundle: ResultBundle = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + bundle.result.media_type = "Application/JSON".to_owned(); + let fake = FakeCoven::builder().result(bundle).build().unwrap(); + + assert!(matches!( + fake.result("session-1").await, + Err(PortError::InvalidResponse) + )); + + let fake = FakeCoven::builder() + .step(CovenScriptStep::Return(CovenScriptReturn::Negotiate( + CapabilityProfile { + api_version: String::new(), + capabilities: std::collections::BTreeSet::new(), + }, + ))) + .build() + .unwrap(); + assert!(matches!( + fake.negotiate(NegotiateRequest::new("coven.daemon.v1")) + .await, + Err(PortError::InvalidResponse) + )); +} + +#[tokio::test] +async fn result_references_must_echo_the_adopted_correlation() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + for (field, replacement) in [ + ( + "/request_id", + serde_json::json!("req_01J00000000000000000000001"), + ), + ( + "/request_digest", + serde_json::json!(format!("sha256:{}", "d".repeat(64))), + ), + ( + "/familiar_snapshot_id", + serde_json::json!("ids_01J00000000000000000000001"), + ), + ("/project_id", serde_json::json!("project:sha256:def")), + ( + "/graph_id", + serde_json::json!("grf_01J00000000000000000000001"), + ), + ( + "/node_id", + serde_json::json!("nod_01J00000000000000000000001"), + ), + ( + "/attempt_id", + serde_json::json!("att_01J00000000000000000000001"), + ), + ("/created_at", serde_json::json!("2026-08-05T13:59:59Z")), + ("/valid_until", serde_json::json!("2026-08-05T14:04:30Z")), + ] { + let mut mismatched: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + *mismatched + .pointer_mut(&format!("/correlation{field}")) + .unwrap() = replacement.clone(); + *mismatched + .pointer_mut(&format!("/artifacts/0/correlation{field}")) + .unwrap() = replacement; + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .result(serde_json::from_value(mismatched).unwrap()) + .build() + .unwrap(); + + fake.adopt(request.clone()).await.unwrap(); + assert!( + matches!( + fake.result("session-1").await, + Err(PortError::CorrelationMismatch) + ), + "{field}" + ); + } +} + +#[tokio::test] +async fn result_references_reject_an_unadopted_session() { + let request = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()).unwrap(); + let mut mismatched: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + mismatched["session_id"] = serde_json::json!("session-2"); + mismatched["artifacts"][0]["session_id"] = serde_json::json!("session-2"); + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .result(serde_json::from_value(mismatched).unwrap()) + .build() + .unwrap(); + + fake.adopt(request).await.unwrap(); + assert!(matches!( + fake.result("session-2").await, + Err(PortError::CorrelationMismatch) + )); +} + +#[tokio::test] +async fn session_snapshot_must_echo_the_adopted_correlation() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let mut correlations = Vec::new(); + let mut correlation = request.correlation(); + correlation.request_digest = digest_of('d'); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.familiar_snapshot_id = + record_id(RecordKind::IdentitySnapshot, "01J00000000000000000000001"); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.project_id = "project:sha256:def".to_owned(); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.graph_id = record_id(RecordKind::Graph, "01J00000000000000000000001"); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.node_id = record_id(RecordKind::GraphNode, "01J00000000000000000000001"); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.attempt_id = record_id(RecordKind::Attempt, "01J00000000000000000000001"); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.created_at += time::Duration::seconds(1); + correlations.push(correlation); + let mut correlation = request.correlation(); + correlation.valid_until -= time::Duration::seconds(1); + correlations.push(correlation); + + for mismatched in correlations { + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .snapshot(SessionSnapshot { + session_id: "session-1".to_owned(), + correlation: mismatched, + terminal_state: None, + }) + .build() + .unwrap(); + fake.adopt(request.clone()).await.unwrap(); + assert!(matches!( + fake.inspect("session-1").await, + Err(PortError::CorrelationMismatch) + )); + } + + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .snapshot(SessionSnapshot { + session_id: "session-2".to_owned(), + correlation: request.correlation(), + terminal_state: None, + }) + .build() + .unwrap(); + fake.adopt(request).await.unwrap(); + assert!(matches!( + fake.inspect("session-2").await, + Err(PortError::CorrelationMismatch) + )); +} + +#[tokio::test] +async fn surface_after_commit_replays_the_durable_disposition() { + let committed = DeliveryDisposition::Applied { + external_id: "delivery-1".to_owned(), + }; + let fake = FakeSurface::builder() + .step(SurfaceScriptStep::DisconnectAfterCommit( + SurfaceScriptReturn::Apply(committed.clone()), + )) + .delivery(DeliveryDisposition::Applied { + external_id: "delivery-2".to_owned(), + }) + .build() + .unwrap(); + let effect = surface_effect(); + + assert!(matches!( + fake.apply(effect.clone()).await, + Err(psyche_surfaces::PortError::Unavailable) + )); + assert_eq!(fake.restart().apply(effect).await.unwrap(), committed); +} + +#[tokio::test] +async fn raw_session_statuses_never_become_termination_acknowledgement() { + for status in [ + "created", + "running", + "idle", + "completed", + "failed", + "killed", + "orphaned", + ] { + let (_dir, path) = create_store(); + let requested = seed(&path); + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let fake = FakeCoven::builder() + .snapshot(SessionSnapshot { + session_id: "session-1".to_owned(), + correlation, + terminal_state: Some(status.to_owned()), + }) + .event_page(EventPage { + events: vec![CovenEvent { + sequence: 1, + event_digest: digest_of('e'), + terminal_state: Some(status.to_owned()), + }], + next_cursor: EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 1, + }, + }) + .build() + .unwrap(); + fake.inspect("session-1").await.unwrap(); + fake.events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }) + .await + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::UnexpectedCall)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + } +} + +#[tokio::test] +async fn changed_request_with_retained_digest_fails_before_adoption() { + for golden in [LAUNCH_GOLDEN, INPUT_GOLDEN] { + let input: ExecutionRequestInput = serde_json::from_slice(golden).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let envelope = serde_json::to_value(request).unwrap(); + let base = envelope["input"].clone(); + let retained_digest = envelope["request_digest"].clone(); + let mut mutations = Vec::new(); + let changes = [ + ( + "/schema_version", + serde_json::json!("psyche.execution_request.v2"), + ), + ( + "/request_id", + serde_json::json!("req_01J00000000000000000000001"), + ), + ( + "/graph_id", + serde_json::json!("grf_01J00000000000000000000001"), + ), + ( + "/node_id", + serde_json::json!("nod_01J00000000000000000000001"), + ), + ( + "/attempt_id", + serde_json::json!("att_01J00000000000000000000001"), + ), + ("/principal_id", serde_json::json!("principal:other")), + ( + "/familiar_snapshot_id", + serde_json::json!("ids_01J00000000000000000000001"), + ), + ("/project_id", serde_json::json!("project:sha256:def")), + ( + "/context_manifest_digest", + serde_json::json!(format!("sha256:{}", "7".repeat(64))), + ), + ( + "/payload_digest", + serde_json::json!(format!("sha256:{}", "8".repeat(64))), + ), + ("/created_at", serde_json::json!("2026-08-05T14:00:01Z")), + ("/valid_until", serde_json::json!("2026-08-05T14:07:00Z")), + ]; + for (pointer, replacement) in changes { + let mut changed = base.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + mutations.push(changed); + } + if base["operation"] == "launch" { + for (pointer, replacement) in [ + ("/project_root", serde_json::json!("/workspace")), + ("/cwd", serde_json::json!("/workspace/project/subdir")), + ("/harness", serde_json::json!("future-harness")), + ( + "/delegation_digest", + serde_json::json!(format!("sha256:{}", "9".repeat(64))), + ), + ( + "/budget_digest", + serde_json::json!(format!("sha256:{}", "a".repeat(64))), + ), + ( + "/required_artifact_bindings/0/artifact_id", + serde_json::json!("artifact-2"), + ), + ( + "/required_artifact_bindings/0/digest", + serde_json::json!(format!("sha256:{}", "b".repeat(64))), + ), + ( + "/required_artifact_bindings/0/media_type", + serde_json::json!("application/json"), + ), + ("/required_artifact_bindings/0/size", serde_json::json!(13)), + ] { + let mut changed = base.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + mutations.push(changed); + } + let mut changed = base.clone(); + changed["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "artifact_id":"artifact-2", + "digest":format!("sha256:{}", "c".repeat(64)), + "media_type":"text/plain", + "size":1 + })); + mutations.push(changed.clone()); + changed["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .reverse(); + mutations.push(changed); + } else { + for (pointer, replacement) in [ + ("/session_id", serde_json::json!("session-2")), + ( + "/input_digest", + serde_json::json!(format!("sha256:{}", "9".repeat(64))), + ), + ] { + let mut changed = base.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + mutations.push(changed); + } + let mut changed = base.clone(); + changed["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "artifact_id":"artifact-1", + "digest":format!("sha256:{}", "c".repeat(64)), + "media_type":"text/plain", + "size":1 + })); + mutations.push(changed); + } + + for mutated_input in mutations { + if let Ok(input) = + serde_json::from_value::(mutated_input.clone()) + { + if let Ok(rebuilt) = AdoptionRequest::new(input) { + assert_ne!( + serde_json::to_value(rebuilt.request_digest()).unwrap(), + retained_digest + ); + } + } + let forged: AdoptionRequest = serde_json::from_value(serde_json::json!({ + "input": mutated_input, + "request_digest": retained_digest + })) + .unwrap(); + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .build() + .unwrap(); + assert!(matches!( + fake.adopt(forged).await, + Err(PortError::RequestDigestMismatch) + )); + assert!(fake.calls().is_empty()); + } + } +} + +#[tokio::test] +async fn stable_adoption_replay_survives_fake_restart_and_rejects_changed_intent() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let mut changed: serde_json::Value = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + changed["cwd"] = serde_json::json!("/workspace/project/subdir"); + let changed = AdoptionRequest::new(serde_json::from_value(changed).unwrap()).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .adoption(disposition.clone()) + .build() + .unwrap(); + assert_eq!(fake.adopt(request.clone()).await.unwrap(), disposition); + let restarted = fake.restart(); + assert_eq!(restarted.adopt(request).await.unwrap(), disposition); + assert!(matches!( + restarted.adopt(changed).await, + Err(PortError::IntentConflict) + )); +} + +#[tokio::test] +async fn expired_new_adoption_fails_before_calls_but_durable_replay_survives() { + let request = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .current_time(at("2026-08-05T14:04:00Z")) + .adoption(disposition.clone()) + .build() + .unwrap(); + assert_eq!(fake.adopt(request.clone()).await.unwrap(), disposition); + assert_eq!( + fake.at_time(at("2026-08-05T14:05:00.000000001Z")) + .adopt(request.clone()) + .await + .unwrap(), + disposition + ); + + let expired = FakeCoven::builder() + .current_time(at("2026-08-05T14:05:00.000000001Z")) + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .build() + .unwrap(); + assert!(matches!( + expired.adopt(request).await, + Err(PortError::InvalidRequest) + )); + assert!(expired.calls().is_empty()); + assert_eq!(expired.remaining_steps(), 1); +} + +#[tokio::test] +async fn distinct_requests_may_share_an_adopted_session() { + let launch = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()).unwrap(); + let mut input: serde_json::Value = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + input["request_id"] = serde_json::json!("req_01J00000000000000000000001"); + let input = AdoptionRequest::new(serde_json::from_value(input).unwrap()).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .adoption(disposition.clone()) + .adoption(disposition.clone()) + .build() + .unwrap(); + + assert_eq!(fake.adopt(launch).await.unwrap(), disposition); + assert_eq!(fake.adopt(input).await.unwrap(), disposition); +} + +#[tokio::test] +async fn termination_dispatch_requires_durable_session_bound_revision() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let check_path = path.clone(); + let attempt = requested.attempt_id.clone(); + let expected_requested = canonical_bytes(&requested).unwrap(); + let fake = FakeCoven::builder() + .before_terminate(Arc::new(move |_| { + let persisted = revisions(&check_path, &attempt); + if persisted.len() == 2 + && canonical_bytes(&persisted[1]).ok().as_ref() == Some(&expected_requested) + { + Ok(()) + } else { + Err(PortError::Unavailable) + } + })) + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut store = persistence(&path); + let dyn_port: &dyn CovenPort = &fake; + persist_then_terminate(&mut store, dyn_port, requested.clone()) + .await + .unwrap(); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); + + let (_dir, path) = create_store(); + let initial = session_bound(); + let mut unbound = initial.clone(); + unbound.coven_session_id = None; + Store::open(&path) + .unwrap() + .insert(&CanonicalDocument::ExecutionBinding(unbound.clone())) + .unwrap(); + let requested = requested_after(&initial); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let result = persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await; + assert!(matches!( + result, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert!(fake.calls().is_empty()); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 1); + + let (_dir, path) = create_store(); + let mut unbound = session_bound(); + unbound.coven_session_id = None; + let mut requested = requested_after(&unbound); + requested.coven_session_id = Some("session-1".to_owned()); + let mut raw_store = Store::open(&path).unwrap(); + raw_store + .insert(&CanonicalDocument::ExecutionBinding(unbound)) + .unwrap(); + raw_store + .insert(&CanonicalDocument::ExecutionBinding(requested.clone())) + .unwrap(); + drop(raw_store); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert!(fake.calls().is_empty()); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + + let (_dir, path) = create_store(); + let mut changed_session = seed(&path); + changed_session.coven_session_id = Some("session-2".to_owned()); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&changed_session)) + .build() + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, changed_session.clone()).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert!(fake.calls().is_empty()); + assert_eq!(revisions(&path, &changed_session.attempt_id).len(), 1); + + let (_dir, path) = create_store(); + let mut unknown = requested_after(&session_bound()); + unknown.attempt_id = record_id(RecordKind::Attempt, "01J00000000000000000000002"); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&unknown)) + .build() + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, unknown.clone()).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert!(fake.calls().is_empty()); + assert!(revisions(&path, &unknown.attempt_id).is_empty()); + + let (_dir, path) = create_store(); + let requested = seed(&path); + for mode in [RequestFaultMode::Write, RequestFaultMode::Attestation] { + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut faulty = RequestFault { + inner: persistence(&path), + mode, + }; + let result = persist_then_terminate(&mut faulty, &fake, requested.clone()).await; + match mode { + RequestFaultMode::Write => assert!(matches!( + result, + Err(TerminationDispatchError::RequestPersistence(_)) + )), + RequestFaultMode::Attestation => assert!(matches!( + result, + Err(TerminationDispatchError::PersistedBindingMismatch) + )), + } + assert!(fake.calls().is_empty()); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 1); + } + + let (_dir, path) = create_store(); + let requested = seed(&path); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut fault = OutcomeFault { + inner: persistence(&path), + fail_once: true, + attest_wrong_bytes: false, + }; + assert!( + persist_then_terminate(&mut fault, &fake, requested.clone()) + .await + .is_err() + ); + let mut changed_reason = requested; + changed_reason.termination_reason_code = Some("shutdown".to_owned()); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&changed_reason)) + .build() + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, changed_reason).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert!(fake.calls().is_empty()); +} + +#[derive(Debug, Clone, Copy)] +enum RequestFaultMode { + Write, + Attestation, +} + +struct RequestFault { + inner: StoreTerminationPersistence, + mode: RequestFaultMode, +} + +impl TerminationPersistence for RequestFault { + type Error = StoreError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + match self.mode { + RequestFaultMode::Write => Err(TerminationPersistenceFailure::Write( + StoreError::DatabaseOperation, + )), + RequestFaultMode::Attestation => { + let bytes = canonical_bytes(&requested) + .map_err(StoreError::Contract) + .map_err(TerminationPersistenceFailure::Write)?; + Ok(alternate_utc_spelling(bytes)) + } + } + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.inner.persist_outcome(outcome) + } +} + +#[tokio::test] +async fn termination_dispatch_persists_acknowledged_outcome_before_success() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let mut evidence = acknowledgement_for(&requested); + evidence.acknowledged_at = requested.termination_request.as_ref().unwrap().created_at; + let disposition = TerminationDisposition::Acknowledged { + evidence: evidence.clone(), + }; + let fake = FakeCoven::builder() + .acknowledge_termination(evidence) + .build() + .unwrap(); + let actual = persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(); + assert_eq!(actual, disposition); + let stored = revisions(&path, &requested.attempt_id); + assert_eq!(stored.len(), 3); + let expected = derive_termination_outcome_revision(&requested, &disposition).unwrap(); + assert_eq!( + canonical_bytes(&stored[2]).unwrap(), + canonical_bytes(&expected).unwrap() + ); +} + +#[tokio::test] +async fn termination_dispatch_persists_unresolved_outcome_before_success() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let mut evidence = unresolved_for(&requested); + evidence.recorded_at = requested.termination_request.as_ref().unwrap().valid_until; + let disposition = TerminationDisposition::Unresolved { + evidence: evidence.clone(), + }; + let fake = FakeCoven::builder() + .unresolved_termination(evidence) + .build() + .unwrap(); + let actual = persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(); + assert_eq!(actual, disposition); + let stored = revisions(&path, &requested.attempt_id); + assert_eq!(stored.len(), 3); + let expected = derive_termination_outcome_revision(&requested, &disposition).unwrap(); + assert_eq!( + canonical_bytes(&stored[2]).unwrap(), + canonical_bytes(&expected).unwrap() + ); +} + +#[tokio::test] +async fn termination_dispatch_exact_replay_is_idempotent() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let evidence = acknowledgement_for(&requested); + let fake = FakeCoven::builder() + .acknowledge_termination(evidence.clone()) + .acknowledge_termination(evidence) + .build() + .unwrap(); + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(); + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +struct OutcomeFault { + inner: StoreTerminationPersistence, + fail_once: bool, + attest_wrong_bytes: bool, +} + +impl TerminationPersistence for OutcomeFault { + type Error = StoreError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.inner.persist_requested(requested) + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + if self.fail_once { + self.fail_once = false; + return Err(TerminationPersistenceFailure::Write( + StoreError::DatabaseOperation, + )); + } + let bytes = self.inner.persist_outcome(outcome)?; + if self.attest_wrong_bytes { + Ok(alternate_utc_spelling(bytes)) + } else { + Ok(bytes) + } + } +} + +#[tokio::test] +async fn termination_dispatch_crash_after_response_leaves_recoverable_request() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut fault = OutcomeFault { + inner: persistence(&path), + fail_once: true, + attest_wrong_bytes: false, + }; + let result = persist_then_terminate(&mut fault, &fake, requested.clone()).await; + assert!(matches!( + result, + Err(TerminationDispatchError::OutcomePersistenceIndeterminate(_)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); +} + +#[tokio::test] +async fn termination_dispatch_restart_recovers_missing_outcome() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let evidence = acknowledgement_for(&requested); + let fake = FakeCoven::builder() + .acknowledge_termination(evidence.clone()) + .build() + .unwrap(); + let mut fault = OutcomeFault { + inner: persistence(&path), + fail_once: true, + attest_wrong_bytes: false, + }; + assert!( + persist_then_terminate(&mut fault, &fake, requested.clone()) + .await + .is_err() + ); + drop(fault); + let restarted = FakeCoven::builder() + .acknowledge_termination(evidence) + .build() + .unwrap(); + persist_then_terminate(&mut persistence(&path), &restarted, requested.clone()) + .await + .unwrap(); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn termination_dispatch_rejects_conflicting_replay_response() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .conflicting_termination(TerminationDisposition::Unresolved { + evidence: unresolved_for(&requested), + }) + .build() + .unwrap(); + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(); + let result = persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await; + assert!(matches!( + result, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn termination_dispatch_rejects_invalid_outcome_evidence() { + type EvidenceMutation = Box; + let mut mutations: Vec = vec![ + Box::new(|evidence| evidence.acknowledgement_id.clear()), + Box::new(|evidence| evidence.acknowledgement_id = "a".repeat(256)), + Box::new(|evidence| { + evidence.termination_request_id = + RequestId::parse("req_01J00000000000000000000002").unwrap(); + }), + Box::new(|evidence| evidence.session_id = "session-2".to_owned()), + Box::new(|evidence| { + evidence.execution_request_id = + RequestId::parse("req_01J00000000000000000000002").unwrap(); + }), + Box::new(|evidence| evidence.execution_request_digest = digest_of('e')), + Box::new(|evidence| evidence.authority_evidence_digest = digest_of('0')), + Box::new(|evidence| evidence.acknowledged_at = at("2026-08-05T14:03:00.000000001Z")), + ]; + for mutation in mutations.drain(..) { + let (_dir, path) = create_store(); + let requested = seed(&path); + let mut evidence = acknowledgement_for(&requested); + mutation(&mut evidence); + let fake = FakeCoven::builder() + .acknowledge_termination(evidence) + .build() + .unwrap(); + let result = + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await; + assert!(matches!( + result, + Err(TerminationDispatchError::OutcomeEvidenceMismatch) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + } +} + +#[tokio::test] +async fn termination_dispatch_rejects_unresolved_outside_termination_window() { + for recorded_at in [ + at("2026-08-05T14:00:59.999999999Z"), + at("2026-08-05T14:03:00.000000001Z"), + ] { + let (_dir, path) = create_store(); + let requested = seed(&path); + let mut evidence = unresolved_for(&requested); + evidence.recorded_at = recorded_at; + let fake = FakeCoven::builder() + .unresolved_termination(evidence) + .build() + .unwrap(); + let result = + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await; + assert!(matches!( + result, + Err(TerminationDispatchError::OutcomeEvidenceMismatch) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + } +} + +#[tokio::test] +async fn termination_dispatch_reports_indeterminate_outcome_persistence() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut fault = OutcomeFault { + inner: persistence(&path), + fail_once: true, + attest_wrong_bytes: false, + }; + assert!(matches!( + persist_then_terminate(&mut fault, &fake, requested.clone()).await, + Err(TerminationDispatchError::OutcomePersistenceIndeterminate(_)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); +} + +struct ConcurrentPersistence { + inner: StoreTerminationPersistence, + path: PathBuf, + concurrent: Option, +} + +impl TerminationPersistence for ConcurrentPersistence { + type Error = StoreError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.inner.persist_requested(requested) + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + if let Some(concurrent) = self.concurrent.take() { + Store::open(&self.path) + .unwrap() + .insert(&CanonicalDocument::ExecutionBinding(concurrent)) + .unwrap(); + } + self.inner.persist_outcome(outcome) + } +} + +#[tokio::test] +async fn termination_dispatch_accepts_concurrent_exact_outcome_replay() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let disposition = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested), + }; + let concurrent = derive_termination_outcome_revision(&requested, &disposition).unwrap(); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut persistence = ConcurrentPersistence { + inner: persistence(&path), + path: path.clone(), + concurrent: Some(concurrent), + }; + assert_eq!( + persist_then_terminate(&mut persistence, &fake, requested.clone()) + .await + .unwrap(), + disposition + ); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn termination_dispatch_rejects_concurrent_divergent_outcome() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let divergent = derive_termination_outcome_revision( + &requested, + &TerminationDisposition::Unresolved { + evidence: unresolved_for(&requested), + }, + ) + .unwrap(); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut persistence = ConcurrentPersistence { + inner: persistence(&path), + path: path.clone(), + concurrent: Some(divergent), + }; + assert!(matches!( + persist_then_terminate(&mut persistence, &fake, requested.clone()).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn termination_dispatch_rejects_outcome_byte_attestation_mismatch() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + let mut fault = OutcomeFault { + inner: persistence(&path), + fail_once: false, + attest_wrong_bytes: true, + }; + assert!(matches!( + persist_then_terminate(&mut fault, &fake, requested.clone()).await, + Err(TerminationDispatchError::PersistedOutcomeMismatch) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn surface_fake_consumes_only_scripted_behavior() { + let fake = FakeSurface::builder() + .delivery(DeliveryDisposition::Unknown) + .build() + .unwrap(); + let effect = surface_effect(); + let port: &dyn SurfacePort = &fake; + assert_eq!( + port.apply(effect.clone()).await.unwrap(), + DeliveryDisposition::Unknown + ); + assert!(port.apply(effect).await.is_err()); +} From 536a83bc4deb821aa9915949e8fcc72d149a388d Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:51:46 -0500 Subject: [PATCH 41/66] feat(ports): add behavior-level fakes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-coven/src/port.rs | 3 +- crates/psyche-coven/tests/bindings.rs | 3 + crates/psyche-test-support/src/coven.rs | 3 + crates/psyche-test-support/src/surface.rs | 39 +++-- crates/psyche-test-support/tests/fakes.rs | 198 +++++++++++++++++++++- 5 files changed, 228 insertions(+), 18 deletions(-) diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs index 15b7090..4079e8a 100644 --- a/crates/psyche-coven/src/port.rs +++ b/crates/psyche-coven/src/port.rs @@ -636,7 +636,6 @@ impl ReconciliationDisposition { || ambiguity_digest != &request.ambiguity_digest || !utc(*recorded_at) || *recorded_at < request.correlation.created_at - || *recorded_at > request.correlation.valid_until { return Err(PortError::CorrelationMismatch); } @@ -811,7 +810,7 @@ impl ContentAddressedReference { /// Validates metadata only; this does not attest payload bytes. pub fn validate(&self) -> Result<(), PortError> { validate_media_type(&self.media_type)?; - if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { + if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER || !utc(self.expires_at) { return Err(PortError::InvalidRequest); } Ok(()) diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs index ef6c0a9..d2aabc0 100644 --- a/crates/psyche-coven/tests/bindings.rs +++ b/crates/psyche-coven/tests/bindings.rs @@ -139,6 +139,9 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { let mut oversized = reference.clone(); oversized.size_bytes = (i64::MAX as u64) + 1; assert!(oversized.validate().is_err()); + let mut non_interoperable = reference.clone(); + non_interoperable.size_bytes = 9_007_199_254_740_992; + assert!(non_interoperable.validate().is_err()); assert!( reference .validate_payload_at(b"{}", expires + time::Duration::nanoseconds(1)) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index 0cae5aa..a1f2e55 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -326,6 +326,9 @@ impl FakeCoven { request: &ReconciliationRequest, disposition: &ReconciliationDisposition, ) -> Result { + if disposition == &ReconciliationDisposition::Unresolved { + return Ok(ReconciliationDisposition::Unresolved); + } let key = request.correlation.request_id.as_str().to_owned(); { let state = self.state.lock().map_err(|_| PortError::Unavailable)?; diff --git a/crates/psyche-test-support/src/surface.rs b/crates/psyche-test-support/src/surface.rs index 619f3d7..ad57817 100644 --- a/crates/psyche-test-support/src/surface.rs +++ b/crates/psyche-test-support/src/surface.rs @@ -146,18 +146,26 @@ impl FakeSurface { &self, event: &SurfaceEvent, acceptance: SurfaceAcceptance, - ) -> Result<(), PortError> { + ) -> Result { acceptance.validate()?; if acceptance.surface_event_id != event.surface_event_id { return Err(PortError::InvalidResponse); } let bytes = canonical_bytes(event).map_err(|_| PortError::InvalidEvent)?; let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some((stored_bytes, stored)) = state.acceptances.get(event.surface_event_id.as_str()) + { + return if stored_bytes == &bytes { + Ok(stored.clone()) + } else { + Err(PortError::IntentConflict) + }; + } state.acceptances.insert( event.surface_event_id.as_str().to_owned(), - (bytes, acceptance), + (bytes, acceptance.clone()), ); - Ok(()) + Ok(acceptance) } fn replay_delivery( @@ -182,15 +190,24 @@ impl FakeSurface { &self, effect: &SurfaceEffect, disposition: DeliveryDisposition, - ) -> Result<(), PortError> { + ) -> Result { disposition.validate()?; let bytes = canonical_bytes(effect).map_err(|_| PortError::InvalidEffect)?; let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + if let Some((stored_bytes, stored)) = + state.deliveries.get(effect.surface_effect_id.as_str()) + { + return if stored_bytes == &bytes { + Ok(stored.clone()) + } else { + Err(PortError::IntentConflict) + }; + } state.deliveries.insert( effect.surface_effect_id.as_str().to_owned(), - (bytes, disposition), + (bytes, disposition.clone()), ); - Ok(()) + Ok(disposition) } } @@ -261,12 +278,7 @@ impl SurfacePort for FakeSurface { } match self.take(SurfaceFakeCall::Accept)? { SurfaceScriptStep::Return(SurfaceScriptReturn::Accept(acceptance)) => { - acceptance.validate()?; - if acceptance.surface_event_id == event.surface_event_id { - Ok(acceptance) - } else { - Err(PortError::InvalidResponse) - } + self.commit_acceptance(&event, acceptance) } SurfaceScriptStep::DisconnectAfterCommit(SurfaceScriptReturn::Accept(acceptance)) => { self.commit_acceptance(&event, acceptance)?; @@ -287,8 +299,7 @@ impl SurfacePort for FakeSurface { } match self.take(SurfaceFakeCall::Apply)? { SurfaceScriptStep::Return(SurfaceScriptReturn::Apply(disposition)) => { - disposition.validate()?; - Ok(disposition) + self.commit_delivery(&effect, disposition) } SurfaceScriptStep::DisconnectAfterCommit(SurfaceScriptReturn::Apply(disposition)) => { self.commit_delivery(&effect, disposition)?; diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 7f2aeb0..9b52e4e 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -19,7 +19,7 @@ use psyche_coven::{ TerminationPersistenceFailure, derive_termination_outcome_revision, persist_then_terminate, }; use psyche_store::{Store, StoreError}; -use psyche_surfaces::{DeliveryDisposition, SurfacePort}; +use psyche_surfaces::{DeliveryDisposition, SurfaceAcceptance, SurfacePort}; use psyche_test_support::{ CovenScriptReturn, CovenScriptStep, FakeBuildError, FakeCall, FakeCoven, FakeOperation, FakeSurface, StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, @@ -167,6 +167,21 @@ fn surface_effect() -> psyche_core::contracts::surface::SurfaceEffect { .unwrap() } +fn surface_event() -> psyche_core::contracts::surface::SurfaceEvent { + serde_json::from_value(serde_json::json!({ + "schema_version":"psyche.surface_event.v1", + "surface_event_id":"sev_01J00000000000000000000000", + "adapter_id":"telegram", + "account_id":"account-1", + "actor":{"type":"user","id":"123"}, + "locator":{"type":"message","chat_id":"123","message_id":"42"}, + "adapter_event_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "received_at":"2026-08-05T14:00:00Z", + "content":{"type":"text","text":"hello"} + })) + .unwrap() +} + fn alternate_utc_spelling(bytes: Vec) -> Vec { let canonical = String::from_utf8(bytes).unwrap(); canonical.replacen("Z\"", "+00:00\"", 1).into_bytes() @@ -267,6 +282,124 @@ async fn reconcile_after_commit_replays_and_changed_correlation_conflicts() { )); } +#[test] +fn reconciliation_resolution_may_be_recorded_after_correlation_deadline() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let recorded_at = request.correlation.valid_until + time::Duration::nanoseconds(1); + + for disposition in [ + ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation: correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at, + }, + ReconciliationDisposition::Fenced { + disposition_id: "disposition-2".to_owned(), + fence_token: "fence-1".to_owned(), + correlation: correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at, + }, + ] { + disposition.validate_for(&request).unwrap(); + } +} + +#[test] +fn reconciliation_resolution_requires_exact_correlation_digest_utc_and_lower_bound() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let disposition = + |correlation, ambiguity_digest, recorded_at| ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation, + ambiguity_digest, + recorded_at, + }; + + let mut mismatched_correlation = correlation.clone(); + mismatched_correlation.project_id = "project:sha256:def".to_owned(); + let non_utc = correlation + .created_at + .to_offset(time::UtcOffset::from_hms(1, 0, 0).unwrap()); + for invalid in [ + disposition( + mismatched_correlation, + request.ambiguity_digest.clone(), + correlation.valid_until, + ), + disposition(correlation.clone(), digest_of('f'), correlation.valid_until), + disposition( + correlation.clone(), + request.ambiguity_digest.clone(), + correlation.created_at - time::Duration::nanoseconds(1), + ), + disposition(correlation, request.ambiguity_digest.clone(), non_utc), + ] { + assert!(matches!( + invalid.validate_for(&request), + Err(PortError::CorrelationMismatch) + )); + } +} + +#[tokio::test] +async fn unresolved_reconciliation_remains_retryable_until_returned_or_fenced() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let recorded_at = request.correlation.valid_until + time::Duration::nanoseconds(1); + let resolutions = [ + ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation: correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at, + }, + ReconciliationDisposition::Fenced { + disposition_id: "disposition-2".to_owned(), + fence_token: "fence-1".to_owned(), + correlation, + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at, + }, + ]; + + for resolution in resolutions { + let fake = FakeCoven::builder() + .reconciliation(ReconciliationDisposition::Unresolved) + .reconciliation(resolution.clone()) + .build() + .unwrap(); + + assert_eq!( + fake.reconcile(request.clone()).await.unwrap(), + ReconciliationDisposition::Unresolved + ); + assert_eq!(fake.reconcile(request.clone()).await.unwrap(), resolution); + assert_eq!(fake.remaining_steps(), 0); + } +} + #[tokio::test] async fn before_commit_error_and_stall_never_advertise_success() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -508,6 +641,64 @@ async fn surface_after_commit_replays_the_durable_disposition() { assert_eq!(fake.restart().apply(effect).await.unwrap(), committed); } +#[tokio::test] +async fn successful_surface_acceptance_is_durable_and_conflict_safe() { + let event = surface_event(); + let committed = SurfaceAcceptance { + surface_event_id: event.surface_event_id.clone(), + accepted: true, + }; + let fake = FakeSurface::builder() + .acceptance(committed.clone()) + .acceptance(SurfaceAcceptance { + surface_event_id: event.surface_event_id.clone(), + accepted: false, + }) + .build() + .unwrap(); + + assert_eq!(fake.accept(event.clone()).await.unwrap(), committed); + assert_eq!( + fake.restart().accept(event.clone()).await.unwrap(), + committed + ); + + let mut changed = event; + changed.content["text"] = serde_json::json!("different"); + assert!(matches!( + fake.accept(changed).await, + Err(psyche_surfaces::PortError::IntentConflict) + )); +} + +#[tokio::test] +async fn successful_surface_delivery_is_durable_and_conflict_safe() { + let committed = DeliveryDisposition::Applied { + external_id: "delivery-1".to_owned(), + }; + let fake = FakeSurface::builder() + .delivery(committed.clone()) + .delivery(DeliveryDisposition::Rejected { + code: "policy_denied".to_owned(), + }) + .build() + .unwrap(); + let effect = surface_effect(); + + assert_eq!(fake.apply(effect.clone()).await.unwrap(), committed); + assert_eq!( + fake.restart().apply(effect.clone()).await.unwrap(), + committed + ); + + let mut changed = effect; + changed.project_id = "project:sha256:def".to_owned(); + assert!(matches!( + fake.apply(changed).await, + Err(psyche_surfaces::PortError::IntentConflict) + )); +} + #[tokio::test] async fn raw_session_statuses_never_become_termination_acknowledgement() { for status in [ @@ -1360,5 +1551,8 @@ async fn surface_fake_consumes_only_scripted_behavior() { port.apply(effect.clone()).await.unwrap(), DeliveryDisposition::Unknown ); - assert!(port.apply(effect).await.is_err()); + assert_eq!( + port.apply(effect).await.unwrap(), + DeliveryDisposition::Unknown + ); } From cc9b6d48f9b7040816945c56a53227d494fb41fe Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:53:22 -0500 Subject: [PATCH 42/66] feat(ports): add behavior-level fakes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-test-support/src/coven.rs | 25 +--- crates/psyche-test-support/tests/fakes.rs | 147 ++++++++++++++++++++++ 2 files changed, 150 insertions(+), 22 deletions(-) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index a1f2e55..58f5a78 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -449,23 +449,6 @@ impl FakeCoven { let state = self.state.lock().map_err(|_| PortError::Unavailable)?; Ok(state.terminations.get(key).cloned()) } - - fn scripted_negotiation(&self) -> Result, PortError> { - let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; - if state - .script - .front() - .is_some_and(|step| step.operation() == FakeOperation::Negotiate) - { - state - .script - .pop_front() - .map(Some) - .ok_or(PortError::UnexpectedCall) - } else { - Ok(None) - } - } } /// Builder for [`FakeCoven`]. @@ -673,21 +656,19 @@ impl FakeCovenBuilder { impl CovenPort for FakeCoven { async fn negotiate(&self, request: NegotiateRequest) -> Result { request.validate()?; - self.record(FakeOperation::Negotiate)?; if request.required_api_version != self.contract { + self.record(FakeOperation::Negotiate)?; return Err(PortError::ContractUnsupported {}); } if !request.required_capabilities.is_subset(&self.capabilities) { + self.record(FakeOperation::Negotiate)?; return Err(PortError::CapabilityMissing {}); } let configured = CapabilityProfile { api_version: self.contract.clone(), capabilities: self.capabilities.clone(), }; - let Some(step) = self.scripted_negotiation()? else { - return Ok(configured); - }; - match step { + match self.take(FakeOperation::Negotiate)? { CovenScriptStep::Return(CovenScriptReturn::Negotiate(profile)) => { profile.validate().map_err(|_| PortError::InvalidResponse)?; if profile == configured { diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 9b52e4e..b059732 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -214,6 +214,44 @@ async fn unknown_contract_fails_before_adoption() { assert_eq!(fake.calls(), vec![FakeCall::Negotiate]); } +#[tokio::test] +async fn supported_negotiation_requires_and_consumes_a_matching_script_step() { + let unscripted = FakeCoven::builder().build().unwrap(); + assert!(matches!( + unscripted + .negotiate(NegotiateRequest::new("coven.daemon.v1")) + .await, + Err(PortError::UnexpectedCall) + )); + assert_eq!(unscripted.calls(), vec![FakeCall::Negotiate]); + + let profile = CapabilityProfile { + api_version: "coven.daemon.v1".to_owned(), + capabilities: [Capability::StableAdoption.as_str().to_owned()] + .into_iter() + .collect(), + }; + let fake = FakeCoven::builder() + .capability(Capability::StableAdoption) + .step(CovenScriptStep::Return(CovenScriptReturn::Negotiate( + profile.clone(), + ))) + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .build() + .unwrap(); + let request = NegotiateRequest::new("coven.daemon.v1").requiring(Capability::StableAdoption); + + assert_eq!(fake.negotiate(request.clone()).await.unwrap(), profile); + assert_eq!(fake.remaining_steps(), 1); + assert!(matches!( + fake.negotiate(request).await, + Err(PortError::UnexpectedCall) + )); + assert_eq!(fake.remaining_steps(), 1); +} + #[tokio::test] async fn explicit_script_steps_are_consumed_in_order() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -903,6 +941,115 @@ async fn changed_request_with_retained_digest_fails_before_adoption() { } } +#[tokio::test] +async fn input_request_digest_binds_every_artifact_field_order_and_content() { + let mut base: serde_json::Value = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + base["required_artifact_bindings"] = serde_json::json!([ + { + "artifact_id":"artifact-1", + "digest":format!("sha256:{}", "c".repeat(64)), + "media_type":"text/plain", + "size":1 + }, + { + "artifact_id":"artifact-2", + "digest":format!("sha256:{}", "d".repeat(64)), + "media_type":"application/json", + "size":2 + } + ]); + let baseline = + AdoptionRequest::new(serde_json::from_value(base.clone()).unwrap()).unwrap(); + let retained_digest = serde_json::to_value(baseline.request_digest()).unwrap(); + let mut mutations = Vec::new(); + + for (name, pointer, replacement) in [ + ( + "artifact_id", + "/required_artifact_bindings/0/artifact_id", + serde_json::json!("artifact-3"), + ), + ( + "digest", + "/required_artifact_bindings/0/digest", + serde_json::json!(format!("sha256:{}", "e".repeat(64))), + ), + ( + "media_type", + "/required_artifact_bindings/0/media_type", + serde_json::json!("application/octet-stream"), + ), + ( + "size", + "/required_artifact_bindings/0/size", + serde_json::json!(3), + ), + ] { + let mut changed = base.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + mutations.push((name, changed)); + } + + let mut reordered = base.clone(); + reordered["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .reverse(); + mutations.push(("order", reordered)); + + let mut removed = base.clone(); + removed["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .remove(1); + mutations.push(("removed_content", removed)); + + let mut added = base; + added["required_artifact_bindings"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "artifact_id":"artifact-3", + "digest":format!("sha256:{}", "f".repeat(64)), + "media_type":"text/plain", + "size":3 + })); + mutations.push(("added_content", added)); + + for (name, mutated_input) in mutations { + let rebuilt = AdoptionRequest::new( + serde_json::from_value(mutated_input.clone()).unwrap(), + ) + .unwrap(); + assert_ne!( + serde_json::to_value(rebuilt.request_digest()).unwrap(), + retained_digest, + "{name}" + ); + + let forged: AdoptionRequest = serde_json::from_value(serde_json::json!({ + "input": mutated_input, + "request_digest": retained_digest + })) + .unwrap(); + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .build() + .unwrap(); + assert!( + matches!( + fake.adopt(forged).await, + Err(PortError::RequestDigestMismatch) + ), + "{name}" + ); + assert!(fake.calls().is_empty(), "{name}"); + assert_eq!(fake.remaining_steps(), 1, "{name}"); + } +} + #[tokio::test] async fn stable_adoption_replay_survives_fake_restart_and_rejects_changed_intent() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); From 5bba54818225de275e5b1db8168ec1d5993105bb Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:54:03 -0500 Subject: [PATCH 43/66] feat(ports): add behavior-level fakes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-test-support/tests/fakes.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index b059732..17cfcc9 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -958,8 +958,7 @@ async fn input_request_digest_binds_every_artifact_field_order_and_content() { "size":2 } ]); - let baseline = - AdoptionRequest::new(serde_json::from_value(base.clone()).unwrap()).unwrap(); + let baseline = AdoptionRequest::new(serde_json::from_value(base.clone()).unwrap()).unwrap(); let retained_digest = serde_json::to_value(baseline.request_digest()).unwrap(); let mut mutations = Vec::new(); @@ -1017,10 +1016,8 @@ async fn input_request_digest_binds_every_artifact_field_order_and_content() { mutations.push(("added_content", added)); for (name, mutated_input) in mutations { - let rebuilt = AdoptionRequest::new( - serde_json::from_value(mutated_input.clone()).unwrap(), - ) - .unwrap(); + let rebuilt = + AdoptionRequest::new(serde_json::from_value(mutated_input.clone()).unwrap()).unwrap(); assert_ne!( serde_json::to_value(rebuilt.request_digest()).unwrap(), retained_digest, From 41affe3b521e5d5d31b7631f7a6294126d498ec3 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:55:52 -0500 Subject: [PATCH 44/66] fix(ports): enforce durable scripted outcomes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-test-support/tests/fakes.rs | 215 ++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 17cfcc9..fe3ae69 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -438,6 +438,83 @@ async fn unresolved_reconciliation_remains_retryable_until_returned_or_fenced() } } +#[tokio::test] +async fn reconciliation_disconnect_before_and_stall_leave_ambiguity_retryable() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let returned = ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation, + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectBeforeCommit( + FakeOperation::Reconcile, + )) + .step(CovenScriptStep::Stall(FakeOperation::Reconcile)) + .reconciliation(returned.clone()) + .build() + .unwrap(); + + assert!(matches!( + fake.reconcile(request.clone()).await, + Err(PortError::Unavailable) + )); + assert!(matches!( + fake.restart().reconcile(request.clone()).await, + Err(PortError::Stalled) + )); + assert_eq!(fake.reconcile(request.clone()).await.unwrap(), returned); + assert_eq!(fake.restart().reconcile(request).await.unwrap(), returned); +} + +#[tokio::test] +async fn fenced_reconciliation_survives_after_commit_disconnect_and_restart() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let fenced = ReconciliationDisposition::Fenced { + disposition_id: "disposition-1".to_owned(), + fence_token: "fence-1".to_owned(), + correlation, + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Reconcile(fenced.clone()), + )) + .build() + .unwrap(); + + assert!(matches!( + fake.reconcile(request.clone()).await, + Err(PortError::Unavailable) + )); + assert_eq!( + fake.restart().reconcile(request.clone()).await.unwrap(), + fenced + ); + + let mut changed = request; + changed.ambiguity_digest = digest_of('f'); + assert!(matches!( + fake.reconcile(changed).await, + Err(PortError::IntentConflict) + )); +} + #[tokio::test] async fn before_commit_error_and_stall_never_advertise_success() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -786,6 +863,80 @@ async fn raw_session_statuses_never_become_termination_acknowledgement() { } } +#[tokio::test] +async fn killed_then_orphaned_statuses_do_not_create_termination_evidence() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let snapshot = |status: &str| SessionSnapshot { + session_id: "session-1".to_owned(), + correlation: correlation.clone(), + terminal_state: Some(status.to_owned()), + }; + let page = |status: &str, sequence| EventPage { + events: vec![CovenEvent { + sequence, + event_digest: digest_of('e'), + terminal_state: Some(status.to_owned()), + }], + next_cursor: EventCursor { + session_id: "session-1".to_owned(), + after_sequence: sequence, + }, + }; + let fake = FakeCoven::builder() + .snapshot(snapshot("killed")) + .event_page(page("killed", 1)) + .snapshot(snapshot("orphaned")) + .event_page(page("orphaned", 2)) + .build() + .unwrap(); + + assert_eq!( + fake.inspect("session-1").await.unwrap().terminal_state, + Some("killed".to_owned()) + ); + fake.events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }) + .await + .unwrap(); + + let restarted = fake.restart(); + assert_eq!( + restarted.inspect("session-1").await.unwrap().terminal_state, + Some("orphaned".to_owned()) + ); + restarted + .events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 1, + }) + .await + .unwrap(); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &restarted, requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::UnexpectedCall)) + )); + + let stored = revisions(&path, &requested.attempt_id); + assert_eq!(stored.len(), 2); + assert_eq!( + stored.last().unwrap().cancellation_state, + CancellationState::TerminationRequested + ); + assert!( + stored + .last() + .unwrap() + .cancellation_acknowledgement + .is_none() + ); + assert!(stored.last().unwrap().cancellation_unresolved.is_none()); +} + #[tokio::test] async fn changed_request_with_retained_digest_fails_before_adoption() { for golden in [LAUNCH_GOLDEN, INPUT_GOLDEN] { @@ -1124,6 +1275,70 @@ async fn distinct_requests_may_share_an_adopted_session() { assert_eq!(fake.adopt(input).await.unwrap(), disposition); } +#[tokio::test] +async fn termination_disconnect_before_and_stall_leave_request_retryable() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let disposition = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectBeforeCommit( + FakeOperation::Terminate, + )) + .step(CovenScriptStep::Stall(FakeOperation::Terminate)) + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::Stalled)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + assert_eq!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) + .await + .unwrap(), + disposition + ); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + +#[tokio::test] +async fn termination_after_commit_disconnect_replays_durable_acknowledgement() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let disposition = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested), + }; + let fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Terminate(disposition.clone()), + )) + .build() + .unwrap(); + + assert!(matches!( + persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); + + assert_eq!( + persist_then_terminate(&mut persistence(&path), &fake.restart(), requested.clone(),) + .await + .unwrap(), + disposition + ); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + #[tokio::test] async fn termination_dispatch_requires_durable_session_bound_revision() { let (_dir, path) = create_store(); From 11fd8cb576a54c6075ae474d0b4cb43ca271c163 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:10:03 -0500 Subject: [PATCH 45/66] fix(ports): honor durable lookup contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-coven/src/port.rs | 2 +- crates/psyche-coven/tests/bindings.rs | 9 +++++--- crates/psyche-test-support/src/coven.rs | 14 +++++++++++ crates/psyche-test-support/tests/lookup.rs | 27 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 crates/psyche-test-support/tests/lookup.rs diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs index 4079e8a..bc15d81 100644 --- a/crates/psyche-coven/src/port.rs +++ b/crates/psyche-coven/src/port.rs @@ -810,7 +810,7 @@ impl ContentAddressedReference { /// Validates metadata only; this does not attest payload bytes. pub fn validate(&self) -> Result<(), PortError> { validate_media_type(&self.media_type)?; - if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER || !utc(self.expires_at) { + if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { return Err(PortError::InvalidRequest); } Ok(()) diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs index d2aabc0..1f7918a 100644 --- a/crates/psyche-coven/tests/bindings.rs +++ b/crates/psyche-coven/tests/bindings.rs @@ -139,9 +139,12 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { let mut oversized = reference.clone(); oversized.size_bytes = (i64::MAX as u64) + 1; assert!(oversized.validate().is_err()); - let mut non_interoperable = reference.clone(); - non_interoperable.size_bytes = 9_007_199_254_740_992; - assert!(non_interoperable.validate().is_err()); + let mut maximum = reference.clone(); + maximum.size_bytes = i64::MAX as u64; + maximum.validate().unwrap(); + let maximum: ContentAddressedReference = + serde_json::from_value(serde_json::to_value(maximum).unwrap()).unwrap(); + maximum.validate().unwrap(); assert!( reference .validate_payload_at(b"{}", expires + time::Duration::nanoseconds(1)) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index 58f5a78..c03f2e0 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -711,6 +711,20 @@ impl CovenPort for FakeCoven { } async fn lookup(&self, request_id: &RequestId) -> Result { + let durable = { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let disposition = state + .adoptions + .get(request_id.as_str()) + .map(|(_, _, disposition)| disposition.clone()); + if disposition.is_some() { + state.calls.push(FakeCall::Lookup); + } + disposition + }; + if let Some(disposition) = durable { + return Ok(disposition); + } match self.take(FakeOperation::Lookup)? { CovenScriptStep::Return(CovenScriptReturn::Lookup(disposition)) => { self.lookup_adoption(request_id, &disposition) diff --git a/crates/psyche-test-support/tests/lookup.rs b/crates/psyche-test-support/tests/lookup.rs new file mode 100644 index 0000000..64b8915 --- /dev/null +++ b/crates/psyche-test-support/tests/lookup.rs @@ -0,0 +1,27 @@ +//! Durable adoption lookup regression coverage. + +use psyche_coven::{AdoptionDisposition, AdoptionRequest, CovenPort, ExecutionRequestInput}; +use psyche_test_support::FakeCoven; + +const LAUNCH_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-launch.json"); + +#[tokio::test] +async fn lookup_replays_durable_adoption_after_restart_without_a_script_step() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let request_id = request.correlation().request_id; + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let fake = FakeCoven::builder() + .adoption(disposition.clone()) + .build() + .unwrap(); + + assert_eq!(fake.adopt(request).await.unwrap(), disposition); + assert_eq!( + fake.restart().lookup(&request_id).await.unwrap(), + disposition + ); +} From 8296d1af4d174ae9da52ea949140cb4590b25530 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:12:58 -0500 Subject: [PATCH 46/66] fix(test): expose adapter-neutral observations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-test-support/src/coven.rs | 292 ++++++++++++++++++++-- crates/psyche-test-support/src/lib.rs | 6 +- crates/psyche-test-support/tests/fakes.rs | 233 ++++++++++++++--- 3 files changed, 467 insertions(+), 64 deletions(-) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index c03f2e0..dfe3f6e 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -39,7 +39,7 @@ pub enum FakeOperation { /// Redacted call observation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FakeCall { +enum FakeCall { /// Contract negotiation. Negotiate, /// Stable request adoption. @@ -73,6 +73,151 @@ impl From for FakeCall { } } +/// Adapter-neutral fault points used by Coven conformance fixtures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CovenFaultPoint { + /// Lose a launch adoption request before its durable write. + AdoptionBeforeCommit, + /// Lose a launch adoption response after its durable write. + AdoptionAfterCommit, + /// Lose an input adoption request before its durable write. + InputBeforeCommit, + /// Lose an input adoption response after its durable write. + InputAfterCommit, + /// Lose a stable-adoption lookup before reading it. + LookupBeforeRead, + /// Lose a stable-adoption lookup response after reading it. + LookupAfterRead, + /// Lose an event cursor request before reading its page. + CursorBeforePage, + /// Lose an event page response after reading it. + CursorAfterPage, + /// Lose a termination request before acknowledgement. + CancellationBeforeAcknowledgement, + /// Lose a termination response after acknowledgement. + CancellationAfterAcknowledgement, + /// Fail before durable terminal-state persistence. + TerminalBeforePersistence, + /// Fail before durable result persistence. + ResultBeforePersistence, + /// Fail before durable artifact persistence. + ArtifactBeforePersistence, + /// Lose reconciliation before its durable disposition. + ReconcileBeforeDisposition, + /// Lose reconciliation after its durable disposition. + ReconcileAfterDisposition, + /// Stall reconciliation without a durable disposition. + ReconcileStall, +} + +/// Redacted kind-specific metadata for a durable reconciliation disposition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DurableDispositionKind { + /// The original session was authoritatively returned. + Returned { + /// Session that owns the original execution. + session_id: String, + }, + /// Every resource for the ambiguous correlation was fenced. + Fenced { + /// Opaque authority-issued fence token. + fence_token: String, + }, +} + +/// Immutable, payload-free observation of one durable reconciliation disposition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DurableDispositionObservation { + /// Opaque durable disposition identity. + pub disposition_id: String, + /// Complete immutable execution correlation. + pub correlation: ExecutionCorrelation, + /// Digest of the durable ambiguity evidence. + pub ambiguity_digest: Sha256Digest, + /// Returned-session or fence metadata. + pub kind: DurableDispositionKind, + /// Authority-recorded disposition time. + pub recorded_at: time::OffsetDateTime, +} + +/// Adapter-neutral, payload-free observations needed by Coven conformance tests. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CovenConformanceObservations { + /// Number of adoption calls that reached the fixture. + pub adoption_calls: u64, + /// Number of reconciliation calls that reached the fixture. + pub reconciliation_calls: u64, + /// Most recently committed durable reconciliation disposition. + pub durable_reconciliation: Option, +} + +/// One reusable Coven conformance case. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CovenConformanceCase { + /// Contract negotiation. + C_S1, + /// Session lifecycle. + C_S2, + /// Snapshot attempt binding. + C_S3, + /// Stable adoption. + C_S4, + /// Durable non-adoption proof. + C_S5, + /// Ambiguity fencing. + C_S6, + /// Ordered cursors. + C_S7, + /// Terminal authority. + C_S8, + /// Cancellation acknowledgement. + C_S9, + /// Result and artifact binding. + C_S10, + /// Restart persistence. + C_S11, + /// Structured denials. + C_S12, +} + +/// Whether a fixture can execute a conformance case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FixtureAvailability { + /// The case is supported. + Supported, + /// The negotiated adapter contract does not support the case. + ExpectedUnsupported { + /// Stable structured denial code. + code: String, + }, +} + +/// Adapter-neutral controls and observations for reusable Coven conformance tests. +#[async_trait::async_trait] +pub trait CovenConformanceFixture { + /// Returns the production behavior boundary under test. + fn port(&self) -> &dyn CovenPort; + + /// Reports whether the fixture can execute a case. + fn availability(&self, case: CovenConformanceCase) -> FixtureAvailability; + + /// Restarts the fixture while retaining its durable state. + async fn restart(&mut self); + + /// Selects one deterministic transport or persistence fault. + async fn select_fault(&mut self, point: CovenFaultPoint); + + /// Clears the selected fault. + async fn clear_fault(&mut self); + + /// Restores the fixture to its initial clean state and script. + async fn reset(&mut self); + + /// Returns an immutable, payload-free observation snapshot. + async fn observations(&self) -> CovenConformanceObservations; +} + /// Typed response carried by a successful fake script step. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CovenScriptReturn { @@ -169,6 +314,7 @@ pub type BeforeTerminate = struct FakeState { script: VecDeque, calls: Vec, + selected_fault: Option, adoptions: BTreeMap, AdoptionDisposition)>, sessions: BTreeMap>, reconciliations: BTreeMap, @@ -183,6 +329,7 @@ pub struct FakeCoven { capabilities: BTreeSet, current_time: time::OffsetDateTime, state: Arc>, + initial_script: Arc>, before_terminate: Option, } @@ -198,22 +345,6 @@ impl FakeCoven { FakeCovenBuilder::default() } - /// Returns a redacted call log. - pub fn calls(&self) -> Vec { - self.state - .lock() - .map(|state| state.calls.clone()) - .unwrap_or_default() - } - - /// Number of script steps not yet consumed. - pub fn remaining_steps(&self) -> usize { - self.state - .lock() - .map(|state| state.script.len()) - .unwrap_or_default() - } - /// Simulates process restart while preserving only fake-owned durable state. pub fn restart(&self) -> Self { Self { @@ -221,6 +352,7 @@ impl FakeCoven { capabilities: self.capabilities.clone(), current_time: self.current_time, state: Arc::clone(&self.state), + initial_script: Arc::clone(&self.initial_script), before_terminate: self.before_terminate.clone(), } } @@ -232,6 +364,7 @@ impl FakeCoven { capabilities: self.capabilities.clone(), current_time, state: Arc::clone(&self.state), + initial_script: Arc::clone(&self.initial_script), before_terminate: self.before_terminate.clone(), } } @@ -243,8 +376,12 @@ impl FakeCoven { } fn take(&self, operation: FakeOperation) -> Result { + self.record(operation)?; + self.take_script(operation) + } + + fn take_script(&self, operation: FakeOperation) -> Result { let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; - state.calls.push(operation.into()); let Some(step) = state.script.front() else { return Err(PortError::UnexpectedCall); }; @@ -635,6 +772,7 @@ impl FakeCovenBuilder { }); } } + let initial_script = Arc::new(self.script.clone()); Ok(FakeCoven { contract: self.contract, capabilities: self @@ -647,6 +785,7 @@ impl FakeCovenBuilder { script: self.script, ..FakeState::default() })), + initial_script, before_terminate: self.before_terminate, }) } @@ -745,13 +884,32 @@ impl CovenPort for FakeCoven { request: ReconciliationRequest, ) -> Result { request.validate()?; + self.record(FakeOperation::Reconcile)?; + let selected_fault = self + .state + .lock() + .map_err(|_| PortError::Unavailable)? + .selected_fault; + match selected_fault { + Some(CovenFaultPoint::ReconcileBeforeDisposition) => { + return Err(PortError::Unavailable); + } + Some(CovenFaultPoint::ReconcileStall) => { + return Err(PortError::Stalled); + } + _ => {} + } if let Some(disposition) = self.replay_reconciliation(&request)? { - self.record(FakeOperation::Reconcile)?; return Ok(disposition); } - match self.take(FakeOperation::Reconcile)? { + match self.take_script(FakeOperation::Reconcile)? { CovenScriptStep::Return(CovenScriptReturn::Reconcile(disposition)) => { - self.store_reconciliation(&request, &disposition) + let disposition = self.store_reconciliation(&request, &disposition)?; + if selected_fault == Some(CovenFaultPoint::ReconcileAfterDisposition) { + Err(PortError::Unavailable) + } else { + Ok(disposition) + } } CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Reconcile(disposition)) => { self.store_reconciliation(&request, &disposition)?; @@ -892,6 +1050,98 @@ impl CovenPort for FakeCoven { } } +#[async_trait::async_trait] +impl CovenConformanceFixture for FakeCoven { + fn port(&self) -> &dyn CovenPort { + self + } + + fn availability(&self, _case: CovenConformanceCase) -> FixtureAvailability { + FixtureAvailability::Supported + } + + async fn restart(&mut self) { + *self = FakeCoven::restart(self); + } + + async fn select_fault(&mut self, point: CovenFaultPoint) { + if let Ok(mut state) = self.state.lock() { + state.selected_fault = Some(point); + } + } + + async fn clear_fault(&mut self) { + if let Ok(mut state) = self.state.lock() { + state.selected_fault = None; + } + } + + async fn reset(&mut self) { + if let Ok(mut state) = self.state.lock() { + *state = FakeState { + script: (*self.initial_script).clone(), + ..FakeState::default() + }; + } + } + + async fn observations(&self) -> CovenConformanceObservations { + let Ok(state) = self.state.lock() else { + return CovenConformanceObservations::default(); + }; + let count = |call| { + u64::try_from( + state + .calls + .iter() + .filter(|candidate| **candidate == call) + .count(), + ) + .unwrap_or(u64::MAX) + }; + let durable_reconciliation = state.reconciliations.values().next_back().and_then( + |(_, disposition)| match disposition { + ReconciliationDisposition::Returned { + disposition_id, + session_id, + correlation, + ambiguity_digest, + recorded_at, + } => Some(DurableDispositionObservation { + disposition_id: disposition_id.clone(), + correlation: correlation.clone(), + ambiguity_digest: ambiguity_digest.clone(), + kind: DurableDispositionKind::Returned { + session_id: session_id.clone(), + }, + recorded_at: *recorded_at, + }), + ReconciliationDisposition::Fenced { + disposition_id, + fence_token, + correlation, + ambiguity_digest, + recorded_at, + } => Some(DurableDispositionObservation { + disposition_id: disposition_id.clone(), + correlation: correlation.clone(), + ambiguity_digest: ambiguity_digest.clone(), + kind: DurableDispositionKind::Fenced { + fence_token: fence_token.clone(), + }, + recorded_at: *recorded_at, + }), + ReconciliationDisposition::Unresolved => None, + }, + ); + CovenConformanceObservations { + adoption_calls: count(FakeCall::Adopt), + reconciliation_calls: count(FakeCall::Reconcile), + durable_reconciliation, + } + } +} + /// Real Store-backed implementation of the narrow termination persistence port. #[derive(Debug)] pub struct StoreTerminationPersistence { diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs index 4e5c63f..a5efd4b 100644 --- a/crates/psyche-test-support/src/lib.rs +++ b/crates/psyche-test-support/src/lib.rs @@ -4,8 +4,10 @@ pub mod coven; pub mod surface; pub use coven::{ - BeforeTerminate, CovenScriptReturn, CovenScriptStep, FakeBuildError, FakeCall, FakeCoven, - FakeCovenBuilder, FakeError, FakeOperation, StoreTerminationPersistence, + BeforeTerminate, CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, + CovenFaultPoint, CovenScriptReturn, CovenScriptStep, DurableDispositionKind, + DurableDispositionObservation, FakeBuildError, FakeCoven, FakeCovenBuilder, FakeError, + FakeOperation, FixtureAvailability, StoreTerminationPersistence, }; pub use surface::{ FakeSurface, FakeSurfaceBuilder, SurfaceFakeBuildError, SurfaceFakeCall, SurfaceScriptReturn, diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index fe3ae69..55992ea 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -21,8 +21,10 @@ use psyche_coven::{ use psyche_store::{Store, StoreError}; use psyche_surfaces::{DeliveryDisposition, SurfaceAcceptance, SurfacePort}; use psyche_test_support::{ - CovenScriptReturn, CovenScriptStep, FakeBuildError, FakeCall, FakeCoven, FakeOperation, - FakeSurface, StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, + CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, CovenScriptReturn, + CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, FakeBuildError, + FakeCoven, FakeOperation, FakeSurface, StoreTerminationPersistence, SurfaceScriptReturn, + SurfaceScriptStep, }; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -200,18 +202,20 @@ async fn advertised_adoption_requires_a_scripted_adoption_step() { #[tokio::test] async fn unknown_contract_fails_before_adoption() { + let adoption = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".into(), + }; let fake = FakeCoven::builder() .contract("coven.daemon.v1") - .adoption(AdoptionDisposition::Adopted { - session_id: "session-1".into(), - }) + .adoption(disposition.clone()) .build() .unwrap(); let result = fake .negotiate(NegotiateRequest::new("coven.daemon.v2")) .await; assert!(matches!(result, Err(PortError::ContractUnsupported { .. }))); - assert_eq!(fake.calls(), vec![FakeCall::Negotiate]); + assert_eq!(fake.adopt(adoption).await.unwrap(), disposition); } #[tokio::test] @@ -223,8 +227,6 @@ async fn supported_negotiation_requires_and_consumes_a_matching_script_step() { .await, Err(PortError::UnexpectedCall) )); - assert_eq!(unscripted.calls(), vec![FakeCall::Negotiate]); - let profile = CapabilityProfile { api_version: "coven.daemon.v1".to_owned(), capabilities: [Capability::StableAdoption.as_str().to_owned()] @@ -242,14 +244,17 @@ async fn supported_negotiation_requires_and_consumes_a_matching_script_step() { .build() .unwrap(); let request = NegotiateRequest::new("coven.daemon.v1").requiring(Capability::StableAdoption); + let adoption = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()).unwrap(); + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; assert_eq!(fake.negotiate(request.clone()).await.unwrap(), profile); - assert_eq!(fake.remaining_steps(), 1); assert!(matches!( fake.negotiate(request).await, Err(PortError::UnexpectedCall) )); - assert_eq!(fake.remaining_steps(), 1); + assert_eq!(fake.adopt(adoption).await.unwrap(), disposition); } #[tokio::test] @@ -277,8 +282,6 @@ async fn explicit_script_steps_are_consumed_in_order() { "coven.daemon.v1" ); assert_eq!(fake.adopt(request).await.unwrap(), disposition); - assert_eq!(fake.remaining_steps(), 0); - assert_eq!(fake.calls(), vec![FakeCall::Negotiate, FakeCall::Adopt]); } #[tokio::test] @@ -297,27 +300,29 @@ async fn reconcile_after_commit_replays_and_changed_correlation_conflicts() { ambiguity_digest: request.ambiguity_digest.clone(), recorded_at: at("2026-08-05T14:02:00Z"), }; - let fake = FakeCoven::builder() + let mut fake = FakeCoven::builder() .step(CovenScriptStep::DisconnectAfterCommit( CovenScriptReturn::Reconcile(disposition.clone()), )) .build() .unwrap(); + let fixture: &mut dyn CovenConformanceFixture = &mut fake; assert!(matches!( - fake.reconcile(request.clone()).await, + fixture.port().reconcile(request.clone()).await, Err(PortError::Unavailable) )); - let restarted = fake.restart(); + fixture.restart().await; assert!(matches!( - restarted.reconcile(request.clone()).await, + fixture.port().reconcile(request.clone()).await, Ok(ReconciliationDisposition::Returned { .. }) )); let mut changed = request; changed.correlation.request_digest = digest_of('f'); assert!(matches!( - restarted.reconcile(changed).await, + fixture.port().reconcile(changed).await, Err(PortError::IntentConflict) )); + assert_eq!(fixture.observations().await.reconciliation_calls, 3); } #[test] @@ -428,13 +433,17 @@ async fn unresolved_reconciliation_remains_retryable_until_returned_or_fenced() .reconciliation(resolution.clone()) .build() .unwrap(); + let fixture: &dyn CovenConformanceFixture = &fake; assert_eq!( - fake.reconcile(request.clone()).await.unwrap(), + fixture.port().reconcile(request.clone()).await.unwrap(), ReconciliationDisposition::Unresolved ); - assert_eq!(fake.reconcile(request.clone()).await.unwrap(), resolution); - assert_eq!(fake.remaining_steps(), 0); + assert_eq!( + fixture.port().reconcile(request.clone()).await.unwrap(), + resolution + ); + assert_eq!(fixture.observations().await.reconciliation_calls, 2); } } @@ -454,7 +463,7 @@ async fn reconciliation_disconnect_before_and_stall_leave_ambiguity_retryable() ambiguity_digest: request.ambiguity_digest.clone(), recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), }; - let fake = FakeCoven::builder() + let mut fake = FakeCoven::builder() .step(CovenScriptStep::DisconnectBeforeCommit( FakeOperation::Reconcile, )) @@ -462,17 +471,24 @@ async fn reconciliation_disconnect_before_and_stall_leave_ambiguity_retryable() .reconciliation(returned.clone()) .build() .unwrap(); + let fixture: &mut dyn CovenConformanceFixture = &mut fake; assert!(matches!( - fake.reconcile(request.clone()).await, + fixture.port().reconcile(request.clone()).await, Err(PortError::Unavailable) )); + fixture.restart().await; assert!(matches!( - fake.restart().reconcile(request.clone()).await, + fixture.port().reconcile(request.clone()).await, Err(PortError::Stalled) )); - assert_eq!(fake.reconcile(request.clone()).await.unwrap(), returned); - assert_eq!(fake.restart().reconcile(request).await.unwrap(), returned); + assert_eq!( + fixture.port().reconcile(request.clone()).await.unwrap(), + returned + ); + fixture.restart().await; + assert_eq!(fixture.port().reconcile(request).await.unwrap(), returned); + assert_eq!(fixture.observations().await.reconciliation_calls, 4); } #[tokio::test] @@ -491,30 +507,163 @@ async fn fenced_reconciliation_survives_after_commit_disconnect_and_restart() { ambiguity_digest: request.ambiguity_digest.clone(), recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), }; - let fake = FakeCoven::builder() + let mut fake = FakeCoven::builder() .step(CovenScriptStep::DisconnectAfterCommit( CovenScriptReturn::Reconcile(fenced.clone()), )) .build() .unwrap(); + let fixture: &mut dyn CovenConformanceFixture = &mut fake; assert!(matches!( - fake.reconcile(request.clone()).await, + fixture.port().reconcile(request.clone()).await, Err(PortError::Unavailable) )); + fixture.restart().await; assert_eq!( - fake.restart().reconcile(request.clone()).await.unwrap(), + fixture.port().reconcile(request.clone()).await.unwrap(), fenced ); let mut changed = request; changed.ambiguity_digest = digest_of('f'); assert!(matches!( - fake.reconcile(changed).await, + fixture.port().reconcile(changed).await, Err(PortError::IntentConflict) )); } +#[tokio::test] +async fn conformance_observations_match_through_concrete_and_trait_object_without_mutation() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let adoption = AdoptionRequest::new(input).unwrap(); + let correlation = adoption.correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let disposition = ReconciliationDisposition::Returned { + disposition_id: "disposition-1".to_owned(), + session_id: "session-1".to_owned(), + correlation: correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: correlation.valid_until + time::Duration::nanoseconds(1), + }; + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .reconciliation(disposition) + .build() + .unwrap(); + + fake.adopt(adoption).await.unwrap(); + fake.reconcile(request.clone()).await.unwrap(); + + let concrete = fake.observations().await; + let fixture: &dyn CovenConformanceFixture = &fake; + let through_trait = fixture.observations().await; + assert_eq!(concrete, through_trait); + assert_eq!(fixture.observations().await, through_trait); + assert_eq!( + through_trait, + CovenConformanceObservations { + adoption_calls: 1, + reconciliation_calls: 1, + durable_reconciliation: Some(DurableDispositionObservation { + disposition_id: "disposition-1".to_owned(), + correlation, + ambiguity_digest: request.ambiguity_digest, + kind: DurableDispositionKind::Returned { + session_id: "session-1".to_owned(), + }, + recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), + }), + } + ); +} + +#[tokio::test] +async fn conformance_observations_are_redacted_and_follow_restart_reset_semantics() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let disposition = ReconciliationDisposition::Fenced { + disposition_id: "disposition-1".to_owned(), + fence_token: "fence-1".to_owned(), + correlation, + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: request.correlation.valid_until + time::Duration::nanoseconds(1), + }; + let mut fake = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Reconcile(disposition), + )) + .build() + .unwrap(); + + assert!(matches!( + fake.reconcile(request).await, + Err(PortError::Unavailable) + )); + let before_restart = fake.observations().await; + CovenConformanceFixture::restart(&mut fake).await; + assert_eq!(fake.observations().await, before_restart); + + let redacted = format!("{before_restart:?}"); + for raw_field in ["principal_id", "project_root", "cwd", "payload_digest"] { + assert!(!redacted.contains(raw_field), "{raw_field}"); + } + + CovenConformanceFixture::reset(&mut fake).await; + assert_eq!( + fake.observations().await, + CovenConformanceObservations::default() + ); +} + +#[tokio::test] +async fn conformance_fault_controls_are_object_safe_and_resettable() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation, + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let mut fake = FakeCoven::builder() + .reconciliation(ReconciliationDisposition::Unresolved) + .build() + .unwrap(); + let fixture: &mut dyn CovenConformanceFixture = &mut fake; + + fixture.select_fault(CovenFaultPoint::ReconcileStall).await; + assert!(matches!( + fixture.port().reconcile(request.clone()).await, + Err(PortError::Stalled) + )); + assert_eq!(fixture.observations().await.reconciliation_calls, 1); + + fixture.clear_fault().await; + assert_eq!( + fixture.port().reconcile(request).await.unwrap(), + ReconciliationDisposition::Unresolved + ); + assert_eq!(fixture.observations().await.reconciliation_calls, 2); + + fixture.reset().await; + fixture.restart().await; + assert_eq!( + fixture.observations().await, + CovenConformanceObservations::default() + ); +} + #[tokio::test] async fn before_commit_error_and_stall_never_advertise_success() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -1087,7 +1236,8 @@ async fn changed_request_with_retained_digest_fails_before_adoption() { fake.adopt(forged).await, Err(PortError::RequestDigestMismatch) )); - assert!(fake.calls().is_empty()); + let fixture: &dyn CovenConformanceFixture = &fake; + assert_eq!(fixture.observations().await.adoption_calls, 0); } } } @@ -1193,8 +1343,8 @@ async fn input_request_digest_binds_every_artifact_field_order_and_content() { ), "{name}" ); - assert!(fake.calls().is_empty(), "{name}"); - assert_eq!(fake.remaining_steps(), 1, "{name}"); + let fixture: &dyn CovenConformanceFixture = &fake; + assert_eq!(fixture.observations().await.adoption_calls, 0, "{name}"); } } @@ -1249,11 +1399,18 @@ async fn expired_new_adoption_fails_before_calls_but_durable_replay_survives() { .build() .unwrap(); assert!(matches!( - expired.adopt(request).await, + expired.adopt(request.clone()).await, Err(PortError::InvalidRequest) )); - assert!(expired.calls().is_empty()); - assert_eq!(expired.remaining_steps(), 1); + let fixture: &dyn CovenConformanceFixture = &expired; + assert_eq!(fixture.observations().await.adoption_calls, 0); + assert!( + expired + .at_time(at("2026-08-05T14:04:00Z")) + .adopt(request) + .await + .is_ok() + ); } #[tokio::test] @@ -1385,7 +1542,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { result, Err(TerminationDispatchError::RevisionConflict(_)) )); - assert!(fake.calls().is_empty()); assert_eq!(revisions(&path, &requested.attempt_id).len(), 1); let (_dir, path) = create_store(); @@ -1409,7 +1565,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { persist_then_terminate(&mut persistence(&path), &fake, requested.clone()).await, Err(TerminationDispatchError::RevisionConflict(_)) )); - assert!(fake.calls().is_empty()); assert_eq!(revisions(&path, &requested.attempt_id).len(), 2); let (_dir, path) = create_store(); @@ -1423,7 +1578,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { persist_then_terminate(&mut persistence(&path), &fake, changed_session.clone()).await, Err(TerminationDispatchError::RevisionConflict(_)) )); - assert!(fake.calls().is_empty()); assert_eq!(revisions(&path, &changed_session.attempt_id).len(), 1); let (_dir, path) = create_store(); @@ -1437,7 +1591,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { persist_then_terminate(&mut persistence(&path), &fake, unknown.clone()).await, Err(TerminationDispatchError::RevisionConflict(_)) )); - assert!(fake.calls().is_empty()); assert!(revisions(&path, &unknown.attempt_id).is_empty()); let (_dir, path) = create_store(); @@ -1462,7 +1615,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { Err(TerminationDispatchError::PersistedBindingMismatch) )), } - assert!(fake.calls().is_empty()); assert_eq!(revisions(&path, &requested.attempt_id).len(), 1); } @@ -1492,7 +1644,6 @@ async fn termination_dispatch_requires_durable_session_bound_revision() { persist_then_terminate(&mut persistence(&path), &fake, changed_reason).await, Err(TerminationDispatchError::RevisionConflict(_)) )); - assert!(fake.calls().is_empty()); } #[derive(Debug, Clone, Copy)] From 262aa62ee77e1523430b586b67ccab35490c1cb2 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:24:58 -0500 Subject: [PATCH 47/66] fix(test): harden durable conformance fakes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-coven/src/port.rs | 2 +- crates/psyche-coven/tests/bindings.rs | 15 +- crates/psyche-test-support/src/coven.rs | 126 ++++++++++--- crates/psyche-test-support/src/lib.rs | 2 +- crates/psyche-test-support/tests/fakes.rs | 210 ++++++++++++++++++++- crates/psyche-test-support/tests/lookup.rs | 89 ++++++++- 6 files changed, 402 insertions(+), 42 deletions(-) diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs index bc15d81..4079e8a 100644 --- a/crates/psyche-coven/src/port.rs +++ b/crates/psyche-coven/src/port.rs @@ -810,7 +810,7 @@ impl ContentAddressedReference { /// Validates metadata only; this does not attest payload bytes. pub fn validate(&self) -> Result<(), PortError> { validate_media_type(&self.media_type)?; - if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { + if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER || !utc(self.expires_at) { return Err(PortError::InvalidRequest); } Ok(()) diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs index 1f7918a..c9b6b43 100644 --- a/crates/psyche-coven/tests/bindings.rs +++ b/crates/psyche-coven/tests/bindings.rs @@ -137,10 +137,10 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { zero.size_bytes = 0; assert!(zero.validate().is_err()); let mut oversized = reference.clone(); - oversized.size_bytes = (i64::MAX as u64) + 1; + oversized.size_bytes = 9_007_199_254_740_992; assert!(oversized.validate().is_err()); let mut maximum = reference.clone(); - maximum.size_bytes = i64::MAX as u64; + maximum.size_bytes = 9_007_199_254_740_991; maximum.validate().unwrap(); let maximum: ContentAddressedReference = serde_json::from_value(serde_json::to_value(maximum).unwrap()).unwrap(); @@ -216,7 +216,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { ("/result/size_bytes", serde_json::json!(0)), ( "/result/size_bytes", - serde_json::json!((i64::MAX as u64) + 1), + serde_json::json!(9_007_199_254_740_992_u64), ), ( "/artifacts/0/content/media_type", @@ -225,7 +225,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { ("/artifacts/0/content/size_bytes", serde_json::json!(0)), ( "/artifacts/0/content/size_bytes", - serde_json::json!((i64::MAX as u64) + 1), + serde_json::json!(9_007_199_254_740_992_u64), ), ] { let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); @@ -235,6 +235,13 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { "{pointer}" ); } + + for pointer in ["/result/size_bytes", "/artifacts/0/content/size_bytes"] { + let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); + *value.pointer_mut(pointer).unwrap() = serde_json::json!(9_007_199_254_740_991_u64); + let bundle: ResultBundle = serde_json::from_value(value).unwrap(); + bundle.validate().unwrap(); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index dfe3f6e..c4ed9a8 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -15,6 +15,7 @@ use psyche_coven::{ TerminationPersistence, TerminationPersistenceFailure, TerminationRequest, }; use psyche_store::{Store, StoreError}; +use tokio::sync::Mutex as AsyncMutex; /// Redacted Coven operation identity used by scripts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -193,6 +194,17 @@ pub enum FixtureAvailability { }, } +/// A payload-free conformance fixture control failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum FixtureControlError { + /// The fixture does not implement the selected fault point. + #[error("fixture fault point is unsupported")] + UnsupportedFault, + /// The fixture control state could not be accessed. + #[error("fixture control state is unavailable")] + Unavailable, +} + /// Adapter-neutral controls and observations for reusable Coven conformance tests. #[async_trait::async_trait] pub trait CovenConformanceFixture { @@ -202,11 +214,14 @@ pub trait CovenConformanceFixture { /// Reports whether the fixture can execute a case. fn availability(&self, case: CovenConformanceCase) -> FixtureAvailability; + /// Reports whether the fixture implements a fault point. + fn supports(&self, point: CovenFaultPoint) -> bool; + /// Restarts the fixture while retaining its durable state. async fn restart(&mut self); /// Selects one deterministic transport or persistence fault. - async fn select_fault(&mut self, point: CovenFaultPoint); + async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError>; /// Clears the selected fault. async fn clear_fault(&mut self); @@ -316,10 +331,13 @@ struct FakeState { calls: Vec, selected_fault: Option, adoptions: BTreeMap, AdoptionDisposition)>, + lookup_adoptions: BTreeMap, sessions: BTreeMap>, reconciliations: BTreeMap, + latest_reconciliation: Option, results: BTreeMap, terminations: BTreeMap, + termination_in_flight: BTreeMap>>, } /// Honest, deterministic, thread-safe Coven fake. @@ -407,6 +425,13 @@ impl FakeCoven { } return Err(PortError::IntentConflict); } + if state + .lookup_adoptions + .get(&key) + .is_some_and(|stored| stored != disposition) + { + return Err(PortError::IntentConflict); + } if let AdoptionDisposition::Adopted { session_id } = disposition { let correlation = request.correlation(); let correlations = state.sessions.entry(session_id.clone()).or_default(); @@ -440,22 +465,44 @@ impl FakeCoven { } } - fn lookup_adoption( + fn store_lookup_adoption( &self, request_id: &RequestId, scripted: &AdoptionDisposition, ) -> Result { scripted.validate()?; - let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; if let Some((_, _, stored)) = state.adoptions.get(request_id.as_str()) { if stored == scripted { + return Ok(stored.clone()); + } else { + return Err(PortError::IntentConflict); + } + } + if let Some(stored) = state.lookup_adoptions.get(request_id.as_str()) { + return if stored == scripted { Ok(stored.clone()) } else { Err(PortError::IntentConflict) - } - } else { - Ok(scripted.clone()) + }; } + state + .lookup_adoptions + .insert(request_id.as_str().to_owned(), scripted.clone()); + Ok(scripted.clone()) + } + + fn replay_lookup_adoption( + &self, + request_id: &RequestId, + ) -> Result, PortError> { + let state = self.state.lock().map_err(|_| PortError::Unavailable)?; + Ok(state + .adoptions + .get(request_id.as_str()) + .map(|(_, _, disposition)| disposition) + .or_else(|| state.lookup_adoptions.get(request_id.as_str())) + .cloned()) } fn store_reconciliation( @@ -487,7 +534,8 @@ impl FakeCoven { } state .reconciliations - .insert(key, (request.clone(), disposition.clone())); + .insert(key.clone(), (request.clone(), disposition.clone())); + state.latest_reconciliation = Some(key); Ok(disposition.clone()) } @@ -586,6 +634,16 @@ impl FakeCoven { let state = self.state.lock().map_err(|_| PortError::Unavailable)?; Ok(state.terminations.get(key).cloned()) } + + fn termination_lock(&self, key: &str) -> Result>, PortError> { + let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; + Ok(Arc::clone( + state + .termination_in_flight + .entry(key.to_owned()) + .or_default(), + )) + } } /// Builder for [`FakeCoven`]. @@ -850,26 +908,17 @@ impl CovenPort for FakeCoven { } async fn lookup(&self, request_id: &RequestId) -> Result { - let durable = { - let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; - let disposition = state - .adoptions - .get(request_id.as_str()) - .map(|(_, _, disposition)| disposition.clone()); - if disposition.is_some() { - state.calls.push(FakeCall::Lookup); - } - disposition - }; + let durable = self.replay_lookup_adoption(request_id)?; if let Some(disposition) = durable { + self.record(FakeOperation::Lookup)?; return Ok(disposition); } match self.take(FakeOperation::Lookup)? { CovenScriptStep::Return(CovenScriptReturn::Lookup(disposition)) => { - self.lookup_adoption(request_id, &disposition) + self.store_lookup_adoption(request_id, &disposition) } CovenScriptStep::DisconnectAfterCommit(CovenScriptReturn::Lookup(disposition)) => { - self.lookup_adoption(request_id, &disposition)?; + self.store_lookup_adoption(request_id, &disposition)?; Err(PortError::Unavailable) } CovenScriptStep::Error { error, .. } => Err(error), @@ -994,6 +1043,8 @@ impl CovenPort for FakeCoven { request: TerminationRequest, ) -> Result { let key = Self::termination_key(&request)?; + let in_flight = self.termination_lock(&key)?; + let _guard = in_flight.lock().await; if let Some(stored) = self.replay_termination(&key)? { let scripted = { let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; @@ -1057,17 +1108,34 @@ impl CovenConformanceFixture for FakeCoven { } fn availability(&self, _case: CovenConformanceCase) -> FixtureAvailability { - FixtureAvailability::Supported + FixtureAvailability::ExpectedUnsupported { + code: "task_9_conformance_case_not_implemented".to_owned(), + } + } + + fn supports(&self, point: CovenFaultPoint) -> bool { + matches!( + point, + CovenFaultPoint::ReconcileBeforeDisposition + | CovenFaultPoint::ReconcileAfterDisposition + | CovenFaultPoint::ReconcileStall + ) } async fn restart(&mut self) { *self = FakeCoven::restart(self); } - async fn select_fault(&mut self, point: CovenFaultPoint) { - if let Ok(mut state) = self.state.lock() { - state.selected_fault = Some(point); + async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError> { + if !self.supports(point) { + return Err(FixtureControlError::UnsupportedFault); } + let mut state = self + .state + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + state.selected_fault = Some(point); + Ok(()) } async fn clear_fault(&mut self) { @@ -1099,8 +1167,11 @@ impl CovenConformanceFixture for FakeCoven { ) .unwrap_or(u64::MAX) }; - let durable_reconciliation = state.reconciliations.values().next_back().and_then( - |(_, disposition)| match disposition { + let durable_reconciliation = state + .latest_reconciliation + .as_ref() + .and_then(|key| state.reconciliations.get(key)) + .and_then(|(_, disposition)| match disposition { ReconciliationDisposition::Returned { disposition_id, session_id, @@ -1132,8 +1203,7 @@ impl CovenConformanceFixture for FakeCoven { recorded_at: *recorded_at, }), ReconciliationDisposition::Unresolved => None, - }, - ); + }); CovenConformanceObservations { adoption_calls: count(FakeCall::Adopt), reconciliation_calls: count(FakeCall::Reconcile), diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs index a5efd4b..b524999 100644 --- a/crates/psyche-test-support/src/lib.rs +++ b/crates/psyche-test-support/src/lib.rs @@ -7,7 +7,7 @@ pub use coven::{ BeforeTerminate, CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, CovenScriptReturn, CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, FakeBuildError, FakeCoven, FakeCovenBuilder, FakeError, - FakeOperation, FixtureAvailability, StoreTerminationPersistence, + FakeOperation, FixtureAvailability, FixtureControlError, StoreTerminationPersistence, }; pub use surface::{ FakeSurface, FakeSurfaceBuilder, SurfaceFakeBuildError, SurfaceFakeCall, SurfaceScriptReturn, diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 55992ea..55b3dc4 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -1,7 +1,9 @@ #![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; use psyche_core::contracts::execution::{ AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, @@ -21,10 +23,10 @@ use psyche_coven::{ use psyche_store::{Store, StoreError}; use psyche_surfaces::{DeliveryDisposition, SurfaceAcceptance, SurfacePort}; use psyche_test_support::{ - CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, CovenScriptReturn, - CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, FakeBuildError, - FakeCoven, FakeOperation, FakeSurface, StoreTerminationPersistence, SurfaceScriptReturn, - SurfaceScriptStep, + CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, + CovenScriptReturn, CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, + FakeBuildError, FakeCoven, FakeOperation, FakeSurface, FixtureAvailability, + FixtureControlError, StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, }; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -627,6 +629,69 @@ async fn conformance_observations_are_redacted_and_follow_restart_reset_semantic ); } +#[tokio::test] +async fn observations_follow_reconciliation_commit_order_across_restart() { + let mut high_input: serde_json::Value = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + high_input["request_id"] = serde_json::json!("req_01J00000000000000000000001"); + let high = AdoptionRequest::new(serde_json::from_value(high_input).unwrap()) + .unwrap() + .correlation(); + let low = AdoptionRequest::new(serde_json::from_slice(LAUNCH_GOLDEN).unwrap()) + .unwrap() + .correlation(); + let high_request = ReconciliationRequest { + correlation: high.clone(), + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let low_request = ReconciliationRequest { + correlation: low.clone(), + ambiguity_digest: digest_of('f'), + reason_code: "adoption_unknown".to_owned(), + }; + let high_disposition = ReconciliationDisposition::Returned { + disposition_id: "committed-first".to_owned(), + session_id: "session-high".to_owned(), + correlation: high, + ambiguity_digest: high_request.ambiguity_digest.clone(), + recorded_at: high_request.correlation.valid_until, + }; + let low_disposition = ReconciliationDisposition::Returned { + disposition_id: "committed-last".to_owned(), + session_id: "session-low".to_owned(), + correlation: low, + ambiguity_digest: low_request.ambiguity_digest.clone(), + recorded_at: low_request.correlation.valid_until, + }; + let mut fake = FakeCoven::builder() + .reconciliation(high_disposition) + .reconciliation(low_disposition) + .build() + .unwrap(); + + fake.reconcile(high_request).await.unwrap(); + fake.reconcile(low_request).await.unwrap(); + assert_eq!( + fake.observations() + .await + .durable_reconciliation + .unwrap() + .disposition_id, + "committed-last" + ); + CovenConformanceFixture::restart(&mut fake).await; + assert_eq!( + fake.observations() + .await + .durable_reconciliation + .unwrap() + .disposition_id, + "committed-last" + ); + CovenConformanceFixture::reset(&mut fake).await; + assert!(fake.observations().await.durable_reconciliation.is_none()); +} + #[tokio::test] async fn conformance_fault_controls_are_object_safe_and_resettable() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -642,7 +707,10 @@ async fn conformance_fault_controls_are_object_safe_and_resettable() { .unwrap(); let fixture: &mut dyn CovenConformanceFixture = &mut fake; - fixture.select_fault(CovenFaultPoint::ReconcileStall).await; + fixture + .select_fault(CovenFaultPoint::ReconcileStall) + .await + .unwrap(); assert!(matches!( fixture.port().reconcile(request.clone()).await, Err(PortError::Stalled) @@ -664,6 +732,80 @@ async fn conformance_fault_controls_are_object_safe_and_resettable() { ); } +#[tokio::test] +async fn conformance_fixture_truthfully_reports_cases_and_fault_support() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let correlation = AdoptionRequest::new(input).unwrap().correlation(); + let request = ReconciliationRequest { + correlation, + ambiguity_digest: digest_of('e'), + reason_code: "adoption_unknown".to_owned(), + }; + let mut fake = FakeCoven::builder() + .reconciliation(ReconciliationDisposition::Unresolved) + .build() + .unwrap(); + let fixture: &mut dyn CovenConformanceFixture = &mut fake; + + for case in [ + CovenConformanceCase::C_S1, + CovenConformanceCase::C_S2, + CovenConformanceCase::C_S3, + CovenConformanceCase::C_S4, + CovenConformanceCase::C_S5, + CovenConformanceCase::C_S6, + CovenConformanceCase::C_S7, + CovenConformanceCase::C_S8, + CovenConformanceCase::C_S9, + CovenConformanceCase::C_S10, + CovenConformanceCase::C_S11, + CovenConformanceCase::C_S12, + ] { + assert!(matches!( + fixture.availability(case), + FixtureAvailability::ExpectedUnsupported { .. } + )); + } + + let supported = [ + CovenFaultPoint::ReconcileBeforeDisposition, + CovenFaultPoint::ReconcileAfterDisposition, + CovenFaultPoint::ReconcileStall, + ]; + let unsupported = [ + CovenFaultPoint::AdoptionBeforeCommit, + CovenFaultPoint::AdoptionAfterCommit, + CovenFaultPoint::InputBeforeCommit, + CovenFaultPoint::InputAfterCommit, + CovenFaultPoint::LookupBeforeRead, + CovenFaultPoint::LookupAfterRead, + CovenFaultPoint::CursorBeforePage, + CovenFaultPoint::CursorAfterPage, + CovenFaultPoint::CancellationBeforeAcknowledgement, + CovenFaultPoint::CancellationAfterAcknowledgement, + CovenFaultPoint::TerminalBeforePersistence, + CovenFaultPoint::ResultBeforePersistence, + CovenFaultPoint::ArtifactBeforePersistence, + ]; + for point in supported { + assert!(fixture.supports(point), "{point:?}"); + } + for point in unsupported { + assert!(!fixture.supports(point), "{point:?}"); + } + + assert_eq!( + fixture + .select_fault(CovenFaultPoint::AdoptionBeforeCommit) + .await, + Err(FixtureControlError::UnsupportedFault) + ); + assert_eq!( + fixture.port().reconcile(request).await.unwrap(), + ReconciliationDisposition::Unresolved + ); +} + #[tokio::test] async fn before_commit_error_and_stall_never_advertise_success() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); @@ -1496,6 +1638,62 @@ async fn termination_after_commit_disconnect_replays_durable_acknowledgement() { assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_identical_termination_calls_share_one_scripted_commit() { + let (_dir, path) = create_store(); + let requested = seed(&path); + let disposition = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested), + }; + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let first = Arc::new(AtomicBool::new(true)); + let fake = FakeCoven::builder() + .before_terminate({ + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + let first = Arc::clone(&first); + Arc::new(move |_| { + if first.swap(false, Ordering::SeqCst) { + entered.wait(); + release.wait(); + } + Ok(()) + }) + }) + .acknowledge_termination(acknowledgement_for(&requested)) + .build() + .unwrap(); + + let first_call = { + let fake = fake.clone(); + let path = path.clone(); + let requested = requested.clone(); + tokio::spawn(async move { + persist_then_terminate(&mut persistence(&path), &fake, requested).await + }) + }; + entered.wait(); + let mut second_call = { + let fake = fake.clone(); + let path = path.clone(); + let requested = requested.clone(); + tokio::spawn(async move { + persist_then_terminate(&mut persistence(&path), &fake, requested).await + }) + }; + + let early_second = tokio::time::timeout(Duration::from_millis(100), &mut second_call).await; + release.wait(); + assert!( + early_second.is_err(), + "same-key caller bypassed the in-flight durable commit" + ); + assert_eq!(first_call.await.unwrap().unwrap(), disposition); + assert_eq!(second_call.await.unwrap().unwrap(), disposition); + assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); +} + #[tokio::test] async fn termination_dispatch_requires_durable_session_bound_revision() { let (_dir, path) = create_store(); diff --git a/crates/psyche-test-support/tests/lookup.rs b/crates/psyche-test-support/tests/lookup.rs index 64b8915..d5c89ed 100644 --- a/crates/psyche-test-support/tests/lookup.rs +++ b/crates/psyche-test-support/tests/lookup.rs @@ -1,7 +1,10 @@ //! Durable adoption lookup regression coverage. -use psyche_coven::{AdoptionDisposition, AdoptionRequest, CovenPort, ExecutionRequestInput}; -use psyche_test_support::FakeCoven; +use psyche_core::id::RequestId; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, CovenPort, ExecutionRequestInput, PortError, +}; +use psyche_test_support::{CovenScriptReturn, CovenScriptStep, FakeCoven, FakeOperation}; const LAUNCH_GOLDEN: &[u8] = include_bytes!("../../psyche-coven/tests/fixtures/execution-request-launch.json"); @@ -25,3 +28,85 @@ async fn lookup_replays_durable_adoption_after_restart_without_a_script_step() { disposition ); } + +#[tokio::test] +async fn successful_scripted_lookup_replays_after_restart_without_consuming_another_step() { + let first_id = RequestId::parse("req_01J00000000000000000000000").unwrap(); + let second_id = RequestId::parse("req_01J00000000000000000000001").unwrap(); + let first = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + let second = AdoptionDisposition::ProvenNotAdopted; + let fake = FakeCoven::builder() + .lookup(first.clone()) + .lookup(second.clone()) + .build() + .unwrap(); + + assert_eq!(fake.lookup(&first_id).await.unwrap(), first); + let restarted = fake.restart(); + assert_eq!(restarted.lookup(&first_id).await.unwrap(), first); + assert_eq!(restarted.lookup(&second_id).await.unwrap(), second); +} + +#[tokio::test] +async fn lookup_after_commit_disconnect_replays_but_before_commit_does_not() { + let request_id = RequestId::parse("req_01J00000000000000000000000").unwrap(); + let other_id = RequestId::parse("req_01J00000000000000000000001").unwrap(); + let disposition = AdoptionDisposition::ProvenNotAdopted; + let after_commit = FakeCoven::builder() + .step(CovenScriptStep::DisconnectAfterCommit( + CovenScriptReturn::Lookup(disposition.clone()), + )) + .build() + .unwrap(); + + assert_eq!( + after_commit.lookup(&request_id).await, + Err(PortError::Unavailable) + ); + assert_eq!( + after_commit.restart().lookup(&request_id).await.unwrap(), + disposition + ); + + let before_commit = FakeCoven::builder() + .step(CovenScriptStep::DisconnectBeforeCommit( + FakeOperation::Lookup, + )) + .lookup(disposition.clone()) + .build() + .unwrap(); + assert_eq!( + before_commit.lookup(&request_id).await, + Err(PortError::Unavailable) + ); + assert_eq!( + before_commit.restart().lookup(&request_id).await.unwrap(), + disposition + ); + assert_eq!( + before_commit.lookup(&other_id).await, + Err(PortError::UnexpectedCall) + ); +} + +#[tokio::test] +async fn durable_scripted_lookup_conflicts_with_a_later_different_adoption() { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let request_id = request.correlation().request_id; + let fake = FakeCoven::builder() + .lookup(AdoptionDisposition::ProvenNotAdopted) + .adoption(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + .build() + .unwrap(); + + assert_eq!( + fake.lookup(&request_id).await.unwrap(), + AdoptionDisposition::ProvenNotAdopted + ); + assert_eq!(fake.adopt(request).await, Err(PortError::IntentConflict)); +} From 4bfe0f318087c5bb69f0c06839bca93114799740 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:37:18 -0500 Subject: [PATCH 48/66] fix(test): isolate termination replay scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-test-support/src/coven.rs | 57 ++++++----- crates/psyche-test-support/tests/fakes.rs | 114 +++++++++++++++++++++- 2 files changed, 139 insertions(+), 32 deletions(-) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index c4ed9a8..24901a1 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -286,7 +286,12 @@ pub enum CovenScriptStep { /// Commits the carried response and then simulates a lost reply. DisconnectAfterCommit(CovenScriptReturn), /// Deliberately returns a response that conflicts with a durable replay. - ConflictingReplay(CovenScriptReturn), + ConflictingReplay { + /// Termination request identity that must already have a durable response. + expected_termination_request_id: RequestId, + /// Deliberately divergent termination disposition. + disposition: TerminationDisposition, + }, /// Leaves durable state unchanged and returns a deterministic stalled error. Stall(FakeOperation), } @@ -294,9 +299,8 @@ pub enum CovenScriptStep { impl CovenScriptStep { fn operation(&self) -> FakeOperation { match self { - Self::Return(response) - | Self::DisconnectAfterCommit(response) - | Self::ConflictingReplay(response) => response.operation(), + Self::Return(response) | Self::DisconnectAfterCommit(response) => response.operation(), + Self::ConflictingReplay { .. } => FakeOperation::Terminate, Self::Error { operation, .. } | Self::DisconnectBeforeCommit(operation) | Self::Stall(operation) => *operation, @@ -406,6 +410,9 @@ impl FakeCoven { if step.operation() != operation { return Err(PortError::UnexpectedCall); } + if matches!(step, CovenScriptStep::ConflictingReplay { .. }) { + return Err(PortError::UnexpectedCall); + } state.script.pop_front().ok_or(PortError::UnexpectedCall) } @@ -793,10 +800,15 @@ impl FakeCovenBuilder { /// Scripts a deliberately divergent response for coordinator conflict tests. #[must_use] - pub fn conflicting_termination(mut self, disposition: TerminationDisposition) -> Self { - self.script.push_back(CovenScriptStep::ConflictingReplay( - CovenScriptReturn::Terminate(disposition), - )); + pub fn conflicting_termination( + mut self, + expected_termination_request_id: RequestId, + disposition: TerminationDisposition, + ) -> Self { + self.script.push_back(CovenScriptStep::ConflictingReplay { + expected_termination_request_id, + disposition, + }); self } @@ -902,7 +914,7 @@ impl CovenPort for FakeCoven { CovenScriptStep::Error { error, .. } => Err(error), CovenScriptStep::DisconnectBeforeCommit(_) => Err(PortError::Unavailable), CovenScriptStep::Stall(_) => Err(PortError::Stalled), - CovenScriptStep::ConflictingReplay(_) => Err(PortError::UnexpectedCall), + CovenScriptStep::ConflictingReplay { .. } => Err(PortError::UnexpectedCall), _ => Err(PortError::UnexpectedCall), } } @@ -1046,12 +1058,14 @@ impl CovenPort for FakeCoven { let in_flight = self.termination_lock(&key)?; let _guard = in_flight.lock().await; if let Some(stored) = self.replay_termination(&key)? { + self.record(FakeOperation::Terminate)?; let scripted = { let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; match state.script.front() { - Some(CovenScriptStep::ConflictingReplay(CovenScriptReturn::Terminate(_))) - | Some(CovenScriptStep::Return(CovenScriptReturn::Terminate(_))) => { - state.calls.push(FakeCall::Terminate); + Some(CovenScriptStep::ConflictingReplay { + expected_termination_request_id, + .. + }) if expected_termination_request_id.as_str() == key => { state.script.pop_front() } _ => None, @@ -1061,27 +1075,12 @@ impl CovenPort for FakeCoven { assertion(&request)?; } return match scripted { - Some(CovenScriptStep::ConflictingReplay(CovenScriptReturn::Terminate( - disposition, - ))) => Ok(disposition), - Some(CovenScriptStep::Return(CovenScriptReturn::Terminate(disposition))) => { - if disposition == stored { - Ok(stored) - } else { - Err(PortError::IntentConflict) - } - } + Some(CovenScriptStep::ConflictingReplay { disposition, .. }) => Ok(disposition), Some(_) => Err(PortError::UnexpectedCall), - None => { - self.record(FakeOperation::Terminate)?; - Ok(stored) - } + None => Ok(stored), }; } let step = self.take(FakeOperation::Terminate)?; - if let CovenScriptStep::ConflictingReplay(_) = step { - return Err(PortError::UnexpectedCall); - } if let Some(assertion) = &self.before_terminate { assertion(&request)?; } diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 55b3dc4..227ac84 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -1638,6 +1638,106 @@ async fn termination_after_commit_disconnect_replays_durable_acknowledgement() { assert_eq!(revisions(&path, &requested.attempt_id).len(), 3); } +#[tokio::test] +async fn termination_replay_does_not_consume_another_requests_response() { + let (_dir_a, path_a) = create_store(); + let requested_a = seed(&path_a); + let disposition_a = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested_a), + }; + let (_dir_b, path_b) = create_store(); + let mut requested_b = seed(&path_b); + requested_b + .termination_request + .as_mut() + .unwrap() + .termination_request_id = RequestId::parse("req_01J00000000000000000000002").unwrap(); + let disposition_b = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested_b), + }; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fake = FakeCoven::builder() + .before_terminate({ + let calls = Arc::clone(&calls); + Arc::new(move |_| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }) + .acknowledge_termination(acknowledgement_for(&requested_a)) + .acknowledge_termination(acknowledgement_for(&requested_b)) + .build() + .unwrap(); + + assert_eq!( + persist_then_terminate(&mut persistence(&path_a), &fake, requested_a.clone()) + .await + .unwrap(), + disposition_a + ); + assert_eq!( + persist_then_terminate(&mut persistence(&path_a), &fake, requested_a) + .await + .unwrap(), + disposition_a + ); + assert_eq!( + persist_then_terminate(&mut persistence(&path_b), &fake, requested_b) + .await + .unwrap(), + disposition_b + ); + assert_eq!(calls.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn termination_replay_only_consumes_a_conflict_assertion_for_its_key() { + let (_dir_a, path_a) = create_store(); + let requested_a = seed(&path_a); + let disposition_a = TerminationDisposition::Acknowledged { + evidence: acknowledgement_for(&requested_a), + }; + let (_dir_b, path_b) = create_store(); + let mut requested_b = seed(&path_b); + requested_b + .termination_request + .as_mut() + .unwrap() + .termination_request_id = RequestId::parse("req_01J00000000000000000000002").unwrap(); + let key_b = requested_b + .termination_request + .as_ref() + .unwrap() + .termination_request_id + .clone(); + let conflicting_b = TerminationDisposition::Unresolved { + evidence: unresolved_for(&requested_b), + }; + let fake = FakeCoven::builder() + .acknowledge_termination(acknowledgement_for(&requested_a)) + .acknowledge_termination(acknowledgement_for(&requested_b)) + .conflicting_termination(key_b, conflicting_b) + .build() + .unwrap(); + + persist_then_terminate(&mut persistence(&path_a), &fake, requested_a.clone()) + .await + .unwrap(); + persist_then_terminate(&mut persistence(&path_b), &fake, requested_b.clone()) + .await + .unwrap(); + assert_eq!( + persist_then_terminate(&mut persistence(&path_a), &fake, requested_a) + .await + .unwrap(), + disposition_a + ); + assert!(matches!( + persist_then_terminate(&mut persistence(&path_b), &fake, requested_b).await, + Err(TerminationDispatchError::RevisionConflict(_)) + )); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_identical_termination_calls_share_one_scripted_commit() { let (_dir, path) = create_store(); @@ -2046,9 +2146,17 @@ async fn termination_dispatch_rejects_conflicting_replay_response() { let requested = seed(&path); let fake = FakeCoven::builder() .acknowledge_termination(acknowledgement_for(&requested)) - .conflicting_termination(TerminationDisposition::Unresolved { - evidence: unresolved_for(&requested), - }) + .conflicting_termination( + requested + .termination_request + .as_ref() + .unwrap() + .termination_request_id + .clone(), + TerminationDisposition::Unresolved { + evidence: unresolved_for(&requested), + }, + ) .build() .unwrap(); persist_then_terminate(&mut persistence(&path), &fake, requested.clone()) From 248343824222ffb513d1c0dc6ec07e82bb0977fc Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:41:03 -0500 Subject: [PATCH 49/66] fix(ports): bind authority and session evidence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psyche-core/src/contracts/execution.rs | 8 +++++++ crates/psyche-coven/src/port.rs | 2 +- crates/psyche-coven/tests/bindings.rs | 10 ++++----- crates/psyche-store/tests/records.rs | 12 +++++++++++ crates/psyche-test-support/src/coven.rs | 21 ++++++++++++++++--- crates/psyche-test-support/tests/lookup.rs | 19 +++++++++++++++++ 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/psyche-core/src/contracts/execution.rs b/crates/psyche-core/src/contracts/execution.rs index 47b1916..c92a7d5 100644 --- a/crates/psyche-core/src/contracts/execution.rs +++ b/crates/psyche-core/src/contracts/execution.rs @@ -98,6 +98,14 @@ impl CancellationAcknowledgementEvidence { let s = SchemaKind::ExecutionBinding; bounded(&self.acknowledgement_id, 255, s, "acknowledgement_id")?; bounded(&self.session_id, 255, s, "session_id")?; + if self + .authority_evidence_digest + .as_str() + .strip_prefix("sha256:") + .is_none_or(|hex| hex.bytes().all(|byte| byte == b'0')) + { + return Err(ContractError::CancellationEvidenceMismatch); + } Ok(()) } } diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs index 4079e8a..bc15d81 100644 --- a/crates/psyche-coven/src/port.rs +++ b/crates/psyche-coven/src/port.rs @@ -810,7 +810,7 @@ impl ContentAddressedReference { /// Validates metadata only; this does not attest payload bytes. pub fn validate(&self) -> Result<(), PortError> { validate_media_type(&self.media_type)?; - if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER || !utc(self.expires_at) { + if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { return Err(PortError::InvalidRequest); } Ok(()) diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs index c9b6b43..54f9ce3 100644 --- a/crates/psyche-coven/tests/bindings.rs +++ b/crates/psyche-coven/tests/bindings.rs @@ -137,10 +137,10 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { zero.size_bytes = 0; assert!(zero.validate().is_err()); let mut oversized = reference.clone(); - oversized.size_bytes = 9_007_199_254_740_992; + oversized.size_bytes = (i64::MAX as u64) + 1; assert!(oversized.validate().is_err()); let mut maximum = reference.clone(); - maximum.size_bytes = 9_007_199_254_740_991; + maximum.size_bytes = i64::MAX as u64; maximum.validate().unwrap(); let maximum: ContentAddressedReference = serde_json::from_value(serde_json::to_value(maximum).unwrap()).unwrap(); @@ -216,7 +216,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { ("/result/size_bytes", serde_json::json!(0)), ( "/result/size_bytes", - serde_json::json!(9_007_199_254_740_992_u64), + serde_json::json!((i64::MAX as u64) + 1), ), ( "/artifacts/0/content/media_type", @@ -225,7 +225,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { ("/artifacts/0/content/size_bytes", serde_json::json!(0)), ( "/artifacts/0/content/size_bytes", - serde_json::json!(9_007_199_254_740_992_u64), + serde_json::json!((i64::MAX as u64) + 1), ), ] { let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); @@ -238,7 +238,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { for pointer in ["/result/size_bytes", "/artifacts/0/content/size_bytes"] { let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); - *value.pointer_mut(pointer).unwrap() = serde_json::json!(9_007_199_254_740_991_u64); + *value.pointer_mut(pointer).unwrap() = serde_json::json!(i64::MAX as u64); let bundle: ResultBundle = serde_json::from_value(value).unwrap(); bundle.validate().unwrap(); } diff --git a/crates/psyche-store/tests/records.rs b/crates/psyche-store/tests/records.rs index 0f3e07d..609501c 100644 --- a/crates/psyche-store/tests/records.rs +++ b/crates/psyche-store/tests/records.rs @@ -708,6 +708,18 @@ fn direct_insert_rejects_mismatched_cancellation_evidence() { .execution_request_digest = fixture_other_digest(); }, ), + mutation( + "acknowledgement with zero authority evidence digest", + &acknowledged_terminated, + |binding| { + binding + .cancellation_acknowledgement + .as_mut() + .unwrap() + .authority_evidence_digest = + Sha256Digest::parse(&format!("sha256:{}", "0".repeat(64))).unwrap(); + }, + ), mutation( "termination request id reused as execution request id", &acknowledged_terminated, diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index 24901a1..cbeb1f8 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -10,9 +10,10 @@ use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::RequestId; use psyche_coven::{ AdoptionDisposition, AdoptionRequest, Capability, CapabilityProfile, CovenPort, EventCursor, - EventPage, ExecutionCorrelation, NegotiateRequest, PortError, ReconciliationDisposition, - ReconciliationRequest, ResultBundle, SessionSnapshot, TerminationDisposition, - TerminationPersistence, TerminationPersistenceFailure, TerminationRequest, + EventPage, ExecutionCorrelation, ExecutionRequestInput, NegotiateRequest, PortError, + ReconciliationDisposition, ReconciliationRequest, ResultBundle, SessionSnapshot, + TerminationDisposition, TerminationPersistence, TerminationPersistenceFailure, + TerminationRequest, }; use psyche_store::{Store, StoreError}; use tokio::sync::Mutex as AsyncMutex; @@ -422,6 +423,20 @@ impl FakeCoven { disposition: &AdoptionDisposition, ) -> Result { disposition.validate()?; + if let ( + ExecutionRequestInput::Input { + session_id: requested_session, + .. + }, + AdoptionDisposition::Adopted { + session_id: adopted_session, + }, + ) = (request.input(), disposition) + { + if requested_session != adopted_session { + return Err(PortError::CorrelationMismatch); + } + } let correlation = request.correlation(); let bytes = canonical_bytes(request.input()).map_err(PortError::from)?; let mut state = self.state.lock().map_err(|_| PortError::Unavailable)?; diff --git a/crates/psyche-test-support/tests/lookup.rs b/crates/psyche-test-support/tests/lookup.rs index d5c89ed..31741c5 100644 --- a/crates/psyche-test-support/tests/lookup.rs +++ b/crates/psyche-test-support/tests/lookup.rs @@ -8,6 +8,8 @@ use psyche_test_support::{CovenScriptReturn, CovenScriptStep, FakeCoven, FakeOpe const LAUNCH_GOLDEN: &[u8] = include_bytes!("../../psyche-coven/tests/fixtures/execution-request-launch.json"); +const INPUT_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-input.json"); #[tokio::test] async fn lookup_replays_durable_adoption_after_restart_without_a_script_step() { @@ -91,6 +93,23 @@ async fn lookup_after_commit_disconnect_replays_but_before_commit_does_not() { ); } +#[tokio::test] +async fn input_adoption_rejects_a_different_session() { + let input: ExecutionRequestInput = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + let request = AdoptionRequest::new(input).unwrap(); + let fake = FakeCoven::builder() + .adoption(AdoptionDisposition::Adopted { + session_id: "session-2".to_owned(), + }) + .build() + .unwrap(); + + assert_eq!( + fake.adopt(request).await, + Err(PortError::CorrelationMismatch) + ); +} + #[tokio::test] async fn durable_scripted_lookup_conflicts_with_a_later_different_adoption() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); From 671f94fb7432cdc44cb68083cbcd69f896bf7e25 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:21:30 -0500 Subject: [PATCH 50/66] test(conformance): add reusable foundation suites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/psyche-coven/src/port.rs | 2 +- crates/psyche-coven/tests/bindings.rs | 6 +- crates/psyche-test-support/Cargo.toml | 3 +- crates/psyche-test-support/src/lib.rs | 11 + .../psyche-test-support/src/suites/coven.rs | 2966 +++++++++++++++++ crates/psyche-test-support/src/suites/mod.rs | 27 + .../psyche-test-support/src/suites/surface.rs | 68 + .../psyche-test-support/tests/conformance.rs | 173 + .../tests/state_machine.rs | 1663 +++++++++ 10 files changed, 4915 insertions(+), 5 deletions(-) create mode 100644 crates/psyche-test-support/src/suites/coven.rs create mode 100644 crates/psyche-test-support/src/suites/mod.rs create mode 100644 crates/psyche-test-support/src/suites/surface.rs create mode 100644 crates/psyche-test-support/tests/conformance.rs create mode 100644 crates/psyche-test-support/tests/state_machine.rs diff --git a/Cargo.lock b/Cargo.lock index eef1366..1ae62ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -715,6 +715,7 @@ name = "psyche-test-support" version = "0.0.0" dependencies = [ "async-trait", + "proptest", "psyche-core", "psyche-coven", "psyche-store", diff --git a/crates/psyche-coven/src/port.rs b/crates/psyche-coven/src/port.rs index bc15d81..4079e8a 100644 --- a/crates/psyche-coven/src/port.rs +++ b/crates/psyche-coven/src/port.rs @@ -810,7 +810,7 @@ impl ContentAddressedReference { /// Validates metadata only; this does not attest payload bytes. pub fn validate(&self) -> Result<(), PortError> { validate_media_type(&self.media_type)?; - if self.size_bytes == 0 || self.size_bytes > i64::MAX as u64 || !utc(self.expires_at) { + if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER || !utc(self.expires_at) { return Err(PortError::InvalidRequest); } Ok(()) diff --git a/crates/psyche-coven/tests/bindings.rs b/crates/psyche-coven/tests/bindings.rs index 54f9ce3..888df68 100644 --- a/crates/psyche-coven/tests/bindings.rs +++ b/crates/psyche-coven/tests/bindings.rs @@ -137,10 +137,10 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { zero.size_bytes = 0; assert!(zero.validate().is_err()); let mut oversized = reference.clone(); - oversized.size_bytes = (i64::MAX as u64) + 1; + oversized.size_bytes = 9_007_199_254_740_992; assert!(oversized.validate().is_err()); let mut maximum = reference.clone(); - maximum.size_bytes = i64::MAX as u64; + maximum.size_bytes = 9_007_199_254_740_991; maximum.validate().unwrap(); let maximum: ContentAddressedReference = serde_json::from_value(serde_json::to_value(maximum).unwrap()).unwrap(); @@ -238,7 +238,7 @@ fn content_reference_rejects_digest_size_media_type_and_lifetime_mismatch() { for pointer in ["/result/size_bytes", "/artifacts/0/content/size_bytes"] { let mut value: serde_json::Value = serde_json::from_slice(RESULT_GOLDEN).unwrap(); - *value.pointer_mut(pointer).unwrap() = serde_json::json!(i64::MAX as u64); + *value.pointer_mut(pointer).unwrap() = serde_json::json!(9_007_199_254_740_991_u64); let bundle: ResultBundle = serde_json::from_value(value).unwrap(); bundle.validate().unwrap(); } diff --git a/crates/psyche-test-support/Cargo.toml b/crates/psyche-test-support/Cargo.toml index 25f693f..ab6fedd 100644 --- a/crates/psyche-test-support/Cargo.toml +++ b/crates/psyche-test-support/Cargo.toml @@ -13,12 +13,13 @@ psyche-core = { workspace = true } psyche-coven = { workspace = true } psyche-store = { workspace = true } psyche-surfaces = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true } [dev-dependencies] -serde_json = { workspace = true } +proptest = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs index b524999..a7d31c8 100644 --- a/crates/psyche-test-support/src/lib.rs +++ b/crates/psyche-test-support/src/lib.rs @@ -1,6 +1,7 @@ //! Deterministic fakes and reusable Psyche conformance fixtures. pub mod coven; +pub mod suites; pub mod surface; pub use coven::{ @@ -9,6 +10,16 @@ pub use coven::{ DurableDispositionObservation, FakeBuildError, FakeCoven, FakeCovenBuilder, FakeError, FakeOperation, FixtureAvailability, FixtureControlError, StoreTerminationPersistence, }; +pub use suites::{ + ConformanceOutcome, ScriptedG2Fixture, UnsupportedCovenFixture, + assert_c_s1_contract_negotiation, assert_c_s2_session_lifecycle, + assert_c_s3_snapshot_attempt_binding, assert_c_s4_stable_adoption, + assert_c_s5_non_adoption_proof, assert_c_s6_ambiguity_fence, assert_c_s7_ordered_cursor, + assert_c_s8_terminal_authority, assert_c_s9_cancellation_acknowledgement, + assert_c_s10_result_artifact_binding, assert_c_s11_restart_persistence, + assert_c_s12_structured_denial, assert_surface_unknown_delivery, scripted_fixture, + scripted_surface, unsupported_fixture, +}; pub use surface::{ FakeSurface, FakeSurfaceBuilder, SurfaceFakeBuildError, SurfaceFakeCall, SurfaceScriptReturn, SurfaceScriptStep, diff --git a/crates/psyche-test-support/src/suites/coven.rs b/crates/psyche-test-support/src/suites/coven.rs new file mode 100644 index 0000000..196c3b4 --- /dev/null +++ b/crates/psyche-test-support/src/suites/coven.rs @@ -0,0 +1,2966 @@ +//! Reusable Coven-boundary assertions and deterministic G2 fixture. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard}; + +use psyche_core::contracts::execution::{ + AdoptionState, CancellationAcknowledgementEvidence, CancellationAcknowledgementKind, + CancellationState, CancellationUnresolvedEvidence, ExecutionBinding, + TerminationRequestCorrelation, +}; +use psyche_core::contracts::{RecordKind, SchemaVersion}; +use psyche_core::digest::{Sha256Digest, canonical_bytes}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, ArtifactReference, Capability, CapabilityProfile, + ContentAddressedReference, CovenEvent, CovenPort, EventCursor, EventPage, ExecutionCorrelation, + ExecutionRequestInput, NegotiateRequest, PortError, ReconciliationDisposition, + ReconciliationRequest, ResultBundle, SessionSnapshot, TerminationDispatchError, + TerminationDisposition, TerminationPersistence, TerminationPersistenceFailure, + TerminationRequest, derive_termination_outcome_revision, persist_then_terminate, +}; + +use super::ConformanceOutcome; +use crate::coven::{ + CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, + DurableDispositionKind, DurableDispositionObservation, FixtureAvailability, + FixtureControlError, +}; + +const CONTRACT: &str = "coven.daemon.v1"; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const LAUNCH_GOLDEN: &[u8] = + include_bytes!("../../../psyche-coven/tests/fixtures/execution-request-launch.json"); +const INPUT_GOLDEN: &[u8] = + include_bytes!("../../../psyche-coven/tests/fixtures/execution-request-input.json"); +const RESULT_GOLDEN: &[u8] = + include_bytes!("../../../psyche-coven/tests/fixtures/result-bundle.json"); +const RAW_LEDGER_STATES: [&str; 7] = [ + "created", + "running", + "idle", + "completed", + "failed", + "killed", + "orphaned", +]; + +#[derive(Debug, Clone)] +struct DurableAdoption { + request_digest: Sha256Digest, + canonical_input: Vec, + correlation: ExecutionCorrelation, + disposition: AdoptionDisposition, +} + +#[derive(Debug, Clone, Default)] +struct SessionState { + correlations: Vec, + inspect_index: usize, + authoritative_terminal: bool, +} + +#[derive(Debug, Clone)] +struct DurableReconciliation { + request: ReconciliationRequest, + disposition: ReconciliationDisposition, +} + +#[derive(Debug, Clone)] +struct DurableTermination { + binding: ExecutionBinding, + disposition: TerminationDisposition, +} + +#[derive(Debug, Default)] +struct ScriptedState { + selected_fault: Option, + adoptions: BTreeMap, + lookup_dispositions: BTreeMap, + sessions: BTreeMap, + reconciliations: BTreeMap, + latest_reconciliation: Option, + event_pages: BTreeMap<(String, u64), EventPage>, + event_high_water: BTreeMap, + results: BTreeMap, + terminations: BTreeMap, + adoption_calls: u64, + reconciliation_calls: u64, +} + +#[derive(Debug, Clone)] +struct ScriptedG2Port { + state: Arc>, +} + +/// Deterministic, restartable fixture used for the scripted G2 evidence rows. +#[derive(Debug, Clone)] +pub struct ScriptedG2Fixture { + port: ScriptedG2Port, +} + +/// Builds a clean scripted fixture supporting all twelve G2 cases. +pub fn scripted_fixture() -> ScriptedG2Fixture { + ScriptedG2Fixture { + port: ScriptedG2Port { + state: Arc::new(Mutex::new(ScriptedState::default())), + }, + } +} + +impl ScriptedG2Port { + fn state(&self) -> Result, PortError> { + self.state.lock().map_err(|_| PortError::Unavailable) + } + + fn session_for_launch(state: &ScriptedState) -> String { + format!("session-{}", state.sessions.len().saturating_add(1)) + } + + fn adoption_fault( + state: &ScriptedState, + input: &ExecutionRequestInput, + ) -> Option { + match (input, state.selected_fault) { + ( + ExecutionRequestInput::Launch { .. }, + Some( + point @ (CovenFaultPoint::AdoptionBeforeCommit + | CovenFaultPoint::AdoptionAfterCommit), + ), + ) + | ( + ExecutionRequestInput::Input { .. }, + Some( + point + @ (CovenFaultPoint::InputBeforeCommit | CovenFaultPoint::InputAfterCommit), + ), + ) => Some(point), + _ => None, + } + } + + fn disposition_for_missing_lookup(request_id: &RequestId) -> AdoptionDisposition { + if request_id.as_str().ends_with("01") { + AdoptionDisposition::ProvenNotAdopted + } else { + AdoptionDisposition::Unknown + } + } + + fn result_for( + session_id: &str, + correlation: &ExecutionCorrelation, + ) -> Result { + if session_id == "session-1" { + let golden: ResultBundle = + serde_json::from_slice(RESULT_GOLDEN).map_err(|_| PortError::InvalidResponse)?; + if &golden.correlation == correlation { + return Ok(golden); + } + } + let result = ContentAddressedReference { + digest: digest_of('b'), + media_type: "application/json".to_owned(), + size_bytes: 2, + expires_at: correlation.created_at + time::Duration::minutes(4), + }; + let artifact = ArtifactReference { + artifact_id: "artifact-1".to_owned(), + session_id: session_id.to_owned(), + correlation: correlation.clone(), + content: ContentAddressedReference { + digest: digest_of('c'), + media_type: "text/plain".to_owned(), + size_bytes: 5, + expires_at: correlation.created_at + time::Duration::minutes(3), + }, + }; + let bundle = ResultBundle { + session_id: session_id.to_owned(), + correlation: correlation.clone(), + result, + artifacts: vec![artifact], + }; + bundle.validate().map_err(|_| PortError::InvalidResponse)?; + Ok(bundle) + } + + fn termination_disposition( + binding: &ExecutionBinding, + ) -> Result { + let termination = binding + .termination_request + .as_ref() + .ok_or(PortError::InvalidRequest)?; + let session_id = binding + .coven_session_id + .as_ref() + .ok_or(PortError::InvalidRequest)?; + if binding.termination_reason_code.as_deref() == Some("force_unresolved") { + return Ok(TerminationDisposition::Unresolved { + evidence: CancellationUnresolvedEvidence { + disposition_id: "unresolved-1".to_owned(), + termination_request_id: termination.termination_request_id.clone(), + session_id: session_id.clone(), + execution_request_id: binding.request_id.clone(), + execution_request_digest: binding.request_digest.clone(), + reason_code: "authority_silent".to_owned(), + recorded_at: termination.created_at, + }, + }); + } + Ok(TerminationDisposition::Acknowledged { + evidence: CancellationAcknowledgementEvidence { + acknowledgement_id: "acknowledgement-1".to_owned(), + termination_request_id: termination.termination_request_id.clone(), + session_id: session_id.clone(), + execution_request_id: binding.request_id.clone(), + execution_request_digest: binding.request_digest.clone(), + kind: CancellationAcknowledgementKind::Terminated, + authority_evidence_digest: digest_of('e'), + acknowledged_at: termination.created_at, + }, + }) + } +} + +#[async_trait::async_trait] +impl CovenPort for ScriptedG2Port { + async fn negotiate(&self, request: NegotiateRequest) -> Result { + request.validate()?; + if request.required_api_version != CONTRACT { + return Err(PortError::ContractUnsupported {}); + } + let capabilities = capability_names(); + if !request.required_capabilities.is_subset(&capabilities) { + return Err(PortError::CapabilityMissing {}); + } + Ok(CapabilityProfile { + api_version: CONTRACT.to_owned(), + capabilities, + }) + } + + async fn adopt(&self, request: AdoptionRequest) -> Result { + request.validate_digest()?; + let correlation = request.correlation(); + if correlation.valid_until < at("2026-08-05T14:00:00Z") { + return Err(PortError::InvalidRequest); + } + let canonical_input = + canonical_bytes(request.input()).map_err(|_| PortError::InvalidRequest)?; + let key = correlation.request_id.as_str().to_owned(); + let mut state = self.state()?; + if let Some(stored) = state.adoptions.get(&key) { + return if stored.request_digest == *request.request_digest() + && stored.canonical_input == canonical_input + { + Ok(stored.disposition.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + + state.adoption_calls = state.adoption_calls.saturating_add(1); + let fault = Self::adoption_fault(&state, request.input()); + if matches!( + fault, + Some(CovenFaultPoint::AdoptionBeforeCommit | CovenFaultPoint::InputBeforeCommit) + ) { + return Err(PortError::Unavailable); + } + + let disposition = match request.input() { + ExecutionRequestInput::Launch { .. } => AdoptionDisposition::Adopted { + session_id: Self::session_for_launch(&state), + }, + ExecutionRequestInput::Input { session_id, .. } => { + if !state.sessions.contains_key(session_id) { + return Err(PortError::NotFound); + } + AdoptionDisposition::Adopted { + session_id: session_id.clone(), + } + } + }; + let AdoptionDisposition::Adopted { session_id } = &disposition else { + return Err(PortError::InvalidResponse); + }; + let session = state.sessions.entry(session_id.clone()).or_default(); + if !session.correlations.contains(&correlation) { + session.correlations.push(correlation.clone()); + } + state.adoptions.insert( + key, + DurableAdoption { + request_digest: request.request_digest().clone(), + canonical_input, + correlation, + disposition: disposition.clone(), + }, + ); + if matches!( + fault, + Some(CovenFaultPoint::AdoptionAfterCommit | CovenFaultPoint::InputAfterCommit) + ) { + Err(PortError::Unavailable) + } else { + Ok(disposition) + } + } + + async fn lookup(&self, request_id: &RequestId) -> Result { + let mut state = self.state()?; + if state.selected_fault == Some(CovenFaultPoint::LookupBeforeRead) { + return Err(PortError::Unavailable); + } + let disposition = state + .adoptions + .get(request_id.as_str()) + .map(|stored| stored.disposition.clone()) + .or_else(|| state.lookup_dispositions.get(request_id.as_str()).cloned()) + .unwrap_or_else(|| Self::disposition_for_missing_lookup(request_id)); + state + .lookup_dispositions + .entry(request_id.as_str().to_owned()) + .or_insert_with(|| disposition.clone()); + if state.selected_fault == Some(CovenFaultPoint::LookupAfterRead) { + Err(PortError::Unavailable) + } else { + Ok(disposition) + } + } + + async fn reconcile( + &self, + request: ReconciliationRequest, + ) -> Result { + request.validate()?; + let mut state = self.state()?; + state.reconciliation_calls = state.reconciliation_calls.saturating_add(1); + match state.selected_fault { + Some(CovenFaultPoint::ReconcileBeforeDisposition) => { + return Err(PortError::Unavailable); + } + Some(CovenFaultPoint::ReconcileStall) => return Err(PortError::Stalled), + _ => {} + } + let key = request.correlation.request_id.as_str().to_owned(); + if let Some(stored) = state.reconciliations.get(&key) { + return if stored.request == request { + Ok(stored.disposition.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + let adoption = state.adoptions.get(&key).ok_or(PortError::Unavailable)?; + if adoption.correlation != request.correlation { + return Err(PortError::IntentConflict); + } + let disposition = match request.reason_code.as_str() { + "return_original" => { + let AdoptionDisposition::Adopted { session_id } = &adoption.disposition else { + return Err(PortError::InvalidResponse); + }; + ReconciliationDisposition::Returned { + disposition_id: format!("return-{}", request.correlation.request_id.as_str()), + session_id: session_id.clone(), + correlation: request.correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: request.correlation.created_at + time::Duration::minutes(1), + } + } + "fence_ambiguous" => ReconciliationDisposition::Fenced { + disposition_id: format!("fence-{}", request.correlation.request_id.as_str()), + fence_token: "fence-token-1".to_owned(), + correlation: request.correlation.clone(), + ambiguity_digest: request.ambiguity_digest.clone(), + recorded_at: request.correlation.created_at + time::Duration::minutes(1), + }, + _ => ReconciliationDisposition::Unresolved, + }; + disposition.validate_for(&request)?; + if disposition == ReconciliationDisposition::Unresolved { + return Ok(disposition); + } + state.reconciliations.insert( + key.clone(), + DurableReconciliation { + request, + disposition: disposition.clone(), + }, + ); + state.latest_reconciliation = Some(key); + if state.selected_fault == Some(CovenFaultPoint::ReconcileAfterDisposition) { + Err(PortError::Unavailable) + } else { + Ok(disposition) + } + } + + async fn inspect(&self, session_id: &str) -> Result { + if session_id.is_empty() || session_id.len() > 255 { + return Err(PortError::InvalidRequest); + } + let mut state = self.state()?; + let session = state + .sessions + .get_mut(session_id) + .ok_or(PortError::NotFound)?; + let correlation = session + .correlations + .first() + .cloned() + .ok_or(PortError::InvalidResponse)?; + let terminal_state = if session.authoritative_terminal { + Some("authoritatively_terminated".to_owned()) + } else { + let status = RAW_LEDGER_STATES[session.inspect_index % RAW_LEDGER_STATES.len()]; + session.inspect_index = session.inspect_index.saturating_add(1); + Some(status.to_owned()) + }; + Ok(SessionSnapshot { + session_id: session_id.to_owned(), + correlation, + terminal_state, + }) + } + + async fn events(&self, cursor: EventCursor) -> Result { + cursor.validate()?; + let mut state = self.state()?; + if !state.sessions.contains_key(&cursor.session_id) { + return Err(PortError::CorrelationMismatch); + } + if state.selected_fault == Some(CovenFaultPoint::CursorBeforePage) { + return Err(PortError::Unavailable); + } + let key = (cursor.session_id.clone(), cursor.after_sequence); + if let Some(page) = state.event_pages.get(&key).cloned() { + return if state.selected_fault == Some(CovenFaultPoint::CursorAfterPage) { + Err(PortError::Unavailable) + } else { + Ok(page) + }; + } + if state + .event_high_water + .get(&cursor.session_id) + .is_some_and(|high| cursor.after_sequence < *high) + { + return Err(PortError::IntentConflict); + } + if cursor.after_sequence > RAW_LEDGER_STATES.len() as u64 { + return Err(PortError::InvalidRequest); + } + let start = + usize::try_from(cursor.after_sequence).map_err(|_| PortError::InvalidRequest)?; + let end = start.saturating_add(3).min(RAW_LEDGER_STATES.len()); + let mut events = Vec::with_capacity(end.saturating_sub(start)); + for (index, terminal_state) in RAW_LEDGER_STATES[start..end].iter().enumerate() { + let sequence = u64::try_from(start.saturating_add(index).saturating_add(1)) + .map_err(|_| PortError::InvalidResponse)?; + events.push(CovenEvent { + sequence, + event_digest: digest_for_sequence(sequence), + terminal_state: Some((*terminal_state).to_owned()), + }); + } + let next = events + .last() + .map_or(cursor.after_sequence, |event| event.sequence); + let page = EventPage { + events, + next_cursor: EventCursor { + session_id: cursor.session_id.clone(), + after_sequence: next, + }, + }; + page.validate_for(&cursor)?; + state.event_pages.insert(key, page.clone()); + state + .event_high_water + .insert(cursor.session_id.clone(), next); + if state.selected_fault == Some(CovenFaultPoint::CursorAfterPage) { + Err(PortError::Unavailable) + } else { + Ok(page) + } + } + + async fn result(&self, session_id: &str) -> Result { + if session_id.is_empty() || session_id.len() > 255 { + return Err(PortError::InvalidRequest); + } + let mut state = self.state()?; + if let Some(bundle) = state.results.get(session_id) { + return Ok(bundle.clone()); + } + if matches!( + state.selected_fault, + Some( + CovenFaultPoint::ResultBeforePersistence + | CovenFaultPoint::ArtifactBeforePersistence + ) + ) { + return Err(PortError::Unavailable); + } + let correlation = state + .sessions + .get(session_id) + .and_then(|session| session.correlations.first()) + .cloned() + .ok_or(PortError::NotFound)?; + let bundle = Self::result_for(session_id, &correlation)?; + state.results.insert(session_id.to_owned(), bundle.clone()); + Ok(bundle) + } + + async fn terminate( + &self, + request: TerminationRequest, + ) -> Result { + let binding = request.binding().clone(); + let termination = binding + .termination_request + .as_ref() + .ok_or(PortError::InvalidRequest)?; + let key = termination.termination_request_id.as_str().to_owned(); + let mut state = self.state()?; + if let Some(stored) = state.terminations.get(&key) { + return if stored.binding == binding { + Ok(stored.disposition.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + let session_id = binding + .coven_session_id + .clone() + .ok_or(PortError::InvalidRequest)?; + let session = state.sessions.get(&session_id).ok_or(PortError::NotFound)?; + if !session.correlations.iter().any(|correlation| { + correlation.request_id == binding.request_id + && correlation.request_digest == binding.request_digest + }) { + return Err(PortError::CorrelationMismatch); + } + if matches!( + state.selected_fault, + Some( + CovenFaultPoint::CancellationBeforeAcknowledgement + | CovenFaultPoint::TerminalBeforePersistence + ) + ) { + return Err(PortError::Unavailable); + } + let disposition = Self::termination_disposition(&binding)?; + state.terminations.insert( + key, + DurableTermination { + binding, + disposition: disposition.clone(), + }, + ); + if matches!(disposition, TerminationDisposition::Acknowledged { .. }) { + if let Some(session) = state.sessions.get_mut(&session_id) { + session.authoritative_terminal = true; + } + } + if state.selected_fault == Some(CovenFaultPoint::CancellationAfterAcknowledgement) { + Err(PortError::Unavailable) + } else { + Ok(disposition) + } + } +} + +#[async_trait::async_trait] +impl CovenConformanceFixture for ScriptedG2Fixture { + fn port(&self) -> &dyn CovenPort { + &self.port + } + + fn availability(&self, _case: CovenConformanceCase) -> FixtureAvailability { + FixtureAvailability::Supported + } + + fn supports(&self, _point: CovenFaultPoint) -> bool { + true + } + + async fn restart(&mut self) {} + + async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError> { + let mut state = self + .port + .state + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + state.selected_fault = Some(point); + Ok(()) + } + + async fn clear_fault(&mut self) { + if let Ok(mut state) = self.port.state.lock() { + state.selected_fault = None; + } + } + + async fn reset(&mut self) { + if let Ok(mut state) = self.port.state.lock() { + *state = ScriptedState::default(); + } + } + + async fn observations(&self) -> CovenConformanceObservations { + let Ok(state) = self.port.state.lock() else { + return CovenConformanceObservations::default(); + }; + let durable_reconciliation = state + .latest_reconciliation + .as_ref() + .and_then(|key| state.reconciliations.get(key)) + .and_then(|stored| disposition_observation(&stored.disposition)); + CovenConformanceObservations { + adoption_calls: state.adoption_calls, + reconciliation_calls: state.reconciliation_calls, + durable_reconciliation, + } + } +} + +fn disposition_observation( + disposition: &ReconciliationDisposition, +) -> Option { + match disposition { + ReconciliationDisposition::Returned { + disposition_id, + session_id, + correlation, + ambiguity_digest, + recorded_at, + } => Some(DurableDispositionObservation { + disposition_id: disposition_id.clone(), + correlation: correlation.clone(), + ambiguity_digest: ambiguity_digest.clone(), + kind: DurableDispositionKind::Returned { + session_id: session_id.clone(), + }, + recorded_at: *recorded_at, + }), + ReconciliationDisposition::Fenced { + disposition_id, + fence_token, + correlation, + ambiguity_digest, + recorded_at, + } => Some(DurableDispositionObservation { + disposition_id: disposition_id.clone(), + correlation: correlation.clone(), + ambiguity_digest: ambiguity_digest.clone(), + kind: DurableDispositionKind::Fenced { + fence_token: fence_token.clone(), + }, + recorded_at: *recorded_at, + }), + ReconciliationDisposition::Unresolved => None, + } +} + +#[derive(Clone)] +struct UnsupportedPort { + error: PortError, +} + +impl fmt::Debug for UnsupportedPort { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("UnsupportedPort") + } +} + +/// Fixture used to execute and verify the structured unsupported path. +#[derive(Debug, Clone)] +pub struct UnsupportedCovenFixture { + code: String, + port: UnsupportedPort, +} + +/// Builds a fixture that denies every public operation with one exact code. +pub fn unsupported_fixture(code: &str) -> UnsupportedCovenFixture { + let error = denial_for_code(code); + UnsupportedCovenFixture { + code: code.to_owned(), + port: UnsupportedPort { error }, + } +} + +#[async_trait::async_trait] +impl CovenPort for UnsupportedPort { + async fn negotiate(&self, _request: NegotiateRequest) -> Result { + Err(self.error) + } + + async fn adopt(&self, _request: AdoptionRequest) -> Result { + Err(self.error) + } + + async fn lookup(&self, _request_id: &RequestId) -> Result { + Err(self.error) + } + + async fn reconcile( + &self, + _request: ReconciliationRequest, + ) -> Result { + Err(self.error) + } + + async fn inspect(&self, _session_id: &str) -> Result { + Err(self.error) + } + + async fn events(&self, _cursor: EventCursor) -> Result { + Err(self.error) + } + + async fn result(&self, _session_id: &str) -> Result { + Err(self.error) + } + + async fn terminate( + &self, + _request: TerminationRequest, + ) -> Result { + Err(self.error) + } +} + +#[async_trait::async_trait] +impl CovenConformanceFixture for UnsupportedCovenFixture { + fn port(&self) -> &dyn CovenPort { + &self.port + } + + fn availability(&self, _case: CovenConformanceCase) -> FixtureAvailability { + FixtureAvailability::ExpectedUnsupported { + code: self.code.clone(), + } + } + + fn supports(&self, _point: CovenFaultPoint) -> bool { + false + } + + async fn restart(&mut self) {} + + async fn select_fault(&mut self, _point: CovenFaultPoint) -> Result<(), FixtureControlError> { + Err(FixtureControlError::UnsupportedFault) + } + + async fn clear_fault(&mut self) {} + + async fn reset(&mut self) {} + + async fn observations(&self) -> CovenConformanceObservations { + CovenConformanceObservations::default() + } +} + +#[derive(Debug, Clone, Copy)] +enum UnsupportedCall { + Negotiate, + Adopt, + Lookup, + Reconcile, + Inspect, + Events, + Result, + Terminate, +} + +async fn expected_unsupported( + fixture: &mut dyn CovenConformanceFixture, + case: CovenConformanceCase, + call: UnsupportedCall, +) -> Option { + let FixtureAvailability::ExpectedUnsupported { code } = fixture.availability(case) else { + return None; + }; + fixture.reset().await; + let before = fixture.observations().await; + let expected = denial_for_code(&code); + match call { + UnsupportedCall::Negotiate => { + assert_eq!( + fixture + .port() + .negotiate(NegotiateRequest::new(CONTRACT)) + .await, + Err(expected) + ); + } + UnsupportedCall::Adopt => { + assert_eq!(fixture.port().adopt(launch_request()).await, Err(expected)); + } + UnsupportedCall::Lookup => { + assert_eq!(fixture.port().lookup(&request_id(1)).await, Err(expected)); + } + UnsupportedCall::Reconcile => { + let request = ReconciliationRequest { + correlation: launch_request().correlation(), + ambiguity_digest: digest_of('a'), + reason_code: "return_original".to_owned(), + }; + assert_eq!(fixture.port().reconcile(request).await, Err(expected)); + } + UnsupportedCall::Inspect => { + assert_eq!(fixture.port().inspect("session-1").await, Err(expected)); + } + UnsupportedCall::Events => { + assert_eq!( + fixture + .port() + .events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }) + .await, + Err(expected) + ); + } + UnsupportedCall::Result => { + assert_eq!(fixture.port().result("session-1").await, Err(expected)); + } + UnsupportedCall::Terminate => { + let requested = termination_requested_binding(&launch_request(), "operator_request"); + let mut persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate(&mut persistence, fixture.port(), requested).await, + Err(TerminationDispatchError::Port(error)) if error == expected + )); + } + } + assert_eq!(fixture.observations().await, before); + Some(ConformanceOutcome::ExpectedUnsupported { code }) +} + +fn denial_for_code(code: &str) -> PortError { + match code { + "ContractUnsupported" => PortError::ContractUnsupported {}, + "CapabilityMissing" => PortError::CapabilityMissing {}, + _ => panic!("unsupported fixture declared an unstable denial code"), + } +} + +fn capability_names() -> BTreeSet { + [ + Capability::StableAdoption, + Capability::AmbiguityFence, + Capability::OrderedEvents, + Capability::AuthoritativeTermination, + Capability::ContentAddressedResults, + ] + .into_iter() + .map(|capability| capability.as_str().to_owned()) + .collect() +} + +fn exact_negotiation_request() -> NegotiateRequest { + [ + Capability::StableAdoption, + Capability::AmbiguityFence, + Capability::OrderedEvents, + Capability::AuthoritativeTermination, + Capability::ContentAddressedResults, + ] + .into_iter() + .fold(NegotiateRequest::new(CONTRACT), |request, capability| { + request.requiring(capability) + }) +} + +fn launch_input() -> ExecutionRequestInput { + match serde_json::from_slice(LAUNCH_GOLDEN) { + Ok(input) => input, + Err(error) => panic!("canonical launch fixture must decode: {error}"), + } +} + +fn input_input() -> ExecutionRequestInput { + match serde_json::from_slice(INPUT_GOLDEN) { + Ok(input) => input, + Err(error) => panic!("canonical input fixture must decode: {error}"), + } +} + +fn launch_request() -> AdoptionRequest { + match AdoptionRequest::new(launch_input()) { + Ok(request) => request, + Err(error) => panic!("canonical launch fixture must validate: {error}"), + } +} + +fn input_request() -> AdoptionRequest { + match AdoptionRequest::new(input_input()) { + Ok(request) => request, + Err(error) => panic!("canonical input fixture must validate: {error}"), + } +} + +fn session_input_request() -> AdoptionRequest { + let mut value: serde_json::Value = match serde_json::from_slice(INPUT_GOLDEN) { + Ok(value) => value, + Err(error) => panic!("canonical input fixture must decode: {error}"), + }; + value["request_id"] = serde_json::json!("req_01J00000000000000000000003"); + let input: ExecutionRequestInput = match serde_json::from_value(value) { + Ok(value) => value, + Err(error) => panic!("session input fixture must remain typed: {error}"), + }; + match AdoptionRequest::new(input) { + Ok(request) => request, + Err(error) => panic!("session input fixture must validate: {error}"), + } +} + +fn stale_digest_mutations(request: &AdoptionRequest) -> Vec<(&'static str, AdoptionRequest)> { + let launch = matches!(request.input(), ExecutionRequestInput::Launch { .. }); + let mut mutations: Vec<(&str, serde_json::Value)> = if launch { + vec![ + ( + "/input/schema_version", + serde_json::json!("psyche.execution_request.v2"), + ), + ( + "/input/request_id", + serde_json::json!("req_01J00000000000000000000011"), + ), + ( + "/input/graph_id", + serde_json::json!("grf_01J00000000000000000000011"), + ), + ( + "/input/node_id", + serde_json::json!("nod_01J00000000000000000000011"), + ), + ( + "/input/attempt_id", + serde_json::json!("att_01J00000000000000000000011"), + ), + ( + "/input/principal_id", + serde_json::json!("principal:changed"), + ), + ( + "/input/familiar_snapshot_id", + serde_json::json!("ids_01J00000000000000000000011"), + ), + ( + "/input/project_id", + serde_json::json!("project:sha256:changed"), + ), + ("/input/project_root", serde_json::json!("/workspace/other")), + ( + "/input/cwd", + serde_json::json!("/workspace/project/subdirectory"), + ), + ("/input/harness", serde_json::json!("future_harness")), + ( + "/input/context_manifest_digest", + serde_json::json!(digest_of('7').as_str()), + ), + ( + "/input/delegation_digest", + serde_json::json!(digest_of('8').as_str()), + ), + ( + "/input/budget_digest", + serde_json::json!(digest_of('9').as_str()), + ), + ( + "/input/required_artifact_bindings/0/artifact_id", + serde_json::json!("artifact-changed"), + ), + ( + "/input/required_artifact_bindings/0/digest", + serde_json::json!(digest_of('a').as_str()), + ), + ( + "/input/required_artifact_bindings/0/media_type", + serde_json::json!("application/json"), + ), + ( + "/input/required_artifact_bindings/0/size", + serde_json::json!(13), + ), + ( + "/input/required_artifact_bindings", + serde_json::json!([ + { + "artifact_id": "artifact-2", + "digest": digest_of('a').as_str(), + "media_type": "application/json", + "size": 7 + }, + { + "artifact_id": "artifact-1", + "digest": digest_of('3').as_str(), + "media_type": "text/plain", + "size": 12 + } + ]), + ), + ( + "/input/payload_digest", + serde_json::json!(digest_of('b').as_str()), + ), + ( + "/input/created_at", + serde_json::json!("2026-08-05T14:00:01Z"), + ), + ( + "/input/valid_until", + serde_json::json!("2026-08-05T14:04:59Z"), + ), + ] + } else { + vec![ + ( + "/input/schema_version", + serde_json::json!("psyche.execution_request.v2"), + ), + ( + "/input/request_id", + serde_json::json!("req_01J00000000000000000000011"), + ), + ( + "/input/graph_id", + serde_json::json!("grf_01J00000000000000000000011"), + ), + ( + "/input/node_id", + serde_json::json!("nod_01J00000000000000000000011"), + ), + ( + "/input/attempt_id", + serde_json::json!("att_01J00000000000000000000011"), + ), + ( + "/input/principal_id", + serde_json::json!("principal:changed"), + ), + ( + "/input/familiar_snapshot_id", + serde_json::json!("ids_01J00000000000000000000011"), + ), + ( + "/input/project_id", + serde_json::json!("project:sha256:changed"), + ), + ("/input/session_id", serde_json::json!("session-changed")), + ( + "/input/input_digest", + serde_json::json!(digest_of('7').as_str()), + ), + ( + "/input/context_manifest_digest", + serde_json::json!(digest_of('8').as_str()), + ), + ( + "/input/required_artifact_bindings", + serde_json::json!([{ + "artifact_id": "artifact-new", + "digest": digest_of('9').as_str(), + "media_type": "text/plain", + "size": 1 + }]), + ), + ( + "/input/payload_digest", + serde_json::json!(digest_of('a').as_str()), + ), + ( + "/input/created_at", + serde_json::json!("2026-08-05T14:01:01Z"), + ), + ( + "/input/valid_until", + serde_json::json!("2026-08-05T14:05:59Z"), + ), + ] + }; + let mut other_input: serde_json::Value = if launch { + match serde_json::from_slice(INPUT_GOLDEN) { + Ok(value) => value, + Err(error) => panic!("canonical input fixture must decode: {error}"), + } + } else { + match serde_json::from_slice(LAUNCH_GOLDEN) { + Ok(value) => value, + Err(error) => panic!("canonical launch fixture must decode: {error}"), + } + }; + other_input["request_id"] = serde_json::json!(request.correlation().request_id.as_str()); + mutations.push(("/input", other_input)); + mutations + .into_iter() + .map(|(pointer, replacement)| { + let mut value = match serde_json::to_value(request) { + Ok(value) => value, + Err(error) => panic!("typed adoption request must serialize: {error}"), + }; + let Some(field) = value.pointer_mut(pointer) else { + panic!("static request mutation pointer must exist: {pointer}"); + }; + *field = replacement; + let forged = match serde_json::from_value(value) { + Ok(value) => value, + Err(error) => panic!("stale-digest request must remain typed: {error}"), + }; + (pointer, forged) + }) + .collect() +} + +fn changed_correlations( + correlation: &ExecutionCorrelation, +) -> Vec<(&'static str, ExecutionCorrelation)> { + let mut changed = Vec::new(); + let mut candidate = correlation.clone(); + candidate.request_digest = digest_of('a'); + changed.push(("request_digest", candidate)); + let mut candidate = correlation.clone(); + candidate.familiar_snapshot_id = record_id(RecordKind::IdentitySnapshot, 11); + changed.push(("familiar_snapshot_id", candidate)); + let mut candidate = correlation.clone(); + candidate.project_id = "project:sha256:changed".to_owned(); + changed.push(("project_id", candidate)); + let mut candidate = correlation.clone(); + candidate.graph_id = record_id(RecordKind::Graph, 11); + changed.push(("graph_id", candidate)); + let mut candidate = correlation.clone(); + candidate.node_id = record_id(RecordKind::GraphNode, 11); + changed.push(("node_id", candidate)); + let mut candidate = correlation.clone(); + candidate.attempt_id = record_id(RecordKind::Attempt, 11); + changed.push(("attempt_id", candidate)); + let mut candidate = correlation.clone(); + candidate.valid_until -= time::Duration::seconds(1); + changed.push(("validity_window", candidate)); + changed +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryPersistenceError { + Conflict, + Canonicalization, +} + +#[derive(Debug, Default)] +struct MemoryTerminationPersistence { + revisions: BTreeMap<(String, u64), Vec>, +} + +impl TerminationPersistence for MemoryTerminationPersistence { + type Error = MemoryPersistenceError; + + fn persist_requested( + &mut self, + requested: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.persist(requested) + } + + fn persist_outcome( + &mut self, + outcome: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + self.persist(outcome) + } +} + +impl MemoryTerminationPersistence { + fn persist( + &mut self, + binding: ExecutionBinding, + ) -> Result, TerminationPersistenceFailure> { + let bytes = canonical_bytes(&binding).map_err(|_| { + TerminationPersistenceFailure::Write(MemoryPersistenceError::Canonicalization) + })?; + let key = (binding.attempt_id.as_str().to_owned(), binding.revision); + if let Some(stored) = self.revisions.get(&key) { + return if stored == &bytes { + Ok(stored.clone()) + } else { + Err(TerminationPersistenceFailure::Conflict( + MemoryPersistenceError::Conflict, + )) + }; + } + self.revisions.insert(key, bytes.clone()); + Ok(bytes) + } +} + +fn termination_requested_binding(adoption: &AdoptionRequest, reason: &str) -> ExecutionBinding { + let correlation = adoption.correlation(); + ExecutionBinding { + schema_version: schema("psyche.execution_binding.v1"), + attempt_id: correlation.attempt_id, + revision: 2, + previous_revision_digest: Some(digest_of('f')), + revision_created_at: correlation.created_at + time::Duration::minutes(2), + familiar_snapshot_id: correlation.familiar_snapshot_id, + project_id: correlation.project_id, + request_id: correlation.request_id, + request_digest: correlation.request_digest, + request_created_at: correlation.created_at, + request_valid_until: correlation.valid_until, + coven_contract_version: CONTRACT.to_owned(), + coven_session_id: Some("session-1".to_owned()), + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:0".to_owned()), + cancellation_state: CancellationState::TerminationRequested, + termination_request: Some(TerminationRequestCorrelation { + termination_request_id: request_id(9), + created_at: at("2026-08-05T14:02:00Z"), + valid_until: at("2026-08-05T14:04:00Z"), + }), + termination_reason_code: Some(reason.to_owned()), + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn at(value: &str) -> time::OffsetDateTime { + use time::format_description::well_known::Rfc3339; + + match time::OffsetDateTime::parse(value, &Rfc3339) { + Ok(value) => value, + Err(error) => panic!("static RFC 3339 timestamp is valid: {error}"), + } +} + +fn digest_of(character: char) -> Sha256Digest { + let value = format!("sha256:{}", character.to_string().repeat(64)); + match Sha256Digest::parse(&value) { + Ok(value) => value, + Err(error) => panic!("static SHA-256 digest is valid: {error}"), + } +} + +fn digest_for_sequence(sequence: u64) -> Sha256Digest { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let index = usize::try_from(sequence % 16).unwrap_or(0); + digest_of(char::from(HEX[index])) +} + +fn schema(value: &str) -> SchemaVersion { + match SchemaVersion::parse(value) { + Ok(value) => value, + Err(error) => panic!("static schema version is valid: {error}"), + } +} + +fn record_id(kind: RecordKind, value: u8) -> RecordId { + let suffix = format!("01J000000000000000000000{value:02}"); + match RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())) { + Ok(value) => value, + Err(error) => panic!("static record identity is valid: {error}"), + } +} + +fn request_id(value: u8) -> RequestId { + let value = format!("req_01J000000000000000000000{value:02}"); + match RequestId::parse(&value) { + Ok(value) => value, + Err(error) => panic!("static request identity is valid: {error}"), + } +} + +/// Verifies exact contract negotiation and fail-closed capability handling. +pub async fn assert_c_s1_contract_negotiation( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S1, + UnsupportedCall::Negotiate, + ) + .await + { + return outcome; + } + fixture.reset().await; + let before = fixture.observations().await; + let profile = fixture + .port() + .negotiate(exact_negotiation_request()) + .await + .unwrap_or_else(|error| panic!("exact G2 contract must negotiate: {error}")); + assert_eq!( + profile, + CapabilityProfile { + api_version: CONTRACT.to_owned(), + capabilities: capability_names(), + } + ); + + for unsupported in ["coven.daemon.v0", "coven.daemon.v2"] { + assert_eq!( + fixture + .port() + .negotiate(NegotiateRequest::new(unsupported)) + .await, + Err(PortError::ContractUnsupported {}) + ); + } + let mut missing = NegotiateRequest::new(CONTRACT); + missing + .required_capabilities + .insert("future_capability".to_owned()); + assert_eq!( + fixture.port().negotiate(missing).await, + Err(PortError::CapabilityMissing {}) + ); + let mut false_method = exact_negotiation_request(); + false_method + .required_capabilities + .insert("falsely_advertised_method".to_owned()); + assert_eq!( + fixture.port().negotiate(false_method).await, + Err(PortError::CapabilityMissing {}) + ); + assert_eq!(fixture.observations().await, before); + ConformanceOutcome::Verified +} + +/// Verifies launch, input attachment, observation, close, and lifecycle rejection. +pub async fn assert_c_s2_session_lifecycle( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = + expected_unsupported(fixture, CovenConformanceCase::C_S2, UnsupportedCall::Adopt).await + { + return outcome; + } + fixture.reset().await; + let launch = launch_request(); + let launch_correlation = launch.correlation(); + let adopted = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + assert_eq!( + fixture.port().adopt(launch.clone()).await, + Ok(adopted.clone()) + ); + assert_eq!( + fixture.port().adopt(session_input_request()).await, + Ok(adopted) + ); + let snapshot = fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("adopted session must be observable: {error}")); + assert_eq!(snapshot.session_id, "session-1"); + assert_eq!(snapshot.correlation, launch_correlation); + + let requested = termination_requested_binding(&launch, "operator_request"); + let mut persistence = MemoryTerminationPersistence::default(); + let disposition = persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) + .await + .unwrap_or_else(|error| panic!("persisted session close must succeed: {error}")); + assert!(matches!( + disposition, + TerminationDisposition::Acknowledged { .. } + )); + let closed = fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("closed session must remain observable: {error}")); + assert_eq!( + closed.terminal_state.as_deref(), + Some("authoritatively_terminated") + ); + + for (pointer, replacement) in [ + ("/cwd", serde_json::json!("/outside/project")), + ("/harness", serde_json::json!("unknown_harness")), + ] { + let mut value: serde_json::Value = match serde_json::from_slice(LAUNCH_GOLDEN) { + Ok(value) => value, + Err(error) => panic!("launch fixture must decode: {error}"), + }; + let Some(field) = value.pointer_mut(pointer) else { + panic!("static lifecycle mutation pointer must exist"); + }; + *field = replacement; + let input: ExecutionRequestInput = match serde_json::from_value(value) { + Ok(value) => value, + Err(error) => panic!("invalid lifecycle input must remain typed: {error}"), + }; + assert_eq!(AdoptionRequest::new(input), Err(PortError::InvalidRequest)); + } + + fixture.reset().await; + assert_eq!( + fixture.port().adopt(session_input_request()).await, + Err(PortError::NotFound) + ); + assert_eq!( + fixture.port().inspect("session-1").await, + Err(PortError::NotFound) + ); + let mut persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate( + &mut persistence, + fixture.port(), + termination_requested_binding(&launch, "operator_request"), + ) + .await, + Err(TerminationDispatchError::Port(PortError::NotFound)) + )); + ConformanceOutcome::Verified +} + +/// Verifies exact snapshot echo and the complete independent correlation matrix. +pub async fn assert_c_s3_snapshot_attempt_binding( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S3, + UnsupportedCall::Inspect, + ) + .await + { + return outcome; + } + fixture.reset().await; + let adoption = launch_request(); + let correlation = adoption.correlation(); + assert!(matches!( + fixture.port().adopt(adoption).await, + Ok(AdoptionDisposition::Adopted { .. }) + )); + let snapshot = fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("snapshot must round-trip: {error}")); + assert_eq!(snapshot.correlation, correlation); + assert_eq!(snapshot.session_id, "session-1"); + + let changed_correlations = changed_correlations(&correlation); + let changed_count = u64::try_from(changed_correlations.len()) + .unwrap_or_else(|_| panic!("correlation matrix length must fit u64")); + for (field, changed) in changed_correlations { + let request = ReconciliationRequest { + correlation: changed, + ambiguity_digest: digest_of('d'), + reason_code: "return_original".to_owned(), + }; + assert_eq!( + fixture.port().reconcile(request).await, + Err(PortError::IntentConflict), + "{field}" + ); + } + let observations = fixture.observations().await; + assert_eq!(observations.adoption_calls, 1); + assert_eq!(observations.reconciliation_calls, changed_count); + assert!(observations.durable_reconciliation.is_none()); + ConformanceOutcome::Verified +} + +/// Verifies stable adoption, full digest recomputation, and every-field binding. +pub async fn assert_c_s4_stable_adoption( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = + expected_unsupported(fixture, CovenConformanceCase::C_S4, UnsupportedCall::Adopt).await + { + return outcome; + } + fixture.reset().await; + let request = launch_request(); + assert_eq!( + request.recompute_digest(), + Ok(request.request_digest().clone()) + ); + request + .validate_digest() + .unwrap_or_else(|error| panic!("authority must recompute the canonical request: {error}")); + fixture + .select_fault(CovenFaultPoint::AdoptionAfterCommit) + .await + .unwrap_or_else(|error| panic!("adoption fault must be controllable: {error}")); + assert_eq!( + fixture.port().adopt(request.clone()).await, + Err(PortError::Unavailable) + ); + assert_eq!(fixture.observations().await.adoption_calls, 1); + fixture.restart().await; + fixture.clear_fault().await; + let disposition = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + assert_eq!( + fixture.port().adopt(request.clone()).await, + Ok(disposition.clone()) + ); + assert_eq!(fixture.observations().await.adoption_calls, 1); + assert_eq!(fixture.port().adopt(request.clone()).await, Ok(disposition)); + assert_eq!(fixture.observations().await.adoption_calls, 1); + + for typed in [request, input_request()] { + for (field, forged) in stale_digest_mutations(&typed) { + let before = fixture.observations().await; + assert_eq!( + fixture.port().adopt(forged).await, + Err(PortError::RequestDigestMismatch), + "{field}" + ); + assert_eq!(fixture.observations().await, before, "{field}"); + } + } + ConformanceOutcome::Verified +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RedispatchDecision { + Blocked, + RedispatchEligible, +} + +/// Verifies adopted, proven-not-adopted, and unknown lookup authority. +pub async fn assert_c_s5_non_adoption_proof( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = + expected_unsupported(fixture, CovenConformanceCase::C_S5, UnsupportedCall::Lookup).await + { + return outcome; + } + fixture.reset().await; + let launch = launch_request(); + let adopted = AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }; + assert_eq!( + fixture.port().adopt(launch.clone()).await, + Ok(adopted.clone()) + ); + assert_eq!( + fixture + .port() + .lookup(&launch.correlation().request_id) + .await, + Ok(adopted) + ); + let not_adopted = fixture + .port() + .lookup(&request_id(1)) + .await + .unwrap_or_else(|error| panic!("durable non-adoption proof must be available: {error}")); + let unknown = fixture + .port() + .lookup(&request_id(2)) + .await + .unwrap_or_else(|error| panic!("unknown adoption must be explicit: {error}")); + assert_eq!(not_adopted, AdoptionDisposition::ProvenNotAdopted); + assert_eq!(unknown, AdoptionDisposition::Unknown); + fixture.restart().await; + assert_eq!( + fixture.port().lookup(&request_id(1)).await, + Ok(AdoptionDisposition::ProvenNotAdopted) + ); + assert_eq!( + fixture.port().lookup(&request_id(2)).await, + Ok(AdoptionDisposition::Unknown) + ); + assert_eq!( + redispatch_decision(&AdoptionDisposition::ProvenNotAdopted), + RedispatchDecision::RedispatchEligible + ); + assert_eq!(redispatch_decision(&unknown), RedispatchDecision::Blocked); + assert_eq!( + redispatch_decision(&AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }), + RedispatchDecision::Blocked + ); + ConformanceOutcome::Verified +} + +fn redispatch_decision(disposition: &AdoptionDisposition) -> RedispatchDecision { + if disposition == &AdoptionDisposition::ProvenNotAdopted { + RedispatchDecision::RedispatchEligible + } else { + RedispatchDecision::Blocked + } +} + +/// Verifies correlation-bound return-or-fence recovery without redispatch. +pub async fn assert_c_s6_ambiguity_fence( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S6, + UnsupportedCall::Reconcile, + ) + .await + { + return outcome; + } + + assert_reconciliation_terminal(fixture, false).await; + assert_reconciliation_terminal(fixture, true).await; + + for point in [ + CovenFaultPoint::ReconcileBeforeDisposition, + CovenFaultPoint::ReconcileStall, + ] { + fixture.reset().await; + let correlation = mark_ambiguous(fixture).await; + let request = reconciliation_request(correlation, false); + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + let expected_error = if point == CovenFaultPoint::ReconcileStall { + PortError::Stalled + } else { + PortError::Unavailable + }; + assert_eq!( + fixture.port().reconcile(request.clone()).await, + Err(expected_error) + ); + assert_eq!( + fixture.observations().await, + CovenConformanceObservations { + adoption_calls: 1, + reconciliation_calls: 1, + durable_reconciliation: None, + } + ); + fixture.restart().await; + assert_eq!( + fixture.port().reconcile(request.clone()).await, + Err(expected_error) + ); + let blocked = fixture.observations().await; + assert_eq!(blocked.adoption_calls, 1); + assert_eq!(blocked.reconciliation_calls, 2); + assert!(blocked.durable_reconciliation.is_none()); + fixture.clear_fault().await; + let recovered = fixture + .port() + .reconcile(request) + .await + .unwrap_or_else(|error| panic!("cleared reconciliation must recover: {error}")); + assert!(matches!( + recovered, + ReconciliationDisposition::Returned { .. } + )); + let recovered_observations = fixture.observations().await; + assert_eq!(recovered_observations.adoption_calls, 1); + assert_eq!(recovered_observations.reconciliation_calls, 3); + } + + fixture.reset().await; + let correlation = mark_ambiguous(fixture).await; + let request = reconciliation_request(correlation, true); + fixture + .select_fault(CovenFaultPoint::ReconcileAfterDisposition) + .await + .unwrap_or_else(|error| panic!("after-disposition fault must be controllable: {error}")); + assert_eq!( + fixture.port().reconcile(request.clone()).await, + Err(PortError::Unavailable) + ); + let committed = fixture.observations().await; + assert_eq!(committed.adoption_calls, 1); + assert_eq!(committed.reconciliation_calls, 1); + let committed_observation = committed + .durable_reconciliation + .clone() + .unwrap_or_else(|| panic!("after-disposition fault must retain durable fence")); + assert!(matches!( + committed_observation.kind, + DurableDispositionKind::Fenced { .. } + )); + fixture.restart().await; + fixture.clear_fault().await; + let replay = fixture + .port() + .reconcile(request) + .await + .unwrap_or_else(|error| panic!("durable fence must replay after restart: {error}")); + assert_eq!( + disposition_observation(&replay), + Some(committed_observation) + ); + let replayed = fixture.observations().await; + assert_eq!(replayed.adoption_calls, 1); + assert_eq!(replayed.reconciliation_calls, 2); + ConformanceOutcome::Verified +} + +async fn mark_ambiguous(fixture: &mut dyn CovenConformanceFixture) -> ExecutionCorrelation { + let adoption = launch_request(); + let correlation = adoption.correlation(); + fixture + .select_fault(CovenFaultPoint::AdoptionAfterCommit) + .await + .unwrap_or_else(|error| panic!("after-adoption fault must be controllable: {error}")); + assert_eq!( + fixture.port().adopt(adoption).await, + Err(PortError::Unavailable) + ); + fixture.clear_fault().await; + fixture + .select_fault(CovenFaultPoint::LookupAfterRead) + .await + .unwrap_or_else(|error| panic!("after-lookup fault must be controllable: {error}")); + let local_disposition = match fixture.port().lookup(&correlation.request_id).await { + Err(PortError::Unavailable) => AdoptionDisposition::Unknown, + other => panic!("lost lookup response must remain locally unknown: {other:?}"), + }; + assert_eq!(local_disposition, AdoptionDisposition::Unknown); + fixture.clear_fault().await; + assert_eq!(fixture.observations().await.adoption_calls, 1); + correlation +} + +fn reconciliation_request( + correlation: ExecutionCorrelation, + fenced: bool, +) -> ReconciliationRequest { + ReconciliationRequest { + correlation, + ambiguity_digest: digest_of('d'), + reason_code: if fenced { + "fence_ambiguous" + } else { + "return_original" + } + .to_owned(), + } +} + +async fn assert_reconciliation_terminal(fixture: &mut dyn CovenConformanceFixture, fenced: bool) { + fixture.reset().await; + let correlation = mark_ambiguous(fixture).await; + let request = reconciliation_request(correlation.clone(), fenced); + let disposition = fixture + .port() + .reconcile(request.clone()) + .await + .unwrap_or_else(|error| panic!("terminal reconciliation must succeed: {error}")); + disposition + .validate_for(&request) + .unwrap_or_else(|error| panic!("terminal reconciliation must be correlated: {error}")); + match &disposition { + ReconciliationDisposition::Returned { + session_id, + correlation: echoed, + ambiguity_digest, + disposition_id, + recorded_at, + } => { + assert!(!fenced); + assert_eq!(session_id, "session-1"); + assert_eq!(echoed, &correlation); + assert_eq!(ambiguity_digest, &request.ambiguity_digest); + assert!(!disposition_id.is_empty()); + assert!(*recorded_at >= correlation.created_at); + let resumed = fixture + .port() + .inspect(session_id) + .await + .unwrap_or_else(|error| panic!("returned session must resume: {error}")); + assert_eq!(resumed.correlation, correlation); + assert_eq!( + redispatch_decision(&AdoptionDisposition::Adopted { + session_id: session_id.clone(), + }), + RedispatchDecision::Blocked + ); + assert_eq!( + reconciliation_redispatch_decision(&disposition), + RedispatchDecision::Blocked + ); + } + ReconciliationDisposition::Fenced { + fence_token, + correlation: echoed, + ambiguity_digest, + disposition_id, + recorded_at, + } => { + assert!(fenced); + assert!(!fence_token.is_empty()); + assert_eq!(echoed, &correlation); + assert_eq!(ambiguity_digest, &request.ambiguity_digest); + assert!(!disposition_id.is_empty()); + assert!(*recorded_at >= correlation.created_at); + assert_eq!( + reconciliation_redispatch_decision(&disposition), + RedispatchDecision::RedispatchEligible + ); + } + ReconciliationDisposition::Unresolved => { + panic!("terminal script must not return unresolved") + } + } + + let first_observation = fixture + .observations() + .await + .durable_reconciliation + .unwrap_or_else(|| panic!("terminal disposition must be durably observable")); + assert_eq!( + disposition_observation(&disposition), + Some(first_observation.clone()) + ); + fixture.restart().await; + let replay = fixture + .port() + .reconcile(request.clone()) + .await + .unwrap_or_else(|error| panic!("terminal disposition must replay: {error}")); + assert_eq!(replay, disposition); + assert_eq!( + fixture.observations().await.durable_reconciliation, + Some(first_observation.clone()) + ); + + let changed = changed_correlations(&correlation); + let changed_count = u64::try_from(changed.len()) + .unwrap_or_else(|_| panic!("correlation matrix length must fit u64")); + for (field, changed) in changed { + let changed_request = ReconciliationRequest { + correlation: changed, + ..request.clone() + }; + assert_eq!( + fixture.port().reconcile(changed_request).await, + Err(PortError::IntentConflict), + "{field}" + ); + } + let changed_digest = ReconciliationRequest { + ambiguity_digest: digest_of('e'), + ..request + }; + assert_eq!( + fixture.port().reconcile(changed_digest).await, + Err(PortError::IntentConflict), + "ambiguity_digest" + ); + let observations = fixture.observations().await; + assert_eq!(observations.adoption_calls, 1); + assert_eq!(observations.reconciliation_calls, 3 + changed_count); + assert_eq!(observations.durable_reconciliation, Some(first_observation)); +} + +fn reconciliation_redispatch_decision( + disposition: &ReconciliationDisposition, +) -> RedispatchDecision { + if matches!(disposition, ReconciliationDisposition::Fenced { .. }) { + RedispatchDecision::RedispatchEligible + } else { + RedispatchDecision::Blocked + } +} + +/// Verifies ordered, restart-stable cursors without gaps, duplicates, or drift. +pub async fn assert_c_s7_ordered_cursor( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = + expected_unsupported(fixture, CovenConformanceCase::C_S7, UnsupportedCall::Events).await + { + return outcome; + } + fixture.reset().await; + assert!(matches!( + fixture.port().adopt(launch_request()).await, + Ok(AdoptionDisposition::Adopted { .. }) + )); + let initial = EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }; + fixture + .select_fault(CovenFaultPoint::CursorBeforePage) + .await + .unwrap_or_else(|error| panic!("before-page fault must be controllable: {error}")); + assert_eq!( + fixture.port().events(initial.clone()).await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + let first = fixture + .port() + .events(initial.clone()) + .await + .unwrap_or_else(|error| panic!("cursor must recover before-page fault: {error}")); + assert_eq!( + first + .events + .iter() + .map(|event| event.sequence) + .collect::>(), + vec![1, 2, 3] + ); + + fixture.restart().await; + let mut cursor = first.next_cursor.clone(); + let mut all = first.events; + while cursor.after_sequence < RAW_LEDGER_STATES.len() as u64 { + let page = fixture + .port() + .events(cursor.clone()) + .await + .unwrap_or_else(|error| panic!("ordered cursor page must succeed: {error}")); + assert_eq!(page.next_cursor.session_id, cursor.session_id); + all.extend(page.events); + cursor = page.next_cursor; + } + assert_eq!( + all.iter().map(|event| event.sequence).collect::>(), + (1..=RAW_LEDGER_STATES.len() as u64).collect::>() + ); + let unique = all + .iter() + .map(|event| event.sequence) + .collect::>(); + assert_eq!(unique.len(), all.len()); + assert_eq!( + all.iter() + .map(|event| event.terminal_state.as_deref()) + .collect::>(), + RAW_LEDGER_STATES + .iter() + .copied() + .map(Some) + .collect::>() + ); + assert_eq!( + fixture + .port() + .events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 1, + }) + .await, + Err(PortError::IntentConflict) + ); + assert_eq!( + fixture + .port() + .events(EventCursor { + session_id: "foreign-session".to_owned(), + after_sequence: 0, + }) + .await, + Err(PortError::CorrelationMismatch) + ); + + fixture.reset().await; + assert!(fixture.port().adopt(launch_request()).await.is_ok()); + fixture + .select_fault(CovenFaultPoint::CursorAfterPage) + .await + .unwrap_or_else(|error| panic!("after-page fault must be controllable: {error}")); + assert_eq!( + fixture.port().events(initial.clone()).await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + assert_eq!( + fixture + .port() + .events(initial) + .await + .unwrap_or_else(|error| panic!("committed page must replay: {error}")) + .next_cursor + .after_sequence, + 3 + ); + ConformanceOutcome::Verified +} + +/// Verifies that only durable typed authority can establish terminal state. +pub async fn assert_c_s8_terminal_authority( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S8, + UnsupportedCall::Inspect, + ) + .await + { + return outcome; + } + fixture.reset().await; + let launch = launch_request(); + assert!(fixture.port().adopt(launch.clone()).await.is_ok()); + let snapshot = fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("raw session status must be readable: {error}")); + assert_eq!(snapshot.terminal_state.as_deref(), Some("created")); + let raw_page = fixture + .port() + .events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }) + .await + .unwrap_or_else(|error| panic!("raw ledger events must be readable: {error}")); + for raw in std::iter::once(snapshot.terminal_state.as_deref()).chain( + raw_page + .events + .iter() + .map(|event| event.terminal_state.as_deref()), + ) { + assert!(raw.is_some()); + assert_ne!(raw, Some("authoritatively_terminated")); + } + + let requested = termination_requested_binding(&launch, "operator_request"); + let mut unproven = requested.clone(); + unproven.cancellation_state = CancellationState::AcknowledgedTerminated; + unproven.terminal_state = Some("process_exited".to_owned()); + assert!(unproven.validate().is_err()); + unproven.terminal_state = Some("disconnected".to_owned()); + assert!(unproven.validate().is_err()); + + fixture + .select_fault(CovenFaultPoint::TerminalBeforePersistence) + .await + .unwrap_or_else(|error| panic!("terminal persistence fault must be controllable: {error}")); + let mut persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate(&mut persistence, fixture.port(), requested.clone(),).await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + fixture.restart().await; + let still_raw = fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| { + panic!("unpersisted terminal must remain observable only as raw: {error}") + }); + assert_ne!( + still_raw.terminal_state.as_deref(), + Some("authoritatively_terminated") + ); + fixture.clear_fault().await; + let acknowledged = persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) + .await + .unwrap_or_else(|error| panic!("durable terminal acknowledgement must succeed: {error}")); + assert!(matches!( + acknowledged, + TerminationDisposition::Acknowledged { .. } + )); + fixture.restart().await; + let replay = persist_then_terminate(&mut persistence, fixture.port(), requested) + .await + .unwrap_or_else(|error| panic!("durable terminal acknowledgement must replay: {error}")); + assert_eq!(replay, acknowledged); + assert_eq!( + fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("durable terminal must survive restart: {error}")) + .terminal_state + .as_deref(), + Some("authoritatively_terminated") + ); + ConformanceOutcome::Verified +} + +/// Verifies core-owned, correlated, durable cancellation acknowledgement. +pub async fn assert_c_s9_cancellation_acknowledgement( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S9, + UnsupportedCall::Terminate, + ) + .await + { + return outcome; + } + fixture.reset().await; + let launch = launch_request(); + assert!(fixture.port().adopt(launch.clone()).await.is_ok()); + + let mut snapshot_states = Vec::new(); + for _ in 0..6 { + snapshot_states.push( + fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("raw snapshot must be readable: {error}")) + .terminal_state + .unwrap_or_else(|| panic!("scripted snapshot must name its raw state")), + ); + } + assert_eq!(snapshot_states.last().map(String::as_str), Some("killed")); + fixture.restart().await; + snapshot_states.push( + fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("restart snapshot must be readable: {error}")) + .terminal_state + .unwrap_or_else(|| panic!("restart snapshot must remain raw")), + ); + assert_eq!( + snapshot_states, + RAW_LEDGER_STATES + .iter() + .map(|state| (*state).to_owned()) + .collect::>() + ); + + let mut event_states = Vec::new(); + let mut cursor = EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }; + loop { + let page = fixture + .port() + .events(cursor) + .await + .unwrap_or_else(|error| panic!("raw event table must be readable: {error}")); + event_states.extend(page.events.into_iter().map(|event| { + event + .terminal_state + .unwrap_or_else(|| panic!("scripted raw event must name its state")) + })); + cursor = page.next_cursor; + if cursor.after_sequence == RAW_LEDGER_STATES.len() as u64 { + break; + } + } + assert_eq!(event_states, snapshot_states); + let requested = termination_requested_binding(&launch, "operator_request"); + for state in &snapshot_states { + let mut raw_only = requested.clone(); + raw_only.cancellation_state = CancellationState::AcknowledgedTerminated; + raw_only.terminal_state = Some(state.clone()); + assert!(raw_only.validate().is_err(), "{state}"); + } + + fixture.reset().await; + assert!(fixture.port().adopt(launch.clone()).await.is_ok()); + let unresolved_requested = termination_requested_binding(&launch, "force_unresolved"); + fixture + .select_fault(CovenFaultPoint::CancellationBeforeAcknowledgement) + .await + .unwrap_or_else(|error| { + panic!("before-acknowledgement fault must be controllable: {error}") + }); + let mut unresolved_persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate( + &mut unresolved_persistence, + fixture.port(), + unresolved_requested.clone(), + ) + .await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + fixture.restart().await; + fixture.clear_fault().await; + let unresolved = persist_then_terminate( + &mut unresolved_persistence, + fixture.port(), + unresolved_requested.clone(), + ) + .await + .unwrap_or_else(|error| panic!("silence must resolve only to typed unresolved: {error}")); + let TerminationDisposition::Unresolved { evidence } = &unresolved else { + panic!("silence cannot produce acknowledgement"); + }; + evidence + .validate() + .unwrap_or_else(|error| panic!("unresolved evidence must be valid: {error}")); + let unresolved_binding = + derive_termination_outcome_revision(&unresolved_requested, &unresolved) + .unwrap_or_else(|error| panic!("unresolved evidence must derive an outcome: {error}")); + assert_eq!( + unresolved_binding.cancellation_state, + CancellationState::TerminationUnknown + ); + fixture.restart().await; + assert_eq!( + persist_then_terminate( + &mut unresolved_persistence, + fixture.port(), + unresolved_requested, + ) + .await + .unwrap_or_else(|error| panic!("unresolved disposition must replay: {error}")), + unresolved + ); + + fixture.reset().await; + assert!(fixture.port().adopt(launch.clone()).await.is_ok()); + let acknowledged_requested = termination_requested_binding(&launch, "operator_request"); + fixture + .select_fault(CovenFaultPoint::CancellationAfterAcknowledgement) + .await + .unwrap_or_else(|error| { + panic!("after-acknowledgement fault must be controllable: {error}") + }); + let mut acknowledged_persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate( + &mut acknowledged_persistence, + fixture.port(), + acknowledged_requested.clone(), + ) + .await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + fixture.restart().await; + fixture.clear_fault().await; + let acknowledged = persist_then_terminate( + &mut acknowledged_persistence, + fixture.port(), + acknowledged_requested.clone(), + ) + .await + .unwrap_or_else(|error| panic!("durable acknowledgement must recover: {error}")); + let TerminationDisposition::Acknowledged { evidence } = &acknowledged else { + panic!("authority acknowledgement must remain typed"); + }; + evidence + .validate() + .unwrap_or_else(|error| panic!("authority acknowledgement must validate: {error}")); + let acknowledged_binding = + derive_termination_outcome_revision(&acknowledged_requested, &acknowledged) + .unwrap_or_else(|error| panic!("acknowledgement must derive an outcome: {error}")); + assert_eq!( + acknowledged_binding.cancellation_state, + CancellationState::AcknowledgedTerminated + ); + assert_invalid_acknowledgements(&acknowledged_requested, evidence); + fixture.restart().await; + assert_eq!( + persist_then_terminate( + &mut acknowledged_persistence, + fixture.port(), + acknowledged_requested.clone(), + ) + .await + .unwrap_or_else(|error| panic!("acknowledgement must replay idempotently: {error}")), + acknowledged + ); + + let mut already = evidence.clone(); + already.kind = CancellationAcknowledgementKind::AlreadyAuthoritativelyTerminal; + let already = TerminationDisposition::Acknowledged { evidence: already }; + assert_eq!( + derive_termination_outcome_revision(&acknowledged_requested, &already) + .unwrap_or_else(|error| panic!( + "typed already-terminal evidence must validate: {error}" + )) + .cancellation_state, + CancellationState::AcknowledgedAlreadyTerminal + ); + ConformanceOutcome::Verified +} + +fn assert_invalid_acknowledgements( + requested: &ExecutionBinding, + valid: &CancellationAcknowledgementEvidence, +) { + let mut mutations = Vec::new(); + let mut changed = valid.clone(); + changed.acknowledgement_id.clear(); + mutations.push(("acknowledgement_id", changed)); + let mut changed = valid.clone(); + changed.termination_request_id = request_id(8); + mutations.push(("termination_request_id", changed)); + let mut changed = valid.clone(); + changed.session_id = "session-other".to_owned(); + mutations.push(("session_id", changed)); + let mut changed = valid.clone(); + changed.execution_request_id = request_id(8); + mutations.push(("execution_request_id", changed)); + let mut changed = valid.clone(); + changed.execution_request_digest = digest_of('a'); + mutations.push(("execution_request_digest", changed)); + let mut changed = valid.clone(); + changed.authority_evidence_digest = digest_of('0'); + mutations.push(("authority_evidence_digest", changed)); + let mut changed = valid.clone(); + changed.acknowledged_at = at("2026-08-05T14:01:59Z"); + mutations.push(("acknowledged_at_before", changed)); + let mut changed = valid.clone(); + changed.acknowledged_at = at("2026-08-05T14:04:01Z"); + mutations.push(("acknowledged_at_after", changed)); + + for (field, evidence) in mutations { + assert!( + derive_termination_outcome_revision( + requested, + &TerminationDisposition::Acknowledged { evidence }, + ) + .is_err(), + "{field}" + ); + } +} + +/// Verifies the strict complete result and every independent content binding. +pub async fn assert_c_s10_result_artifact_binding( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S10, + UnsupportedCall::Result, + ) + .await + { + return outcome; + } + fixture.reset().await; + let launch = launch_request(); + let launch_correlation = launch.correlation(); + assert!(fixture.port().adopt(launch).await.is_ok()); + let expected: ResultBundle = match serde_json::from_slice(RESULT_GOLDEN) { + Ok(bundle) => bundle, + Err(error) => panic!("strict result fixture must decode: {error}"), + }; + expected + .validate() + .unwrap_or_else(|error| panic!("strict result fixture must validate: {error}")); + assert_eq!(expected.correlation, launch_correlation); + assert_eq!( + canonical_bytes(&expected) + .unwrap_or_else(|error| panic!("result fixture must canonicalize: {error}")), + RESULT_GOLDEN + ); + let actual = fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("complete result must be returned: {error}")); + assert_eq!(actual, expected); + assert_complete_result(&actual, &expected) + .unwrap_or_else(|field| panic!("complete result mismatch: {field}")); + + let mut request_id_mismatch = expected.correlation.clone(); + request_id_mismatch.request_id = request_id(11); + let mut correlations = vec![("request_id", request_id_mismatch)]; + correlations.extend(changed_correlations(&expected.correlation)); + for (field, correlation) in correlations { + let mut bundle_changed = expected.clone(); + bundle_changed.correlation = correlation.clone(); + assert_complete_result_rejected(&bundle_changed, &expected, field); + + let mut artifact_changed = expected.clone(); + artifact_changed.artifacts[0].correlation = correlation; + assert_complete_result_rejected(&artifact_changed, &expected, &format!("artifact_{field}")); + } + + let mut wrong_session = expected.clone(); + wrong_session.session_id = "session-other".to_owned(); + assert_complete_result_rejected(&wrong_session, &expected, "session_id"); + let mut wrong_artifact_session = expected.clone(); + wrong_artifact_session.artifacts[0].session_id = "session-other".to_owned(); + assert_complete_result_rejected(&wrong_artifact_session, &expected, "artifact_session_id"); + + for (field, mutate) in [ + ( + "result.digest", + mutate_result_digest as fn(&mut ResultBundle), + ), + ("result.media_type", mutate_result_media_type), + ("result.size_bytes", mutate_result_size), + ("result.expires_at", mutate_result_expiry), + ("artifact.content.digest", mutate_artifact_digest), + ("artifact.content.media_type", mutate_artifact_media_type), + ("artifact.content.size_bytes", mutate_artifact_size), + ("artifact.content.expires_at", mutate_artifact_expiry), + ] { + let mut changed = expected.clone(); + mutate(&mut changed); + assert_complete_result_rejected(&changed, &expected, field); + } + + let mut zero_result = expected.clone(); + zero_result.result.size_bytes = 0; + assert!(zero_result.validate().is_err()); + let mut oversized_result = expected.clone(); + oversized_result.result.size_bytes = MAX_SAFE_INTEGER + 1; + assert!(oversized_result.validate().is_err()); + let mut safe_result = expected.clone(); + safe_result.result.size_bytes = MAX_SAFE_INTEGER; + safe_result + .validate() + .unwrap_or_else(|error| panic!("JSON safe-integer boundary must validate: {error}")); + let mut malformed_result = expected.clone(); + malformed_result.result.media_type = "Application/JSON".to_owned(); + assert!(malformed_result.validate().is_err()); + let mut late_result = expected.clone(); + late_result.result.expires_at = + expected.correlation.valid_until + time::Duration::nanoseconds(1); + assert!(late_result.validate().is_err()); + + let mut zero_artifact = expected.clone(); + zero_artifact.artifacts[0].content.size_bytes = 0; + assert!(zero_artifact.validate().is_err()); + let mut oversized_artifact = expected.clone(); + oversized_artifact.artifacts[0].content.size_bytes = MAX_SAFE_INTEGER + 1; + assert!(oversized_artifact.validate().is_err()); + let mut safe_artifact = expected.clone(); + safe_artifact.artifacts[0].content.size_bytes = MAX_SAFE_INTEGER; + safe_artifact + .validate() + .unwrap_or_else(|error| panic!("artifact safe-integer boundary must validate: {error}")); + let mut malformed_artifact = expected.clone(); + malformed_artifact.artifacts[0].content.media_type = "text/plain; charset=utf-8".to_owned(); + assert!(malformed_artifact.validate().is_err()); + let mut beyond_result = expected.clone(); + beyond_result.artifacts[0].content.expires_at = + expected.result.expires_at + time::Duration::nanoseconds(1); + assert!(beyond_result.validate().is_err()); + let mut beyond_correlation = expected.clone(); + beyond_correlation.artifacts[0].content.expires_at = + expected.correlation.valid_until + time::Duration::nanoseconds(1); + assert!(beyond_correlation.validate().is_err()); + + let mut duplicate = expected.clone(); + duplicate.artifacts.push(expected.artifacts[0].clone()); + assert!(duplicate.validate().is_err()); + let mut omitted = expected.clone(); + omitted.artifacts.clear(); + assert_complete_result_rejected(&omitted, &expected, "complete_artifact_association"); + + fixture.restart().await; + assert_eq!( + fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("complete result must replay: {error}")), + expected + ); + ConformanceOutcome::Verified +} + +fn assert_complete_result( + candidate: &ResultBundle, + expected: &ResultBundle, +) -> Result<(), &'static str> { + candidate.validate().map_err(|_| "typed_validation")?; + if candidate.session_id != expected.session_id { + return Err("session_id"); + } + if candidate.correlation != expected.correlation { + return Err("correlation"); + } + if candidate.result != expected.result { + return Err("result_content_reference"); + } + if candidate.artifacts != expected.artifacts { + return Err("complete_artifact_association"); + } + Ok(()) +} + +fn assert_complete_result_rejected(candidate: &ResultBundle, expected: &ResultBundle, field: &str) { + assert!( + assert_complete_result(candidate, expected).is_err(), + "{field}" + ); +} + +fn mutate_result_digest(bundle: &mut ResultBundle) { + bundle.result.digest = digest_of('a'); +} + +fn mutate_result_media_type(bundle: &mut ResultBundle) { + bundle.result.media_type = "text/plain".to_owned(); +} + +fn mutate_result_size(bundle: &mut ResultBundle) { + bundle.result.size_bytes = bundle.result.size_bytes.saturating_add(1); +} + +fn mutate_result_expiry(bundle: &mut ResultBundle) { + bundle.result.expires_at -= time::Duration::seconds(1); +} + +fn mutate_artifact_digest(bundle: &mut ResultBundle) { + bundle.artifacts[0].content.digest = digest_of('a'); +} + +fn mutate_artifact_media_type(bundle: &mut ResultBundle) { + bundle.artifacts[0].content.media_type = "application/json".to_owned(); +} + +fn mutate_artifact_size(bundle: &mut ResultBundle) { + bundle.artifacts[0].content.size_bytes = + bundle.artifacts[0].content.size_bytes.saturating_add(1); +} + +fn mutate_artifact_expiry(bundle: &mut ResultBundle) { + bundle.artifacts[0].content.expires_at -= time::Duration::seconds(1); +} + +/// Verifies durable-before/after semantics for every declared fixture fault. +pub async fn assert_c_s11_restart_persistence( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = + expected_unsupported(fixture, CovenConformanceCase::C_S11, UnsupportedCall::Adopt).await + { + return outcome; + } + let every_fault = [ + CovenFaultPoint::AdoptionBeforeCommit, + CovenFaultPoint::AdoptionAfterCommit, + CovenFaultPoint::InputBeforeCommit, + CovenFaultPoint::InputAfterCommit, + CovenFaultPoint::LookupBeforeRead, + CovenFaultPoint::LookupAfterRead, + CovenFaultPoint::CursorBeforePage, + CovenFaultPoint::CursorAfterPage, + CovenFaultPoint::CancellationBeforeAcknowledgement, + CovenFaultPoint::CancellationAfterAcknowledgement, + CovenFaultPoint::TerminalBeforePersistence, + CovenFaultPoint::ResultBeforePersistence, + CovenFaultPoint::ArtifactBeforePersistence, + CovenFaultPoint::ReconcileBeforeDisposition, + CovenFaultPoint::ReconcileAfterDisposition, + CovenFaultPoint::ReconcileStall, + ]; + for point in every_fault { + assert!(fixture.supports(point), "{point:?}"); + } + + for point in [ + CovenFaultPoint::AdoptionBeforeCommit, + CovenFaultPoint::AdoptionAfterCommit, + CovenFaultPoint::InputBeforeCommit, + CovenFaultPoint::InputAfterCommit, + ] { + assert_adoption_fault_recovery(fixture, point).await; + } + for point in [ + CovenFaultPoint::LookupBeforeRead, + CovenFaultPoint::LookupAfterRead, + ] { + assert_lookup_fault_recovery(fixture, point).await; + } + for point in [ + CovenFaultPoint::CursorBeforePage, + CovenFaultPoint::CursorAfterPage, + ] { + assert_cursor_fault_recovery(fixture, point).await; + } + for point in [ + CovenFaultPoint::CancellationBeforeAcknowledgement, + CovenFaultPoint::CancellationAfterAcknowledgement, + CovenFaultPoint::TerminalBeforePersistence, + ] { + assert_termination_fault_recovery(fixture, point).await; + } + for point in [ + CovenFaultPoint::ResultBeforePersistence, + CovenFaultPoint::ArtifactBeforePersistence, + ] { + assert_result_fault_recovery(fixture, point).await; + } + for point in [ + CovenFaultPoint::ReconcileBeforeDisposition, + CovenFaultPoint::ReconcileAfterDisposition, + CovenFaultPoint::ReconcileStall, + ] { + assert_reconciliation_fault_recovery(fixture, point).await; + } + ConformanceOutcome::Verified +} + +async fn assert_adoption_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + let input_fault = matches!( + point, + CovenFaultPoint::InputBeforeCommit | CovenFaultPoint::InputAfterCommit + ); + if input_fault { + assert!(fixture.port().adopt(launch_request()).await.is_ok()); + } + let request = if input_fault { + session_input_request() + } else { + launch_request() + }; + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + assert_eq!( + fixture.port().adopt(request.clone()).await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + let recovered = fixture + .port() + .adopt(request.clone()) + .await + .unwrap_or_else(|error| panic!("{point:?} must recover after restart: {error}")); + let calls_after_recovery = fixture.observations().await.adoption_calls; + fixture.restart().await; + assert_eq!( + fixture + .port() + .adopt(request) + .await + .unwrap_or_else(|error| panic!("{point:?} must replay: {error}")), + recovered + ); + assert_eq!( + fixture.observations().await.adoption_calls, + calls_after_recovery, + "{point:?}" + ); +} + +async fn assert_lookup_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + let launch = launch_request(); + let request_id = launch.correlation().request_id; + let adopted = fixture + .port() + .adopt(launch) + .await + .unwrap_or_else(|error| panic!("lookup setup must adopt: {error}")); + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + assert_eq!( + fixture.port().lookup(&request_id).await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + assert_eq!( + fixture + .port() + .lookup(&request_id) + .await + .unwrap_or_else(|error| panic!("{point:?} lookup must recover: {error}")), + adopted + ); +} + +async fn assert_cursor_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + assert!(fixture.port().adopt(launch_request()).await.is_ok()); + let cursor = EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 0, + }; + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + assert_eq!( + fixture.port().events(cursor.clone()).await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + let recovered = fixture + .port() + .events(cursor.clone()) + .await + .unwrap_or_else(|error| panic!("{point:?} cursor must recover: {error}")); + fixture.restart().await; + assert_eq!( + fixture + .port() + .events(cursor) + .await + .unwrap_or_else(|error| panic!("{point:?} cursor must replay: {error}")), + recovered + ); +} + +async fn assert_termination_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + let launch = launch_request(); + assert!(fixture.port().adopt(launch.clone()).await.is_ok()); + let requested = termination_requested_binding(&launch, "operator_request"); + let mut persistence = MemoryTerminationPersistence::default(); + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + assert!(matches!( + persist_then_terminate(&mut persistence, fixture.port(), requested.clone(),).await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + fixture.restart().await; + fixture.clear_fault().await; + let recovered = persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) + .await + .unwrap_or_else(|error| panic!("{point:?} termination must recover: {error}")); + fixture.restart().await; + assert_eq!( + persist_then_terminate(&mut persistence, fixture.port(), requested) + .await + .unwrap_or_else(|error| panic!("{point:?} termination must replay: {error}")), + recovered + ); +} + +async fn assert_result_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + assert!(fixture.port().adopt(launch_request()).await.is_ok()); + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + assert_eq!( + fixture.port().result("session-1").await, + Err(PortError::Unavailable) + ); + fixture.restart().await; + fixture.clear_fault().await; + let recovered = fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("{point:?} result must recover: {error}")); + fixture.restart().await; + assert_eq!( + fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("{point:?} result must replay: {error}")), + recovered + ); +} + +async fn assert_reconciliation_fault_recovery( + fixture: &mut dyn CovenConformanceFixture, + point: CovenFaultPoint, +) { + fixture.reset().await; + let correlation = mark_ambiguous(fixture).await; + let request = reconciliation_request(correlation, true); + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + let expected = if point == CovenFaultPoint::ReconcileStall { + PortError::Stalled + } else { + PortError::Unavailable + }; + assert_eq!( + fixture.port().reconcile(request.clone()).await, + Err(expected) + ); + let committed_before_restart = fixture.observations().await.durable_reconciliation; + if point == CovenFaultPoint::ReconcileAfterDisposition { + assert!(committed_before_restart.is_some()); + } else { + assert!(committed_before_restart.is_none()); + } + fixture.restart().await; + fixture.clear_fault().await; + let recovered = fixture + .port() + .reconcile(request.clone()) + .await + .unwrap_or_else(|error| panic!("{point:?} reconciliation must recover: {error}")); + let durable = fixture + .observations() + .await + .durable_reconciliation + .unwrap_or_else(|| panic!("{point:?} reconciliation must become durable")); + assert_eq!(disposition_observation(&recovered), Some(durable.clone())); + fixture.restart().await; + assert_eq!( + fixture + .port() + .reconcile(request) + .await + .unwrap_or_else(|error| panic!("{point:?} reconciliation must replay: {error}")), + recovered + ); + assert_eq!( + fixture.observations().await.durable_reconciliation, + Some(durable) + ); + assert_eq!(fixture.observations().await.adoption_calls, 1); +} + +/// Verifies stable typed denials for every public invalid-input class. +pub async fn assert_c_s12_structured_denial( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S12, + UnsupportedCall::Negotiate, + ) + .await + { + return outcome; + } + fixture.reset().await; + assert_eq!( + fixture + .port() + .negotiate(NegotiateRequest::new("coven.daemon.v2")) + .await, + Err(PortError::ContractUnsupported {}) + ); + let mut capability = NegotiateRequest::new(CONTRACT); + capability + .required_capabilities + .insert("future_capability".to_owned()); + assert_eq!( + fixture.port().negotiate(capability).await, + Err(PortError::CapabilityMissing {}) + ); + + let launch = launch_request(); + let correlation = launch.correlation(); + assert!(fixture.port().adopt(launch).await.is_ok()); + let mut changed = correlation.clone(); + changed.project_id = "project:sha256:other".to_owned(); + assert_eq!( + fixture + .port() + .reconcile(ReconciliationRequest { + correlation: changed, + ambiguity_digest: digest_of('d'), + reason_code: "return_original".to_owned(), + }) + .await, + Err(PortError::IntentConflict) + ); + assert!( + fixture + .observations() + .await + .durable_reconciliation + .is_none() + ); + + assert_eq!( + fixture + .port() + .events(EventCursor { + session_id: "foreign-session".to_owned(), + after_sequence: 0, + }) + .await, + Err(PortError::CorrelationMismatch) + ); + assert_eq!( + fixture + .port() + .events(EventCursor { + session_id: "session-1".to_owned(), + after_sequence: MAX_SAFE_INTEGER + 1, + }) + .await, + Err(PortError::InvalidRequest) + ); + + fixture.reset().await; + assert_eq!( + fixture.port().adopt(session_input_request()).await, + Err(PortError::NotFound) + ); + assert_eq!( + fixture.port().inspect("missing-session").await, + Err(PortError::NotFound) + ); + + let correlation = launch_request().correlation(); + let base = ContentAddressedReference { + digest: digest_of('a'), + media_type: "application/json".to_owned(), + size_bytes: 1, + expires_at: correlation.created_at + time::Duration::minutes(1), + }; + let mut zero = base.clone(); + zero.size_bytes = 0; + assert_eq!(zero.validate(), Err(PortError::InvalidRequest)); + let mut oversized = base.clone(); + oversized.size_bytes = MAX_SAFE_INTEGER + 1; + assert_eq!(oversized.validate(), Err(PortError::InvalidRequest)); + let mut malformed = base; + malformed.media_type = "free form error".to_owned(); + assert_eq!(malformed.validate(), Err(PortError::InvalidRequest)); + + let structured = [ + PortError::ContractUnsupported {}, + PortError::CapabilityMissing {}, + PortError::IntentConflict, + PortError::CorrelationMismatch, + PortError::InvalidRequest, + PortError::NotFound, + ]; + for error in structured { + assert!(!error.to_string().is_empty()); + assert!(!format!("{error:?}").contains("free form error")); + } + ConformanceOutcome::Verified +} diff --git a/crates/psyche-test-support/src/suites/mod.rs b/crates/psyche-test-support/src/suites/mod.rs new file mode 100644 index 0000000..59d8479 --- /dev/null +++ b/crates/psyche-test-support/src/suites/mod.rs @@ -0,0 +1,27 @@ +//! Reusable adapter-neutral conformance suites. + +mod coven; +mod surface; + +pub use coven::{ + ScriptedG2Fixture, UnsupportedCovenFixture, assert_c_s1_contract_negotiation, + assert_c_s2_session_lifecycle, assert_c_s3_snapshot_attempt_binding, + assert_c_s4_stable_adoption, assert_c_s5_non_adoption_proof, assert_c_s6_ambiguity_fence, + assert_c_s7_ordered_cursor, assert_c_s8_terminal_authority, + assert_c_s9_cancellation_acknowledgement, assert_c_s10_result_artifact_binding, + assert_c_s11_restart_persistence, assert_c_s12_structured_denial, scripted_fixture, + unsupported_fixture, +}; +pub use surface::{assert_surface_unknown_delivery, scripted_surface}; + +/// Result of executing one reusable behavior suite. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConformanceOutcome { + /// Every supported behavior was verified. + Verified, + /// The fixture made the required public call and returned its declared denial. + ExpectedUnsupported { + /// Exact stable structured denial code. + code: String, + }, +} diff --git a/crates/psyche-test-support/src/suites/surface.rs b/crates/psyche-test-support/src/suites/surface.rs new file mode 100644 index 0000000..1e799be --- /dev/null +++ b/crates/psyche-test-support/src/suites/surface.rs @@ -0,0 +1,68 @@ +//! Reusable surface-boundary assertions. + +use psyche_core::contracts::surface::SurfaceEffect; +use psyche_core::contracts::{RecordKind, SchemaVersion}; +use psyche_core::digest::digest; +use psyche_core::id::RecordId; +use psyche_surfaces::{DeliveryDisposition, SurfacePort}; + +use crate::surface::FakeSurface; + +/// Builds the deterministic surface fixture used by the G2 wrapper. +pub fn scripted_surface() -> FakeSurface { + match FakeSurface::builder() + .delivery(DeliveryDisposition::Unknown) + .build() + { + Ok(surface) => surface, + Err(error) => panic!("static surface fixture is valid: {error}"), + } +} + +/// Verifies that an ambiguous delivery remains explicitly unknown and replay-safe. +pub async fn assert_surface_unknown_delivery(port: &dyn SurfacePort) { + let effect = surface_effect(); + assert_eq!( + port.apply(effect.clone()).await, + Ok(DeliveryDisposition::Unknown) + ); + assert_eq!(port.apply(effect).await, Ok(DeliveryDisposition::Unknown)); +} + +fn surface_effect() -> SurfaceEffect { + let effect = serde_json::json!({"method": "send_message", "text": "hello"}); + SurfaceEffect { + schema_version: schema("psyche.surface_effect.v1"), + surface_effect_id: record_id(RecordKind::SurfaceEffect, 1), + intent_id: record_id(RecordKind::Intent, 2), + graph_id: record_id(RecordKind::Graph, 3), + node_id: record_id(RecordKind::GraphNode, 4), + attempt_id: record_id(RecordKind::Attempt, 5), + familiar_snapshot_id: record_id(RecordKind::IdentitySnapshot, 6), + project_id: "project:sha256:abc".to_owned(), + action_class: "send_message".to_owned(), + account_id: "account-1".to_owned(), + locator: serde_json::json!({"chat_id": "chat-1"}), + effect_digest: match digest(&effect) { + Ok(value) => value, + Err(error) => panic!("static surface effect is canonical: {error}"), + }, + effect, + created_at: time::OffsetDateTime::UNIX_EPOCH, + } +} + +fn schema(value: &str) -> SchemaVersion { + match SchemaVersion::parse(value) { + Ok(value) => value, + Err(error) => panic!("static schema is valid: {error}"), + } +} + +fn record_id(kind: RecordKind, value: u8) -> RecordId { + let suffix = format!("01J000000000000000000000{value:02}"); + match RecordId::parse(kind, &format!("{}{suffix}", kind.prefix())) { + Ok(value) => value, + Err(error) => panic!("static record identity is valid: {error}"), + } +} diff --git a/crates/psyche-test-support/tests/conformance.rs b/crates/psyche-test-support/tests/conformance.rs new file mode 100644 index 0000000..1bab334 --- /dev/null +++ b/crates/psyche-test-support/tests/conformance.rs @@ -0,0 +1,173 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use psyche_test_support::suites::{ + ConformanceOutcome, assert_c_s1_contract_negotiation, assert_c_s2_session_lifecycle, + assert_c_s3_snapshot_attempt_binding, assert_c_s4_stable_adoption, + assert_c_s5_non_adoption_proof, assert_c_s6_ambiguity_fence, assert_c_s7_ordered_cursor, + assert_c_s8_terminal_authority, assert_c_s9_cancellation_acknowledgement, + assert_c_s10_result_artifact_binding, assert_c_s11_restart_persistence, + assert_c_s12_structured_denial, assert_surface_unknown_delivery, scripted_fixture, + scripted_surface, unsupported_fixture, +}; + +#[tokio::test] +async fn c_s1_contract_negotiation() { + assert_eq!( + assert_c_s1_contract_negotiation(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s2_session_lifecycle() { + assert_eq!( + assert_c_s2_session_lifecycle(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s3_snapshot_attempt_binding() { + assert_eq!( + assert_c_s3_snapshot_attempt_binding(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s4_stable_adoption() { + assert_eq!( + assert_c_s4_stable_adoption(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s5_non_adoption_proof() { + assert_eq!( + assert_c_s5_non_adoption_proof(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s6_ambiguity_fence() { + assert_eq!( + assert_c_s6_ambiguity_fence(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s7_ordered_cursor() { + assert_eq!( + assert_c_s7_ordered_cursor(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s8_terminal_authority() { + assert_eq!( + assert_c_s8_terminal_authority(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s9_cancellation_acknowledgement() { + assert_eq!( + assert_c_s9_cancellation_acknowledgement(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s10_result_artifact_binding() { + assert_eq!( + assert_c_s10_result_artifact_binding(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s11_restart_persistence() { + assert_eq!( + assert_c_s11_restart_persistence(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn c_s12_structured_denial() { + assert_eq!( + assert_c_s12_structured_denial(&mut scripted_fixture()).await, + ConformanceOutcome::Verified + ); +} + +#[tokio::test] +async fn surface_unknown_delivery() { + assert_surface_unknown_delivery(&scripted_surface()).await; +} + +#[tokio::test] +async fn expected_unsupported_paths_execute_public_calls_without_mutation() { + assert_all_expected_unsupported("CapabilityMissing").await; + assert_all_expected_unsupported("ContractUnsupported").await; +} + +async fn assert_all_expected_unsupported(code: &str) { + let expected = ConformanceOutcome::ExpectedUnsupported { + code: code.to_owned(), + }; + + assert_eq!( + assert_c_s1_contract_negotiation(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s2_session_lifecycle(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s3_snapshot_attempt_binding(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s4_stable_adoption(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s5_non_adoption_proof(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s6_ambiguity_fence(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s7_ordered_cursor(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s8_terminal_authority(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s9_cancellation_acknowledgement(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s10_result_artifact_binding(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s11_restart_persistence(&mut unsupported_fixture(code)).await, + expected + ); + assert_eq!( + assert_c_s12_structured_denial(&mut unsupported_fixture(code)).await, + expected + ); +} diff --git a/crates/psyche-test-support/tests/state_machine.rs b/crates/psyche-test-support/tests/state_machine.rs new file mode 100644 index 0000000..6f9bd86 --- /dev/null +++ b/crates/psyche-test-support/tests/state_machine.rs @@ -0,0 +1,1663 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::OnceLock; + +use proptest::prelude::*; +use psyche_core::contracts::execution::{ + AdoptionState, CancellationState, ExecutionBinding, TerminationRequestCorrelation, +}; +use psyche_core::contracts::{ + CanonicalDocument, ContractError, Intent, RecordKind, SchemaKind, SchemaVersion, +}; +use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; +use psyche_core::id::{RecordId, RequestId}; +use psyche_coven::{ + AdoptionDisposition, AdoptionRequest, ExecutionCorrelation, ExecutionRequestInput, PortError, + ReconciliationDisposition, ReconciliationRequest, +}; +use psyche_store::{ + IngestOutcome, QuarantineId, QuarantineReasonCode, QuarantineResolution, + QuarantineResolutionCode, ResolveQuarantineOutcome, Store, StoreError, Transition, +}; +use psyche_test_support::{ + CovenConformanceFixture, CovenFaultPoint, DurableDispositionKind, scripted_fixture, +}; +use serde_json::{Map, json}; +use tempfile::TempDir; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; + +const LAUNCH_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-launch.json"); +const INPUT_GOLDEN: &[u8] = + include_bytes!("../../psyche-coven/tests/fixtures/execution-request-input.json"); + +#[derive(Debug, Clone)] +enum FoundationOperation { + Insert { slot: u8 }, + IdenticalReinsert { slot: u8 }, + ConflictingReinsert { slot: u8 }, + InvalidDirectInsertSchema { slot: u8 }, + InvalidDirectInsertFieldId { slot: u8 }, + InsertInitialBinding { slot: u8 }, + AppendNextBindingRevision { slot: u8 }, + ReplayBindingRevision { slot: u8, selector: u8 }, + InvalidBindingRevision { slot: u8, mutation: u8 }, + AppendNextTransition { slot: u8 }, + AppendDuplicateVersion { slot: u8 }, + InvalidTransitionDigest { slot: u8 }, + InvalidTransitionKind { slot: u8 }, + Quarantine { slot: u8 }, + ResolveQuarantineFirst { slot: u8 }, + ResolveQuarantineReplay { slot: u8 }, + ResolveQuarantineUnknown, + ResolveQuarantineStale { slot: u8 }, + ResolveQuarantineConflict { slot: u8 }, + Prune { future_cutoff: bool }, + Checkpoint, + Reopen, +} + +impl Arbitrary for FoundationOperation { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + let slot = 0_u8..2; + prop_oneof![ + 4 => slot.clone().prop_map(|slot| Self::Insert { slot }), + 2 => slot.clone().prop_map(|slot| Self::IdenticalReinsert { slot }), + 2 => slot.clone().prop_map(|slot| Self::ConflictingReinsert { slot }), + 2 => slot.clone().prop_map(|slot| Self::InvalidDirectInsertSchema { slot }), + 2 => slot.clone().prop_map(|slot| Self::InvalidDirectInsertFieldId { slot }), + 3 => slot.clone().prop_map(|slot| Self::InsertInitialBinding { slot }), + 4 => slot.clone().prop_map(|slot| Self::AppendNextBindingRevision { slot }), + 2 => (slot.clone(), any::()) + .prop_map(|(slot, selector)| Self::ReplayBindingRevision { slot, selector }), + 4 => (slot.clone(), 0_u8..16) + .prop_map(|(slot, mutation)| Self::InvalidBindingRevision { slot, mutation }), + 4 => slot.clone().prop_map(|slot| Self::AppendNextTransition { slot }), + 2 => slot.clone().prop_map(|slot| Self::AppendDuplicateVersion { slot }), + 2 => slot.clone().prop_map(|slot| Self::InvalidTransitionDigest { slot }), + 2 => slot.clone().prop_map(|slot| Self::InvalidTransitionKind { slot }), + 3 => slot.clone().prop_map(|slot| Self::Quarantine { slot }), + 3 => slot.clone().prop_map(|slot| Self::ResolveQuarantineFirst { slot }), + 2 => slot.clone().prop_map(|slot| Self::ResolveQuarantineReplay { slot }), + 1 => Just(Self::ResolveQuarantineUnknown), + 2 => slot.clone().prop_map(|slot| Self::ResolveQuarantineStale { slot }), + 2 => slot.clone().prop_map(|slot| Self::ResolveQuarantineConflict { slot }), + 2 => any::().prop_map(|future_cutoff| Self::Prune { future_cutoff }), + 1 => Just(Self::Checkpoint), + 2 => Just(Self::Reopen), + ] + .boxed() + } +} + +impl FoundationOperation { + fn must_preserve_logical_state(&self) -> bool { + matches!( + self, + Self::IdenticalReinsert { .. } + | Self::ConflictingReinsert { .. } + | Self::InvalidDirectInsertSchema { .. } + | Self::InvalidDirectInsertFieldId { .. } + | Self::ReplayBindingRevision { .. } + | Self::InvalidBindingRevision { .. } + | Self::AppendDuplicateVersion { .. } + | Self::InvalidTransitionDigest { .. } + | Self::InvalidTransitionKind { .. } + | Self::ResolveQuarantineReplay { .. } + | Self::ResolveQuarantineUnknown + | Self::ResolveQuarantineStale { .. } + | Self::ResolveQuarantineConflict { .. } + | Self::Checkpoint + | Self::Reopen + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +enum OperationOutcome { + Applied, + AlreadyPresent, + Conflict, + Invalid, + NoTarget, + Quarantined, + Resolved, + AlreadyResolved, + NotFound, + StaleResolution, + ResolutionConflict, + Pruned(u64), + Checkpointed, + Reopened, +} + +#[derive(Debug, Clone, PartialEq)] +struct QuarantineObservation { + payload_digest: Sha256Digest, + reason: QuarantineReasonCode, + resolution_code: Option, + resolved_at: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct AuditObservation { + event_code: String, + created_at: OffsetDateTime, +} + +#[derive(Debug, Clone, PartialEq)] +struct FoundationSnapshot { + records: BTreeMap, + record_digests: BTreeMap, + binding_revisions: BTreeMap>, + binding_digests: BTreeMap>, + transitions: BTreeMap>, + quarantines: BTreeMap, + audit_events: Vec, + total_record_count: u64, + transition_count: u64, +} + +#[derive(Debug, Clone, PartialEq)] +struct FoundationStep { + outcome: OperationOutcome, + snapshot: FoundationSnapshot, +} + +#[derive(Debug, Clone)] +struct ModelQuarantine { + payload_digest: Sha256Digest, + reason: QuarantineReasonCode, + resolution: Option, +} + +#[derive(Debug, Default)] +struct FoundationModel { + records: BTreeMap, + bindings: BTreeMap>, + transitions: BTreeMap>, + quarantines: BTreeMap, + audit_events: Vec, +} + +impl FoundationModel { + fn apply(&mut self, operation: FoundationOperation) -> FoundationStep { + let before = self.snapshot(); + let outcome = match operation.clone() { + FoundationOperation::Insert { slot } => { + let candidate = fixture_intent(slot, false); + match self.records.get(&slot) { + None => { + self.records.insert(slot, candidate); + OperationOutcome::Applied + } + Some(stored) if stored == &candidate => OperationOutcome::AlreadyPresent, + Some(_) => OperationOutcome::Conflict, + } + } + FoundationOperation::IdenticalReinsert { slot } => { + if self.records.contains_key(&slot) { + OperationOutcome::AlreadyPresent + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::ConflictingReinsert { slot } => { + if self.records.contains_key(&slot) { + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::InvalidDirectInsertSchema { .. } + | FoundationOperation::InvalidDirectInsertFieldId { .. } => OperationOutcome::Invalid, + FoundationOperation::InsertInitialBinding { slot } => { + let candidate = fixture_binding(slot); + match self.bindings.get(&slot) { + None => { + self.bindings.insert(slot, vec![candidate]); + OperationOutcome::Applied + } + Some(history) if history.first() == Some(&candidate) => { + OperationOutcome::AlreadyPresent + } + Some(_) => OperationOutcome::Conflict, + } + } + FoundationOperation::AppendNextBindingRevision { slot } => { + let Some(history) = self.bindings.get_mut(&slot) else { + return self.step_with_preservation( + operation, + before, + OperationOutcome::NoTarget, + ); + }; + let next = next_binding(history.last().expect("history is nonempty"), slot); + history.push(next); + OperationOutcome::Applied + } + FoundationOperation::ReplayBindingRevision { slot, selector } => { + if self + .bindings + .get(&slot) + .is_some_and(|history| !history.is_empty()) + { + let _ = selector; + OperationOutcome::AlreadyPresent + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::InvalidBindingRevision { slot, .. } => { + if self.bindings.contains_key(&slot) { + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::AppendNextTransition { slot } => { + let history = self.transitions.entry(slot).or_default(); + history.push(next_transition(history, slot)); + OperationOutcome::Applied + } + FoundationOperation::AppendDuplicateVersion { slot } => { + if self + .transitions + .get(&slot) + .is_some_and(|history| !history.is_empty()) + { + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::InvalidTransitionDigest { .. } + | FoundationOperation::InvalidTransitionKind { .. } => OperationOutcome::Invalid, + FoundationOperation::Quarantine { slot } => { + self.quarantines.entry(slot).or_insert_with(|| { + let rejected = psyche_core::contracts::RejectedDocument::from_decode_error( + &unknown_major_bytes(slot), + ContractError::UnsupportedMajor { + found: 2, + supported: 1, + }, + ); + ModelQuarantine { + payload_digest: rejected.payload_digest, + reason: QuarantineReasonCode::UnsupportedMajor, + resolution: None, + } + }); + OperationOutcome::Quarantined + } + FoundationOperation::ResolveQuarantineFirst { slot } => { + let Some(quarantine) = self.quarantines.get_mut(&slot) else { + return self.step_with_preservation( + operation, + before, + OperationOutcome::NotFound, + ); + }; + let requested = first_resolution(slot); + if quarantine.resolution.as_ref() == Some(&requested) { + OperationOutcome::AlreadyResolved + } else if quarantine.resolution.is_some() { + OperationOutcome::ResolutionConflict + } else { + quarantine.resolution = Some(requested.clone()); + self.audit_events.push(AuditObservation { + event_code: "quarantine_resolved".to_owned(), + created_at: requested.resolved_at, + }); + OperationOutcome::Resolved + } + } + FoundationOperation::ResolveQuarantineReplay { slot } => { + if self + .quarantines + .get(&slot) + .and_then(|record| record.resolution.as_ref()) + .is_some() + { + OperationOutcome::AlreadyResolved + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::ResolveQuarantineUnknown => OperationOutcome::NotFound, + FoundationOperation::ResolveQuarantineStale { slot } => { + if self.quarantines.contains_key(&slot) { + OperationOutcome::StaleResolution + } else { + OperationOutcome::NotFound + } + } + FoundationOperation::ResolveQuarantineConflict { slot } => { + if self + .quarantines + .get(&slot) + .and_then(|record| record.resolution.as_ref()) + .is_some() + { + OperationOutcome::ResolutionConflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::Prune { future_cutoff } => { + let before_count = self.quarantines.len(); + if future_cutoff { + self.quarantines + .retain(|_, record| record.resolution.is_none()); + } + OperationOutcome::Pruned( + u64::try_from(before_count.saturating_sub(self.quarantines.len())).unwrap(), + ) + } + FoundationOperation::Checkpoint => OperationOutcome::Checkpointed, + FoundationOperation::Reopen => OperationOutcome::Reopened, + }; + self.step_with_preservation(operation, before, outcome) + } + + fn step_with_preservation( + &self, + operation: FoundationOperation, + before: FoundationSnapshot, + outcome: OperationOutcome, + ) -> FoundationStep { + let snapshot = self.snapshot(); + if operation.must_preserve_logical_state() { + assert_eq!(snapshot, before, "{operation:?}"); + } + FoundationStep { outcome, snapshot } + } + + fn snapshot(&self) -> FoundationSnapshot { + let record_digests = self + .records + .iter() + .map(|(slot, document)| (*slot, digest(document).unwrap())) + .collect(); + let binding_digests = self + .bindings + .iter() + .map(|(slot, history)| { + ( + *slot, + history + .iter() + .map(|binding| digest(binding).unwrap()) + .collect(), + ) + }) + .collect(); + FoundationSnapshot { + records: self.records.clone(), + record_digests, + binding_revisions: self.bindings.clone(), + binding_digests, + transitions: self.transitions.clone(), + quarantines: self + .quarantines + .iter() + .map(|(slot, record)| { + ( + *slot, + QuarantineObservation { + payload_digest: record.payload_digest.clone(), + reason: record.reason, + resolution_code: record + .resolution + .as_ref() + .map(|resolution| resolution.code), + resolved_at: record + .resolution + .as_ref() + .map(|resolution| resolution.resolved_at), + }, + ) + }) + .collect(), + audit_events: self.audit_events.clone(), + total_record_count: u64::try_from(self.records.len() + self.bindings.len()).unwrap(), + transition_count: self + .transitions + .values() + .map(Vec::len) + .sum::() + .try_into() + .unwrap(), + } + } +} + +struct FoundationStore { + store: Store, + path: PathBuf, + quarantine_ids: BTreeMap, +} + +fn test_store() -> (FoundationStore, TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("private").join("psyche.sqlite3"); + let store = Store::open(&path).unwrap(); + ( + FoundationStore { + store, + path, + quarantine_ids: BTreeMap::new(), + }, + dir, + ) +} + +fn apply_to_store(harness: &mut FoundationStore, operation: FoundationOperation) -> FoundationStep { + let before = store_snapshot(harness); + let outcome = match operation.clone() { + FoundationOperation::Insert { slot } => { + match harness.store.insert(&fixture_intent(slot, false)) { + Ok(()) if before.records.contains_key(&slot) => OperationOutcome::AlreadyPresent, + Ok(()) => OperationOutcome::Applied, + Err(StoreError::RecordConflict { .. }) => OperationOutcome::Conflict, + other => panic!("unexpected insert result: {other:?}"), + } + } + FoundationOperation::IdenticalReinsert { slot } => { + if before.records.contains_key(&slot) { + harness.store.insert(&fixture_intent(slot, false)).unwrap(); + OperationOutcome::AlreadyPresent + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::ConflictingReinsert { slot } => { + if before.records.contains_key(&slot) { + assert!(matches!( + harness.store.insert(&fixture_intent(slot, true)), + Err(StoreError::RecordConflict { .. }) + )); + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::InvalidDirectInsertSchema { slot } => { + let mut invalid = match fixture_intent(slot, false) { + CanonicalDocument::Intent(intent) => intent, + _ => unreachable!(), + }; + invalid.schema_version = schema("psyche.graph.v1"); + assert!(matches!( + harness.store.insert(&CanonicalDocument::Intent(invalid)), + Err(StoreError::Contract(ContractError::SchemaMismatch { .. })) + )); + OperationOutcome::Invalid + } + FoundationOperation::InvalidDirectInsertFieldId { slot } => { + let mut invalid = match fixture_intent(slot, false) { + CanonicalDocument::Intent(intent) => intent, + _ => unreachable!(), + }; + invalid.intent_id = record_id(RecordKind::Graph, slot); + assert!(matches!( + harness.store.insert(&CanonicalDocument::Intent(invalid)), + Err(StoreError::Contract(ContractError::WrongRecordKind { .. })) + )); + OperationOutcome::Invalid + } + FoundationOperation::InsertInitialBinding { slot } => { + let before_len = before.binding_revisions.get(&slot).map_or(0, Vec::len); + match harness + .store + .insert(&CanonicalDocument::ExecutionBinding(fixture_binding(slot))) + { + Ok(()) if before_len == 0 => OperationOutcome::Applied, + Ok(()) => OperationOutcome::AlreadyPresent, + Err(StoreError::ExecutionBindingRevisionConflict { .. }) => { + OperationOutcome::Conflict + } + other => panic!("unexpected initial binding result: {other:?}"), + } + } + FoundationOperation::AppendNextBindingRevision { slot } => { + let history = harness + .store + .execution_binding_revisions(&attempt_id(slot)) + .unwrap(); + if let Some(latest) = history.last() { + harness + .store + .insert(&CanonicalDocument::ExecutionBinding(next_binding( + latest, slot, + ))) + .unwrap(); + OperationOutcome::Applied + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::ReplayBindingRevision { slot, selector } => { + let history = harness + .store + .execution_binding_revisions(&attempt_id(slot)) + .unwrap(); + if history.is_empty() { + OperationOutcome::NoTarget + } else { + let index = usize::from(selector) % history.len(); + harness + .store + .insert(&CanonicalDocument::ExecutionBinding(history[index].clone())) + .unwrap(); + OperationOutcome::AlreadyPresent + } + } + FoundationOperation::InvalidBindingRevision { slot, mutation } => { + let history = harness + .store + .execution_binding_revisions(&attempt_id(slot)) + .unwrap(); + if let Some(latest) = history.last() { + let invalid = invalid_binding(latest, slot, mutation); + assert!(matches!( + harness + .store + .insert(&CanonicalDocument::ExecutionBinding(invalid)), + Err(StoreError::ExecutionBindingRevisionConflict { .. }) + )); + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::AppendNextTransition { slot } => { + let history = harness.store.transitions(&attempt_id(slot)).unwrap(); + harness + .store + .append_transition(&next_transition(&history, slot)) + .unwrap(); + OperationOutcome::Applied + } + FoundationOperation::AppendDuplicateVersion { slot } => { + let history = harness.store.transitions(&attempt_id(slot)).unwrap(); + if let Some(latest) = history.last() { + let duplicate = Transition::new( + SchemaKind::ExecutionBinding, + attempt_id(slot), + latest.record_version, + latest.from_state.clone(), + format!("conflict_{}", latest.record_version), + latest.created_at + Duration::nanoseconds(1), + ) + .unwrap(); + assert!(matches!( + harness.store.append_transition(&duplicate), + Err(StoreError::TransitionConflict { .. }) + )); + OperationOutcome::Conflict + } else { + OperationOutcome::NoTarget + } + } + FoundationOperation::InvalidTransitionDigest { slot } => { + let history = harness.store.transitions(&attempt_id(slot)).unwrap(); + let mut invalid = next_transition(&history, slot); + invalid.transition_digest = digest_of('f'); + assert!(matches!( + harness.store.append_transition(&invalid), + Err(StoreError::Contract(ContractError::DigestMismatch { .. })) + )); + OperationOutcome::Invalid + } + FoundationOperation::InvalidTransitionKind { slot } => { + let history = harness.store.transitions(&attempt_id(slot)).unwrap(); + let mut invalid = next_transition(&history, slot); + invalid.kind = SchemaKind::Intent; + assert!(matches!( + harness.store.append_transition(&invalid), + Err(StoreError::Contract(ContractError::WrongRecordKind { .. })) + )); + OperationOutcome::Invalid + } + FoundationOperation::Quarantine { slot } => { + let IngestOutcome::Quarantined { quarantine_id } = + harness.store.ingest(&unknown_major_bytes(slot)).unwrap() + else { + panic!("unknown major must be quarantined") + }; + harness.quarantine_ids.insert(slot, quarantine_id); + OperationOutcome::Quarantined + } + FoundationOperation::ResolveQuarantineFirst { slot } => { + let Some(id) = harness.quarantine_ids.get(&slot) else { + return store_step(harness, operation, before, OperationOutcome::NotFound); + }; + match harness + .store + .resolve_quarantine(id, &first_resolution(slot)) + { + Ok(ResolveQuarantineOutcome::Resolved { .. }) => OperationOutcome::Resolved, + Ok(ResolveQuarantineOutcome::AlreadyResolved { .. }) => { + OperationOutcome::AlreadyResolved + } + Err(StoreError::QuarantineResolutionConflict { .. }) => { + OperationOutcome::ResolutionConflict + } + Err(StoreError::QuarantineNotFound { .. }) => OperationOutcome::NotFound, + other => panic!("unexpected first resolution result: {other:?}"), + } + } + FoundationOperation::ResolveQuarantineReplay { slot } => { + let Some(id) = harness.quarantine_ids.get(&slot) else { + return store_step(harness, operation, before, OperationOutcome::NoTarget); + }; + let Some(record) = harness.store.quarantine_record(id).unwrap() else { + return store_step(harness, operation, before, OperationOutcome::NoTarget); + }; + if record.resolution_code.is_none() { + OperationOutcome::NoTarget + } else { + assert!(matches!( + harness + .store + .resolve_quarantine(id, &first_resolution(slot)), + Ok(ResolveQuarantineOutcome::AlreadyResolved { .. }) + )); + OperationOutcome::AlreadyResolved + } + } + FoundationOperation::ResolveQuarantineUnknown => { + let unknown = QuarantineId::parse("qua_01J00000000000000000000000").unwrap(); + assert!(matches!( + harness + .store + .resolve_quarantine(&unknown, &first_resolution(0)), + Err(StoreError::QuarantineNotFound { .. }) + )); + OperationOutcome::NotFound + } + FoundationOperation::ResolveQuarantineStale { slot } => { + let Some(id) = harness.quarantine_ids.get(&slot) else { + return store_step(harness, operation, before, OperationOutcome::NotFound); + }; + match harness.store.resolve_quarantine( + id, + &QuarantineResolution { + code: QuarantineResolutionCode::ConfirmedInvalid, + resolved_at: OffsetDateTime::UNIX_EPOCH, + }, + ) { + Err(StoreError::InvalidQuarantineResolution { .. }) => { + OperationOutcome::StaleResolution + } + Err(StoreError::QuarantineNotFound { .. }) => OperationOutcome::NotFound, + other => panic!("unexpected stale resolution result: {other:?}"), + } + } + FoundationOperation::ResolveQuarantineConflict { slot } => { + let Some(id) = harness.quarantine_ids.get(&slot) else { + return store_step(harness, operation, before, OperationOutcome::NoTarget); + }; + let Some(record) = harness.store.quarantine_record(id).unwrap() else { + return store_step(harness, operation, before, OperationOutcome::NoTarget); + }; + if record.resolution_code.is_none() { + OperationOutcome::NoTarget + } else { + assert!(matches!( + harness.store.resolve_quarantine( + id, + &QuarantineResolution { + code: QuarantineResolutionCode::DuplicatePayload, + resolved_at: at("2101-01-01T00:00:00Z"), + }, + ), + Err(StoreError::QuarantineResolutionConflict { .. }) + )); + OperationOutcome::ResolutionConflict + } + } + FoundationOperation::Prune { future_cutoff } => { + let report = harness + .store + .prune(if future_cutoff { + at("2200-01-01T00:00:00Z") + } else { + at("2000-01-01T00:00:00Z") + }) + .unwrap(); + assert_eq!(report.execution_binding_revisions_deleted, 0); + assert_eq!(report.transitions_deleted, 0); + assert_eq!(report.audit_events_deleted, 0); + assert_eq!(report.unresolved_quarantine_deleted, 0); + OperationOutcome::Pruned(report.resolved_quarantine_deleted) + } + FoundationOperation::Checkpoint => { + harness.store.checkpoint().unwrap(); + OperationOutcome::Checkpointed + } + FoundationOperation::Reopen => { + harness.store = Store::open(&harness.path).unwrap(); + OperationOutcome::Reopened + } + }; + store_step(harness, operation, before, outcome) +} + +fn store_step( + harness: &FoundationStore, + operation: FoundationOperation, + before: FoundationSnapshot, + outcome: OperationOutcome, +) -> FoundationStep { + let snapshot = store_snapshot(harness); + if operation.must_preserve_logical_state() { + assert_eq!(snapshot, before, "{operation:?}"); + } + FoundationStep { outcome, snapshot } +} + +fn store_snapshot(harness: &FoundationStore) -> FoundationSnapshot { + let mut records = BTreeMap::new(); + let mut record_digests = BTreeMap::new(); + let mut binding_revisions = BTreeMap::new(); + let mut binding_digests = BTreeMap::new(); + let mut transitions = BTreeMap::new(); + let mut quarantines = BTreeMap::new(); + for slot in 0..2 { + if let Some(document) = harness + .store + .load(SchemaKind::Intent, &intent_id(slot)) + .unwrap() + { + record_digests.insert(slot, digest(&document).unwrap()); + records.insert(slot, document); + } + let history = harness + .store + .execution_binding_revisions(&attempt_id(slot)) + .unwrap(); + if !history.is_empty() { + binding_digests.insert( + slot, + history + .iter() + .map(|binding| digest(binding).unwrap()) + .collect(), + ); + binding_revisions.insert(slot, history); + } + let history = harness.store.transitions(&attempt_id(slot)).unwrap(); + if !history.is_empty() { + transitions.insert(slot, history); + } + if let Some(id) = harness.quarantine_ids.get(&slot) { + if let Some(record) = harness.store.quarantine_record(id).unwrap() { + quarantines.insert( + slot, + QuarantineObservation { + payload_digest: record.payload_digest, + reason: record.reason, + resolution_code: record.resolution_code, + resolved_at: record.resolved_at, + }, + ); + } + } + } + let audit_events = harness + .store + .audit_events() + .unwrap() + .into_iter() + .map(|event| AuditObservation { + event_code: event.event_code, + created_at: event.created_at, + }) + .collect(); + FoundationSnapshot { + records, + record_digests, + binding_revisions, + binding_digests, + transitions, + quarantines, + audit_events, + total_record_count: harness.store.total_record_count().unwrap(), + transition_count: harness.store.count_transitions().unwrap(), + } +} + +fn fixture_intent(slot: u8, changed: bool) -> CanonicalDocument { + CanonicalDocument::Intent(Intent { + schema_version: schema("psyche.intent.v1"), + intent_id: intent_id(slot), + principal_id: "principal-a".to_owned(), + familiar_snapshot_id: snapshot_id(slot), + project_id: format!("project-{slot}"), + requested_outcome: if changed { + "changed immutable outcome" + } else { + "original immutable outcome" + } + .to_owned(), + constraints: Map::new(), + required_evidence: vec!["review".to_owned()], + surface_event_id: None, + created_at: at("2026-08-05T12:00:00Z"), + digest: if changed { + digest_of('b') + } else { + digest_of('a') + }, + }) +} + +fn fixture_binding(slot: u8) -> ExecutionBinding { + ExecutionBinding { + schema_version: schema("psyche.execution_binding.v1"), + attempt_id: attempt_id(slot), + revision: 1, + previous_revision_digest: None, + revision_created_at: at("2026-08-05T12:00:00Z"), + familiar_snapshot_id: snapshot_id(slot), + project_id: format!("project-{slot}"), + request_id: request_id(slot), + request_digest: digest_of(if slot == 0 { 'a' } else { 'b' }), + request_created_at: at("2026-08-05T11:59:00Z"), + request_valid_until: at("2026-08-05T12:05:00Z"), + coven_contract_version: "coven.daemon.v1".to_owned(), + coven_session_id: None, + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:0".to_owned()), + cancellation_state: CancellationState::NotRequested, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + } +} + +fn next_binding(previous: &ExecutionBinding, slot: u8) -> ExecutionBinding { + let mut next = previous.clone(); + next.revision = previous.revision.checked_add(1).unwrap(); + next.previous_revision_digest = Some(digest(previous).unwrap()); + next.revision_created_at = previous.revision_created_at + Duration::nanoseconds(1); + if previous.coven_session_id.is_none() { + next.coven_session_id = Some(format!("session-{slot}")); + } else if previous.termination_request.is_none() { + next.cancellation_state = CancellationState::TerminationRequested; + next.termination_request = Some(TerminationRequestCorrelation { + termination_request_id: request_id(slot.saturating_add(10)), + created_at: at("2026-08-05T12:01:00Z"), + valid_until: at("2026-08-05T12:03:00Z"), + }); + next.termination_reason_code = Some("operator_request".to_owned()); + } + next +} + +fn invalid_binding(previous: &ExecutionBinding, slot: u8, mutation: u8) -> ExecutionBinding { + let mut candidate = next_binding(previous, slot); + match mutation { + 0 => { + candidate.revision = previous.revision; + candidate.previous_revision_digest = previous.previous_revision_digest.clone(); + candidate.event_cursor = Some("cursor:fork".to_owned()); + } + 1 => { + candidate.revision = previous.revision.saturating_add(2); + } + 2 => candidate.previous_revision_digest = Some(digest_of('e')), + 3 => candidate.attempt_id = attempt_id(slot.saturating_add(10)), + 4 => candidate.familiar_snapshot_id = snapshot_id(slot.saturating_add(10)), + 5 => candidate.project_id = "project-mismatch".to_owned(), + 6 => candidate.request_id = request_id(slot.saturating_add(20)), + 7 => candidate.request_digest = digest_of('e'), + 8 => candidate.request_created_at += Duration::seconds(1), + 9 => candidate.request_valid_until -= Duration::seconds(1), + 10 => candidate.coven_contract_version = "coven.daemon.v2".to_owned(), + 11 => { + if previous.coven_session_id.is_some() { + candidate.coven_session_id = Some("session-rebound".to_owned()); + } else { + candidate.project_id = "project-mismatch".to_owned(); + } + } + 12 => { + if previous.termination_request.is_some() { + let termination = candidate + .termination_request + .as_mut() + .expect("candidate retains termination"); + termination.termination_request_id = request_id(slot.saturating_add(20)); + } else { + candidate.project_id = "project-mismatch".to_owned(); + } + } + 13 => { + if previous.termination_request.is_some() { + let termination = candidate + .termination_request + .as_mut() + .expect("candidate retains termination"); + termination.created_at += Duration::seconds(1); + } else { + candidate.project_id = "project-mismatch".to_owned(); + } + } + 14 => { + if previous.termination_request.is_some() { + let termination = candidate + .termination_request + .as_mut() + .expect("candidate retains termination"); + termination.valid_until += Duration::seconds(1); + } else { + candidate.project_id = "project-mismatch".to_owned(); + } + } + _ => { + if previous.termination_reason_code.is_some() { + candidate.termination_reason_code = Some("changed_reason".to_owned()); + } else { + candidate.project_id = "project-mismatch".to_owned(); + } + } + } + candidate +} + +fn next_transition(history: &[Transition], slot: u8) -> Transition { + let version = u64::try_from(history.len()).unwrap().saturating_add(1); + Transition::new( + SchemaKind::ExecutionBinding, + attempt_id(slot), + version, + history.last().map(|transition| transition.to_state.clone()), + format!("state_{version}"), + at("2026-08-05T12:00:00Z") + Duration::seconds(i64::try_from(version).unwrap()), + ) + .unwrap() +} + +fn unknown_major_bytes(slot: u8) -> Vec { + serde_json::to_vec(&json!({ + "schema_version": "psyche.intent.v2", + "slot": slot, + })) + .unwrap() +} + +fn first_resolution(slot: u8) -> QuarantineResolution { + QuarantineResolution { + code: QuarantineResolutionCode::ConfirmedInvalid, + resolved_at: at("2100-01-01T00:00:00Z") + Duration::seconds(i64::from(slot)), + } +} + +fn schema(value: &str) -> SchemaVersion { + SchemaVersion::parse(value).unwrap() +} + +fn record_id(kind: RecordKind, slot: u8) -> RecordId { + RecordId::parse( + kind, + &format!("{}01J000000000000000000000{slot:02}", kind.prefix()), + ) + .unwrap() +} + +fn intent_id(slot: u8) -> RecordId { + record_id(RecordKind::Intent, slot) +} + +fn attempt_id(slot: u8) -> RecordId { + record_id(RecordKind::Attempt, slot) +} + +fn snapshot_id(slot: u8) -> RecordId { + record_id(RecordKind::IdentitySnapshot, slot) +} + +fn request_id(slot: u8) -> RequestId { + RequestId::parse(&format!("req_01J000000000000000000000{slot:02}")).unwrap() +} + +fn digest_of(character: char) -> Sha256Digest { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() +} + +fn at(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + +fn quarantine_as_unknown_major( + store: &mut Store, + payload: Vec, +) -> Result { + let bytes = serde_json::to_vec(&json!({ + "schema_version": "psyche.intent.v2", + "payload": payload, + })) + .unwrap(); + store.ingest(&bytes) +} + +fn fixture_graph_bytes_with_state(unknown_state: &str) -> Vec { + serde_json::to_vec(&json!({ + "schema_version": "psyche.graph.v1", + "graph_id": "grf_01J00000000000000000000003", + "root_intent_id": "int_01J00000000000000000000004", + "owner_principal_id": "principal:one", + "policy_revision": "policy:one", + "state": unknown_state, + "version": 1 + })) + .unwrap() +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: None, + .. ProptestConfig::default() + })] + + #[test] + fn model_and_store_agree_after_any_foundation_operation_sequence( + operations in proptest::collection::vec(any::(), 1..64) + ) { + let (mut store, _dir) = test_store(); + let mut model = FoundationModel::default(); + for operation in operations { + let expected = model.apply(operation.clone()); + let actual = apply_to_store(&mut store, operation); + prop_assert_eq!(expected, actual); + } + } +} + +#[derive(Debug, Clone)] +enum CovenRecoveryOperation { + MarkAmbiguous, + Reconcile { fenced: bool, mutation: u8 }, + DisconnectBeforeDisposition { fenced: bool, stall: bool }, + DisconnectAfterDisposition { fenced: bool }, + Restart, + AttemptRedispatch, +} + +impl Arbitrary for CovenRecoveryOperation { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + prop_oneof![ + 4 => Just(Self::MarkAmbiguous), + 5 => (any::(), 0_u8..3) + .prop_map(|(fenced, mutation)| Self::Reconcile { fenced, mutation }), + 3 => (any::(), any::()).prop_map(|(fenced, stall)| { + Self::DisconnectBeforeDisposition { fenced, stall } + }), + 3 => any::() + .prop_map(|fenced| Self::DisconnectAfterDisposition { fenced }), + 2 => Just(Self::Restart), + 3 => Just(Self::AttemptRedispatch), + ] + .boxed() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryState { + Clean, + Ambiguous, + Returned, + Fenced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryDispatchDecision { + Rejected, + RedispatchEligible, +} + +#[derive(Debug)] +struct CovenRecoveryModel { + state: RecoveryState, + adoption_calls: u64, + request: Option, + disposition: Option, +} + +impl Default for CovenRecoveryModel { + fn default() -> Self { + Self { + state: RecoveryState::Clean, + adoption_calls: 0, + request: None, + disposition: None, + } + } +} + +fn reconciliation_for(correlation: ExecutionCorrelation, fenced: bool) -> ReconciliationRequest { + ReconciliationRequest { + correlation, + ambiguity_digest: digest_of('d'), + reason_code: if fenced { + "fence_ambiguous" + } else { + "return_original" + } + .to_owned(), + } +} + +fn mutate_reconciliation(request: &ReconciliationRequest, mutation: u8) -> ReconciliationRequest { + let mut changed = request.clone(); + match mutation { + 1 => changed.correlation.project_id = "project:sha256:changed".to_owned(), + 2 => changed.ambiguity_digest = digest_of('e'), + _ => {} + } + changed +} + +async fn compare_c_s6_model_and_fixture( + operations: Vec, +) -> Result<(), TestCaseError> { + let mut fixture = scripted_fixture(); + let adoption = launch_adoption(); + let correlation = adoption.correlation(); + let mut model = CovenRecoveryModel::default(); + + for operation in operations { + match operation { + CovenRecoveryOperation::MarkAmbiguous => { + fixture.reset().await; + fixture + .select_fault(CovenFaultPoint::AdoptionAfterCommit) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + fixture.port().adopt(adoption.clone()).await, + Err(PortError::Unavailable) + ); + fixture.clear_fault().await; + model = CovenRecoveryModel { + state: RecoveryState::Ambiguous, + adoption_calls: 1, + request: None, + disposition: None, + }; + } + CovenRecoveryOperation::Reconcile { fenced, mutation } + if model.state != RecoveryState::Clean => + { + let exact = model + .request + .clone() + .unwrap_or_else(|| reconciliation_for(correlation.clone(), fenced)); + let candidate = if model.request.is_some() + && exact.reason_code + != reconciliation_for(correlation.clone(), fenced).reason_code + { + reconciliation_for(correlation.clone(), fenced) + } else { + exact.clone() + }; + let candidate = mutate_reconciliation(&candidate, mutation); + let changed = if model.state == RecoveryState::Ambiguous { + mutation == 1 + } else { + candidate != exact + }; + let result = fixture.port().reconcile(candidate.clone()).await; + if changed { + prop_assert_eq!(result, Err(PortError::IntentConflict)); + } else if model.state == RecoveryState::Ambiguous { + let disposition = + result.map_err(|error| TestCaseError::fail(error.to_string()))?; + model.state = if fenced { + RecoveryState::Fenced + } else { + RecoveryState::Returned + }; + model.request = Some(candidate); + model.disposition = Some(disposition); + } else { + prop_assert_eq!(result, Ok(model.disposition.clone().unwrap())); + } + } + CovenRecoveryOperation::DisconnectBeforeDisposition { fenced, stall } + if model.state == RecoveryState::Ambiguous => + { + let point = if stall { + CovenFaultPoint::ReconcileStall + } else { + CovenFaultPoint::ReconcileBeforeDisposition + }; + fixture + .select_fault(point) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + let request = reconciliation_for(correlation.clone(), fenced); + let expected = if stall { + PortError::Stalled + } else { + PortError::Unavailable + }; + prop_assert_eq!(fixture.port().reconcile(request).await, Err(expected)); + fixture.restart().await; + prop_assert!( + fixture + .observations() + .await + .durable_reconciliation + .is_none() + ); + fixture.clear_fault().await; + } + CovenRecoveryOperation::DisconnectAfterDisposition { fenced } + if model.state == RecoveryState::Ambiguous => + { + let request = reconciliation_for(correlation.clone(), fenced); + fixture + .select_fault(CovenFaultPoint::ReconcileAfterDisposition) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + fixture.port().reconcile(request.clone()).await, + Err(PortError::Unavailable) + ); + let committed = fixture + .observations() + .await + .durable_reconciliation + .ok_or_else(|| TestCaseError::fail("after-commit disposition was lost"))?; + fixture.restart().await; + fixture.clear_fault().await; + let disposition = fixture + .port() + .reconcile(request.clone()) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + committed.disposition_id.as_str(), + match &disposition { + ReconciliationDisposition::Returned { disposition_id, .. } + | ReconciliationDisposition::Fenced { disposition_id, .. } => { + disposition_id.as_str() + } + ReconciliationDisposition::Unresolved => "", + } + ); + model.state = if fenced { + RecoveryState::Fenced + } else { + RecoveryState::Returned + }; + model.request = Some(request); + model.disposition = Some(disposition); + } + CovenRecoveryOperation::Restart => fixture.restart().await, + CovenRecoveryOperation::AttemptRedispatch => { + let decision = match model.state { + RecoveryState::Fenced => RecoveryDispatchDecision::RedispatchEligible, + RecoveryState::Clean | RecoveryState::Ambiguous | RecoveryState::Returned => { + RecoveryDispatchDecision::Rejected + } + }; + if matches!( + model.state, + RecoveryState::Ambiguous | RecoveryState::Returned + ) { + prop_assert_eq!(decision, RecoveryDispatchDecision::Rejected); + } + let before = fixture.observations().await.adoption_calls; + if decision == RecoveryDispatchDecision::RedispatchEligible { + prop_assert_eq!(model.state, RecoveryState::Fenced); + } + prop_assert_eq!(fixture.observations().await.adoption_calls, before); + } + _ => {} + } + + let observations = fixture.observations().await; + prop_assert_eq!(observations.adoption_calls, model.adoption_calls); + match model.state { + RecoveryState::Clean | RecoveryState::Ambiguous => { + prop_assert!(observations.durable_reconciliation.is_none()); + } + RecoveryState::Returned => { + let durable = observations + .durable_reconciliation + .ok_or_else(|| TestCaseError::fail("returned disposition was not durable"))?; + prop_assert_eq!(durable.correlation, correlation.clone()); + let DurableDispositionKind::Returned { session_id } = durable.kind else { + return Err(TestCaseError::fail("returned model observed a fence")); + }; + prop_assert_eq!(session_id, "session-1"); + let resumed = fixture + .port() + .inspect("session-1") + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!(resumed.correlation, correlation.clone()); + } + RecoveryState::Fenced => { + let durable = observations + .durable_reconciliation + .ok_or_else(|| TestCaseError::fail("fenced disposition was not durable"))?; + prop_assert_eq!(durable.correlation, correlation.clone()); + let DurableDispositionKind::Fenced { fence_token } = durable.kind else { + return Err(TestCaseError::fail("fenced model observed a return")); + }; + prop_assert!(!fence_token.is_empty()); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +enum RequestDigestOperation { + ConstructRequest { input: bool }, + ReplayRequest, + MutateRequestFieldRetainDigest { field: u8 }, + Restart, +} + +impl Arbitrary for RequestDigestOperation { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + prop_oneof![ + 4 => any::().prop_map(|input| Self::ConstructRequest { input }), + 3 => Just(Self::ReplayRequest), + 6 => any::().prop_map(|field| Self::MutateRequestFieldRetainDigest { field }), + 2 => Just(Self::Restart), + ] + .boxed() + } +} + +#[derive(Debug, Default)] +struct RequestDigestModel { + request: Option, + disposition: Option, + adoption_calls: u64, +} + +async fn compare_request_digest_model_and_fixture( + operations: Vec, +) -> Result<(), TestCaseError> { + let mut fixture = scripted_fixture(); + let mut model = RequestDigestModel::default(); + for operation in operations { + match operation { + RequestDigestOperation::ConstructRequest { input } => { + fixture.reset().await; + model = RequestDigestModel::default(); + if input { + fixture + .port() + .adopt(launch_adoption()) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + model.adoption_calls = 1; + } + let request = if input { + session_input_adoption() + } else { + launch_adoption() + }; + prop_assert_eq!( + digest(request.input()).unwrap(), + request.request_digest().clone() + ); + prop_assert!(!canonical_bytes(request.input()).unwrap().is_empty()); + prop_assert_eq!( + request.recompute_digest().unwrap(), + request.request_digest().clone() + ); + let disposition = fixture + .port() + .adopt(request.clone()) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + model.adoption_calls = model.adoption_calls.saturating_add(1); + model.request = Some(request); + model.disposition = Some(disposition); + } + RequestDigestOperation::ReplayRequest => { + if let (Some(request), Some(disposition)) = (&model.request, &model.disposition) { + fixture.restart().await; + prop_assert_eq!( + fixture.port().adopt(request.clone()).await, + Ok(disposition.clone()) + ); + } + } + RequestDigestOperation::MutateRequestFieldRetainDigest { field } => { + if let Some(request) = &model.request { + let mutations = stale_digest_requests(request); + let (_, forged) = &mutations[usize::from(field) % mutations.len()]; + let before = fixture.observations().await; + prop_assert_eq!( + fixture.port().adopt(forged.clone()).await, + Err(PortError::RequestDigestMismatch) + ); + prop_assert_eq!(fixture.observations().await, before); + prop_assert_eq!( + fixture.port().adopt(request.clone()).await, + Ok(model.disposition.clone().unwrap()) + ); + } + } + RequestDigestOperation::Restart => fixture.restart().await, + } + prop_assert_eq!( + fixture.observations().await.adoption_calls, + model.adoption_calls + ); + } + Ok(()) +} + +fn launch_adoption() -> AdoptionRequest { + let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); + AdoptionRequest::new(input).unwrap() +} + +fn session_input_adoption() -> AdoptionRequest { + let mut value: serde_json::Value = serde_json::from_slice(INPUT_GOLDEN).unwrap(); + value["request_id"] = json!("req_01J00000000000000000000003"); + AdoptionRequest::new(serde_json::from_value(value).unwrap()).unwrap() +} + +fn stale_digest_requests(request: &AdoptionRequest) -> Vec<(&'static str, AdoptionRequest)> { + let mut mutations: Vec<(&str, serde_json::Value)> = match request.input() { + ExecutionRequestInput::Launch { .. } => vec![ + ( + "/input/schema_version", + json!("psyche.execution_request.v2"), + ), + ("/input/request_id", json!("req_01J00000000000000000000011")), + ("/input/graph_id", json!("grf_01J00000000000000000000011")), + ("/input/node_id", json!("nod_01J00000000000000000000011")), + ("/input/attempt_id", json!("att_01J00000000000000000000011")), + ("/input/principal_id", json!("principal:changed")), + ( + "/input/familiar_snapshot_id", + json!("ids_01J00000000000000000000011"), + ), + ("/input/project_id", json!("project:sha256:changed")), + ("/input/project_root", json!("/workspace/changed")), + ("/input/cwd", json!("/workspace/project/changed")), + ("/input/harness", json!("future_harness")), + ( + "/input/context_manifest_digest", + json!(digest_of('7').as_str()), + ), + ("/input/delegation_digest", json!(digest_of('8').as_str())), + ("/input/budget_digest", json!(digest_of('9').as_str())), + ( + "/input/required_artifact_bindings/0/artifact_id", + json!("artifact-changed"), + ), + ( + "/input/required_artifact_bindings/0/digest", + json!(digest_of('a').as_str()), + ), + ( + "/input/required_artifact_bindings/0/media_type", + json!("application/json"), + ), + ("/input/required_artifact_bindings/0/size", json!(13)), + ( + "/input/required_artifact_bindings", + json!([ + { + "artifact_id": "artifact-2", + "digest": digest_of('a').as_str(), + "media_type": "application/json", + "size": 7 + }, + { + "artifact_id": "artifact-1", + "digest": digest_of('3').as_str(), + "media_type": "text/plain", + "size": 12 + } + ]), + ), + ("/input/payload_digest", json!(digest_of('b').as_str())), + ("/input/created_at", json!("2026-08-05T14:00:01Z")), + ("/input/valid_until", json!("2026-08-05T14:04:59Z")), + ], + ExecutionRequestInput::Input { .. } => vec![ + ( + "/input/schema_version", + json!("psyche.execution_request.v2"), + ), + ("/input/request_id", json!("req_01J00000000000000000000011")), + ("/input/graph_id", json!("grf_01J00000000000000000000011")), + ("/input/node_id", json!("nod_01J00000000000000000000011")), + ("/input/attempt_id", json!("att_01J00000000000000000000011")), + ("/input/principal_id", json!("principal:changed")), + ( + "/input/familiar_snapshot_id", + json!("ids_01J00000000000000000000011"), + ), + ("/input/project_id", json!("project:sha256:changed")), + ("/input/session_id", json!("session-changed")), + ("/input/input_digest", json!(digest_of('7').as_str())), + ( + "/input/context_manifest_digest", + json!(digest_of('8').as_str()), + ), + ( + "/input/required_artifact_bindings", + json!([{ + "artifact_id": "artifact-new", + "digest": digest_of('9').as_str(), + "media_type": "text/plain", + "size": 1 + }]), + ), + ("/input/payload_digest", json!(digest_of('a').as_str())), + ("/input/created_at", json!("2026-08-05T14:01:01Z")), + ("/input/valid_until", json!("2026-08-05T14:05:59Z")), + ], + }; + let mut other_input: serde_json::Value = + if matches!(request.input(), ExecutionRequestInput::Launch { .. }) { + serde_json::from_slice(INPUT_GOLDEN).unwrap() + } else { + serde_json::from_slice(LAUNCH_GOLDEN).unwrap() + }; + other_input["request_id"] = json!(request.correlation().request_id.as_str()); + mutations.push(("/input", other_input)); + mutations + .into_iter() + .map(|(pointer, replacement)| { + let mut value = serde_json::to_value(request).unwrap(); + *value.pointer_mut(pointer).unwrap() = replacement; + (pointer, serde_json::from_value(value).unwrap()) + }) + .collect() +} + +fn runtime() -> &'static tokio::runtime::Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap() + }) +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: None, + .. ProptestConfig::default() + })] + + #[test] + fn c_s6_model_never_redispatches_without_fence( + operations in proptest::collection::vec(any::(), 1..64) + ) { + runtime().block_on(compare_c_s6_model_and_fixture(operations))?; + } + + #[test] + fn request_digest_binds_every_typed_field( + operations in proptest::collection::vec(any::(), 1..64) + ) { + runtime().block_on(compare_request_digest_model_and_fixture(operations))?; + } +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: None, + .. ProptestConfig::default() + })] + + #[test] + fn unknown_schema_operations_never_create_dispatchable_records( + payload in proptest::collection::vec(any::(), 0..8192) + ) { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(&dir.path().join("private").join("psyche.sqlite3")).unwrap(); + let outcome = quarantine_as_unknown_major(&mut store, payload).unwrap(); + prop_assert!(matches!(outcome, IngestOutcome::Quarantined { .. }), "unknown major was not quarantined"); + prop_assert_eq!(store.total_record_count().unwrap(), 0); + prop_assert_eq!(store.count_transitions().unwrap(), 0); + } + + #[test] + fn unknown_enum_operations_never_create_dispatchable_records( + unknown_state in "future_[a-z]{1,24}" + ) { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(&dir.path().join("private").join("psyche.sqlite3")).unwrap(); + let outcome = store.ingest(&fixture_graph_bytes_with_state(&unknown_state)).unwrap(); + prop_assert!(matches!(outcome, IngestOutcome::Quarantined { .. }), "unknown enum was not quarantined"); + prop_assert_eq!(store.total_record_count().unwrap(), 0); + prop_assert_eq!(store.count_transitions().unwrap(), 0); + } +} From 8525629fc89c810b34b411d9d313d211aee0ec87 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:38:53 -0500 Subject: [PATCH 51/66] test(g2): add reusable state-machine suites --- crates/psyche-test-support/src/coven.rs | 114 ++- crates/psyche-test-support/src/lib.rs | 3 +- .../psyche-test-support/src/suites/coven.rs | 838 ++++++++++++------ crates/psyche-test-support/tests/fakes.rs | 204 ++--- .../tests/state_machine.rs | 182 +++- 5 files changed, 852 insertions(+), 489 deletions(-) diff --git a/crates/psyche-test-support/src/coven.rs b/crates/psyche-test-support/src/coven.rs index cbeb1f8..bfcd555 100644 --- a/crates/psyche-test-support/src/coven.rs +++ b/crates/psyche-test-support/src/coven.rs @@ -195,13 +195,22 @@ pub enum FixtureAvailability { }, } -/// A payload-free conformance fixture control failure. +/// Eligibility reported by the durable reconciliation authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedispatchEligibility { + /// Redispatch is forbidden because no durable fence exists. + Blocked, + /// A durable fence makes a separate redispatch decision eligible. + EligibleAfterFence, +} + +/// Structured conformance-fixture control failure. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum FixtureControlError { - /// The fixture does not implement the selected fault point. + /// The selected fault is not implemented by this fixture. #[error("fixture fault point is unsupported")] UnsupportedFault, - /// The fixture control state could not be accessed. + /// The fixture runtime control state is unavailable. #[error("fixture control state is unavailable")] Unavailable, } @@ -215,9 +224,6 @@ pub trait CovenConformanceFixture { /// Reports whether the fixture can execute a case. fn availability(&self, case: CovenConformanceCase) -> FixtureAvailability; - /// Reports whether the fixture implements a fault point. - fn supports(&self, point: CovenFaultPoint) -> bool; - /// Restarts the fixture while retaining its durable state. async fn restart(&mut self); @@ -225,13 +231,19 @@ pub trait CovenConformanceFixture { async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError>; /// Clears the selected fault. - async fn clear_fault(&mut self); + async fn clear_fault(&mut self) -> Result<(), FixtureControlError>; /// Restores the fixture to its initial clean state and script. async fn reset(&mut self); /// Returns an immutable, payload-free observation snapshot. async fn observations(&self) -> CovenConformanceObservations; + + /// Queries durable redispatch eligibility without dispatching work. + async fn redispatch_eligibility( + &self, + correlation: &ExecutionCorrelation, + ) -> Result; } /// Typed response carried by a successful fake script step. @@ -345,7 +357,19 @@ struct FakeState { termination_in_flight: BTreeMap>>, } -/// Honest, deterministic, thread-safe Coven fake. +/// Honest, deterministic, thread-safe low-level Coven port fake. +/// +/// `FakeCoven` deliberately is not a reusable conformance fixture; arbitrary +/// scripts cannot truthfully advertise support for the complete mandatory +/// fault matrix. +/// +/// ```compile_fail +/// use psyche_test_support::{CovenConformanceFixture, FakeCoven}; +/// +/// fn require_conformance_fixture(_: &dyn CovenConformanceFixture) {} +/// let fake = FakeCoven::builder().build().unwrap(); +/// require_conformance_fixture(&fake); +/// ``` #[derive(Clone)] pub struct FakeCoven { contract: String, @@ -1115,33 +1139,15 @@ impl CovenPort for FakeCoven { } } -#[async_trait::async_trait] -impl CovenConformanceFixture for FakeCoven { - fn port(&self) -> &dyn CovenPort { - self - } - - fn availability(&self, _case: CovenConformanceCase) -> FixtureAvailability { - FixtureAvailability::ExpectedUnsupported { - code: "task_9_conformance_case_not_implemented".to_owned(), - } - } - - fn supports(&self, point: CovenFaultPoint) -> bool { - matches!( +impl FakeCoven { + /// Selects one of the low-level reconciliation faults implemented by this fake. + pub async fn select_fault(&self, point: CovenFaultPoint) -> Result<(), FixtureControlError> { + if !matches!( point, CovenFaultPoint::ReconcileBeforeDisposition | CovenFaultPoint::ReconcileAfterDisposition | CovenFaultPoint::ReconcileStall - ) - } - - async fn restart(&mut self) { - *self = FakeCoven::restart(self); - } - - async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError> { - if !self.supports(point) { + ) { return Err(FixtureControlError::UnsupportedFault); } let mut state = self @@ -1152,13 +1158,18 @@ impl CovenConformanceFixture for FakeCoven { Ok(()) } - async fn clear_fault(&mut self) { - if let Ok(mut state) = self.state.lock() { - state.selected_fault = None; - } + /// Clears the selected low-level reconciliation fault. + pub async fn clear_fault(&self) -> Result<(), FixtureControlError> { + let mut state = self + .state + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + state.selected_fault = None; + Ok(()) } - async fn reset(&mut self) { + /// Restores the fake to its initial script and clears all recorded state. + pub async fn reset(&self) { if let Ok(mut state) = self.state.lock() { *state = FakeState { script: (*self.initial_script).clone(), @@ -1167,7 +1178,8 @@ impl CovenConformanceFixture for FakeCoven { } } - async fn observations(&self) -> CovenConformanceObservations { + /// Returns a redacted snapshot of low-level calls and durable reconciliation state. + pub async fn observations(&self) -> CovenConformanceObservations { let Ok(state) = self.state.lock() else { return CovenConformanceObservations::default(); }; @@ -1224,6 +1236,34 @@ impl CovenConformanceFixture for FakeCoven { durable_reconciliation, } } + + /// Reports whether a durable reconciliation fence permits a later redispatch decision. + pub async fn redispatch_eligibility( + &self, + correlation: &ExecutionCorrelation, + ) -> Result { + let state = self + .state + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + Ok( + match state + .reconciliations + .get(correlation.request_id.as_str()) + .filter(|(request, _)| request.correlation == *correlation) + .map(|(_, disposition)| disposition) + { + Some(ReconciliationDisposition::Fenced { .. }) => { + RedispatchEligibility::EligibleAfterFence + } + Some( + ReconciliationDisposition::Returned { .. } + | ReconciliationDisposition::Unresolved, + ) + | None => RedispatchEligibility::Blocked, + }, + ) + } } /// Real Store-backed implementation of the narrow termination persistence port. diff --git a/crates/psyche-test-support/src/lib.rs b/crates/psyche-test-support/src/lib.rs index a7d31c8..909d0f8 100644 --- a/crates/psyche-test-support/src/lib.rs +++ b/crates/psyche-test-support/src/lib.rs @@ -8,7 +8,8 @@ pub use coven::{ BeforeTerminate, CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, CovenScriptReturn, CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, FakeBuildError, FakeCoven, FakeCovenBuilder, FakeError, - FakeOperation, FixtureAvailability, FixtureControlError, StoreTerminationPersistence, + FakeOperation, FixtureAvailability, FixtureControlError, RedispatchEligibility, + StoreTerminationPersistence, }; pub use suites::{ ConformanceOutcome, ScriptedG2Fixture, UnsupportedCovenFixture, diff --git a/crates/psyche-test-support/src/suites/coven.rs b/crates/psyche-test-support/src/suites/coven.rs index 196c3b4..9366db6 100644 --- a/crates/psyche-test-support/src/suites/coven.rs +++ b/crates/psyche-test-support/src/suites/coven.rs @@ -25,7 +25,7 @@ use super::ConformanceOutcome; use crate::coven::{ CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, DurableDispositionKind, DurableDispositionObservation, FixtureAvailability, - FixtureControlError, + FixtureControlError, RedispatchEligibility, }; const CONTRACT: &str = "coven.daemon.v1"; @@ -57,7 +57,6 @@ struct DurableAdoption { #[derive(Debug, Clone, Default)] struct SessionState { correlations: Vec, - inspect_index: usize, authoritative_terminal: bool, } @@ -73,9 +72,14 @@ struct DurableTermination { disposition: TerminationDisposition, } +#[derive(Debug, Clone)] +struct DurableTerminationAcknowledgement { + binding: ExecutionBinding, + disposition: TerminationDisposition, +} + #[derive(Debug, Default)] -struct ScriptedState { - selected_fault: Option, +struct ScriptedDurableState { adoptions: BTreeMap, lookup_dispositions: BTreeMap, sessions: BTreeMap, @@ -83,15 +87,24 @@ struct ScriptedState { latest_reconciliation: Option, event_pages: BTreeMap<(String, u64), EventPage>, event_high_water: BTreeMap, + primary_results: BTreeMap, results: BTreeMap, + termination_acknowledgements: BTreeMap, terminations: BTreeMap, +} + +#[derive(Debug, Default)] +struct ScriptedRuntimeState { + selected_fault: Option, + inspect_indices: BTreeMap, adoption_calls: u64, reconciliation_calls: u64, } #[derive(Debug, Clone)] struct ScriptedG2Port { - state: Arc>, + durable: Arc>, + runtime: Arc>, } /// Deterministic, restartable fixture used for the scripted G2 evidence rows. @@ -104,25 +117,30 @@ pub struct ScriptedG2Fixture { pub fn scripted_fixture() -> ScriptedG2Fixture { ScriptedG2Fixture { port: ScriptedG2Port { - state: Arc::new(Mutex::new(ScriptedState::default())), + durable: Arc::new(Mutex::new(ScriptedDurableState::default())), + runtime: Arc::new(Mutex::new(ScriptedRuntimeState::default())), }, } } impl ScriptedG2Port { - fn state(&self) -> Result, PortError> { - self.state.lock().map_err(|_| PortError::Unavailable) + fn durable(&self) -> Result, PortError> { + self.durable.lock().map_err(|_| PortError::Unavailable) } - fn session_for_launch(state: &ScriptedState) -> String { + fn runtime(&self) -> Result, PortError> { + self.runtime.lock().map_err(|_| PortError::Unavailable) + } + + fn session_for_launch(state: &ScriptedDurableState) -> String { format!("session-{}", state.sessions.len().saturating_add(1)) } fn adoption_fault( - state: &ScriptedState, + selected_fault: Option, input: &ExecutionRequestInput, ) -> Option { - match (input, state.selected_fault) { + match (input, selected_fault) { ( ExecutionRequestInput::Launch { .. }, Some( @@ -149,6 +167,21 @@ impl ScriptedG2Port { } } + fn same_correlation_except_request_id( + stored: &ExecutionCorrelation, + candidate: &ExecutionCorrelation, + ) -> bool { + stored.request_id != candidate.request_id + && stored.request_digest == candidate.request_digest + && stored.familiar_snapshot_id == candidate.familiar_snapshot_id + && stored.project_id == candidate.project_id + && stored.graph_id == candidate.graph_id + && stored.node_id == candidate.node_id + && stored.attempt_id == candidate.attempt_id + && stored.created_at == candidate.created_at + && stored.valid_until == candidate.valid_until + } + fn result_for( session_id: &str, correlation: &ExecutionCorrelation, @@ -252,7 +285,25 @@ impl CovenPort for ScriptedG2Port { let canonical_input = canonical_bytes(request.input()).map_err(|_| PortError::InvalidRequest)?; let key = correlation.request_id.as_str().to_owned(); - let mut state = self.state()?; + { + let state = self.durable()?; + if let Some(stored) = state.adoptions.get(&key) { + return if stored.request_digest == *request.request_digest() + && stored.canonical_input == canonical_input + { + Ok(stored.disposition.clone()) + } else { + Err(PortError::IntentConflict) + }; + } + } + let selected_fault = { + let mut runtime = self.runtime()?; + runtime.adoption_calls = runtime.adoption_calls.saturating_add(1); + runtime.selected_fault + }; + let mut state = self.durable()?; + // Recheck after acquiring the runtime observation in case another caller committed first. if let Some(stored) = state.adoptions.get(&key) { return if stored.request_digest == *request.request_digest() && stored.canonical_input == canonical_input @@ -263,8 +314,7 @@ impl CovenPort for ScriptedG2Port { }; } - state.adoption_calls = state.adoption_calls.saturating_add(1); - let fault = Self::adoption_fault(&state, request.input()); + let fault = Self::adoption_fault(selected_fault, request.input()); if matches!( fault, Some(CovenFaultPoint::AdoptionBeforeCommit | CovenFaultPoint::InputBeforeCommit) @@ -312,8 +362,9 @@ impl CovenPort for ScriptedG2Port { } async fn lookup(&self, request_id: &RequestId) -> Result { - let mut state = self.state()?; - if state.selected_fault == Some(CovenFaultPoint::LookupBeforeRead) { + let selected_fault = self.runtime()?.selected_fault; + let mut state = self.durable()?; + if selected_fault == Some(CovenFaultPoint::LookupBeforeRead) { return Err(PortError::Unavailable); } let disposition = state @@ -326,7 +377,7 @@ impl CovenPort for ScriptedG2Port { .lookup_dispositions .entry(request_id.as_str().to_owned()) .or_insert_with(|| disposition.clone()); - if state.selected_fault == Some(CovenFaultPoint::LookupAfterRead) { + if selected_fault == Some(CovenFaultPoint::LookupAfterRead) { Err(PortError::Unavailable) } else { Ok(disposition) @@ -338,9 +389,13 @@ impl CovenPort for ScriptedG2Port { request: ReconciliationRequest, ) -> Result { request.validate()?; - let mut state = self.state()?; - state.reconciliation_calls = state.reconciliation_calls.saturating_add(1); - match state.selected_fault { + let selected_fault = { + let mut runtime = self.runtime()?; + runtime.reconciliation_calls = runtime.reconciliation_calls.saturating_add(1); + runtime.selected_fault + }; + let mut state = self.durable()?; + match selected_fault { Some(CovenFaultPoint::ReconcileBeforeDisposition) => { return Err(PortError::Unavailable); } @@ -355,6 +410,11 @@ impl CovenPort for ScriptedG2Port { Err(PortError::IntentConflict) }; } + if state.adoptions.values().any(|stored| { + Self::same_correlation_except_request_id(&stored.correlation, &request.correlation) + }) { + return Err(PortError::IntentConflict); + } let adoption = state.adoptions.get(&key).ok_or(PortError::Unavailable)?; if adoption.correlation != request.correlation { return Err(PortError::IntentConflict); @@ -393,7 +453,7 @@ impl CovenPort for ScriptedG2Port { }, ); state.latest_reconciliation = Some(key); - if state.selected_fault == Some(CovenFaultPoint::ReconcileAfterDisposition) { + if selected_fault == Some(CovenFaultPoint::ReconcileAfterDisposition) { Err(PortError::Unavailable) } else { Ok(disposition) @@ -404,21 +464,28 @@ impl CovenPort for ScriptedG2Port { if session_id.is_empty() || session_id.len() > 255 { return Err(PortError::InvalidRequest); } - let mut state = self.state()?; - let session = state - .sessions - .get_mut(session_id) - .ok_or(PortError::NotFound)?; - let correlation = session - .correlations - .first() - .cloned() - .ok_or(PortError::InvalidResponse)?; - let terminal_state = if session.authoritative_terminal { + let (correlation, authoritative_terminal) = { + let state = self.durable()?; + let session = state.sessions.get(session_id).ok_or(PortError::NotFound)?; + ( + session + .correlations + .first() + .cloned() + .ok_or(PortError::InvalidResponse)?, + session.authoritative_terminal, + ) + }; + let terminal_state = if authoritative_terminal { Some("authoritatively_terminated".to_owned()) } else { - let status = RAW_LEDGER_STATES[session.inspect_index % RAW_LEDGER_STATES.len()]; - session.inspect_index = session.inspect_index.saturating_add(1); + let mut runtime = self.runtime()?; + let inspect_index = runtime + .inspect_indices + .entry(session_id.to_owned()) + .or_default(); + let status = RAW_LEDGER_STATES[*inspect_index % RAW_LEDGER_STATES.len()]; + *inspect_index = inspect_index.saturating_add(1); Some(status.to_owned()) }; Ok(SessionSnapshot { @@ -430,16 +497,17 @@ impl CovenPort for ScriptedG2Port { async fn events(&self, cursor: EventCursor) -> Result { cursor.validate()?; - let mut state = self.state()?; + let selected_fault = self.runtime()?.selected_fault; + let mut state = self.durable()?; if !state.sessions.contains_key(&cursor.session_id) { return Err(PortError::CorrelationMismatch); } - if state.selected_fault == Some(CovenFaultPoint::CursorBeforePage) { + if selected_fault == Some(CovenFaultPoint::CursorBeforePage) { return Err(PortError::Unavailable); } let key = (cursor.session_id.clone(), cursor.after_sequence); if let Some(page) = state.event_pages.get(&key).cloned() { - return if state.selected_fault == Some(CovenFaultPoint::CursorAfterPage) { + return if selected_fault == Some(CovenFaultPoint::CursorAfterPage) { Err(PortError::Unavailable) } else { Ok(page) @@ -483,7 +551,7 @@ impl CovenPort for ScriptedG2Port { state .event_high_water .insert(cursor.session_id.clone(), next); - if state.selected_fault == Some(CovenFaultPoint::CursorAfterPage) { + if selected_fault == Some(CovenFaultPoint::CursorAfterPage) { Err(PortError::Unavailable) } else { Ok(page) @@ -494,19 +562,11 @@ impl CovenPort for ScriptedG2Port { if session_id.is_empty() || session_id.len() > 255 { return Err(PortError::InvalidRequest); } - let mut state = self.state()?; + let selected_fault = self.runtime()?.selected_fault; + let mut state = self.durable()?; if let Some(bundle) = state.results.get(session_id) { return Ok(bundle.clone()); } - if matches!( - state.selected_fault, - Some( - CovenFaultPoint::ResultBeforePersistence - | CovenFaultPoint::ArtifactBeforePersistence - ) - ) { - return Err(PortError::Unavailable); - } let correlation = state .sessions .get(session_id) @@ -514,6 +574,24 @@ impl CovenPort for ScriptedG2Port { .cloned() .ok_or(PortError::NotFound)?; let bundle = Self::result_for(session_id, &correlation)?; + if !state.primary_results.contains_key(session_id) { + if selected_fault == Some(CovenFaultPoint::ResultBeforePersistence) { + return Err(PortError::Unavailable); + } + state + .primary_results + .insert(session_id.to_owned(), bundle.result.clone()); + } + if state + .primary_results + .get(session_id) + .is_some_and(|result| result != &bundle.result) + { + return Err(PortError::IntentConflict); + } + if selected_fault == Some(CovenFaultPoint::ArtifactBeforePersistence) { + return Err(PortError::Unavailable); + } state.results.insert(session_id.to_owned(), bundle.clone()); Ok(bundle) } @@ -528,7 +606,8 @@ impl CovenPort for ScriptedG2Port { .as_ref() .ok_or(PortError::InvalidRequest)?; let key = termination.termination_request_id.as_str().to_owned(); - let mut state = self.state()?; + let selected_fault = self.runtime()?.selected_fault; + let mut state = self.durable()?; if let Some(stored) = state.terminations.get(&key) { return if stored.binding == binding { Ok(stored.disposition.clone()) @@ -547,16 +626,31 @@ impl CovenPort for ScriptedG2Port { }) { return Err(PortError::CorrelationMismatch); } - if matches!( - state.selected_fault, - Some( - CovenFaultPoint::CancellationBeforeAcknowledgement - | CovenFaultPoint::TerminalBeforePersistence - ) - ) { + let disposition = if let Some(stored) = state.termination_acknowledgements.get(&key) { + if stored.binding != binding { + return Err(PortError::IntentConflict); + } + stored.disposition.clone() + } else { + if selected_fault == Some(CovenFaultPoint::CancellationBeforeAcknowledgement) { + return Err(PortError::Unavailable); + } + let disposition = Self::termination_disposition(&binding)?; + state.termination_acknowledgements.insert( + key.clone(), + DurableTerminationAcknowledgement { + binding: binding.clone(), + disposition: disposition.clone(), + }, + ); + if selected_fault == Some(CovenFaultPoint::CancellationAfterAcknowledgement) { + return Err(PortError::Unavailable); + } + disposition + }; + if selected_fault == Some(CovenFaultPoint::TerminalBeforePersistence) { return Err(PortError::Unavailable); } - let disposition = Self::termination_disposition(&binding)?; state.terminations.insert( key, DurableTermination { @@ -569,11 +663,7 @@ impl CovenPort for ScriptedG2Port { session.authoritative_terminal = true; } } - if state.selected_fault == Some(CovenFaultPoint::CancellationAfterAcknowledgement) { - Err(PortError::Unavailable) - } else { - Ok(disposition) - } + Ok(disposition) } } @@ -587,36 +677,47 @@ impl CovenConformanceFixture for ScriptedG2Fixture { FixtureAvailability::Supported } - fn supports(&self, _point: CovenFaultPoint) -> bool { - true + async fn restart(&mut self) { + self.port = ScriptedG2Port { + durable: Arc::clone(&self.port.durable), + runtime: Arc::new(Mutex::new(ScriptedRuntimeState::default())), + }; } - async fn restart(&mut self) {} - async fn select_fault(&mut self, point: CovenFaultPoint) -> Result<(), FixtureControlError> { - let mut state = self + let mut runtime = self .port - .state + .runtime .lock() .map_err(|_| FixtureControlError::Unavailable)?; - state.selected_fault = Some(point); + runtime.selected_fault = Some(point); Ok(()) } - async fn clear_fault(&mut self) { - if let Ok(mut state) = self.port.state.lock() { - state.selected_fault = None; - } + async fn clear_fault(&mut self) -> Result<(), FixtureControlError> { + let mut runtime = self + .port + .runtime + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + runtime.selected_fault = None; + Ok(()) } async fn reset(&mut self) { - if let Ok(mut state) = self.port.state.lock() { - *state = ScriptedState::default(); + if let Ok(mut state) = self.port.durable.lock() { + *state = ScriptedDurableState::default(); + } + if let Ok(mut runtime) = self.port.runtime.lock() { + *runtime = ScriptedRuntimeState::default(); } } async fn observations(&self) -> CovenConformanceObservations { - let Ok(state) = self.port.state.lock() else { + let Ok(state) = self.port.durable.lock() else { + return CovenConformanceObservations::default(); + }; + let Ok(runtime) = self.port.runtime.lock() else { return CovenConformanceObservations::default(); }; let durable_reconciliation = state @@ -625,11 +726,39 @@ impl CovenConformanceFixture for ScriptedG2Fixture { .and_then(|key| state.reconciliations.get(key)) .and_then(|stored| disposition_observation(&stored.disposition)); CovenConformanceObservations { - adoption_calls: state.adoption_calls, - reconciliation_calls: state.reconciliation_calls, + adoption_calls: runtime.adoption_calls, + reconciliation_calls: runtime.reconciliation_calls, durable_reconciliation, } } + + async fn redispatch_eligibility( + &self, + correlation: &ExecutionCorrelation, + ) -> Result { + let state = self + .port + .durable + .lock() + .map_err(|_| FixtureControlError::Unavailable)?; + Ok( + match state + .reconciliations + .get(correlation.request_id.as_str()) + .filter(|stored| stored.request.correlation == *correlation) + .map(|stored| &stored.disposition) + { + Some(ReconciliationDisposition::Fenced { .. }) => { + RedispatchEligibility::EligibleAfterFence + } + Some( + ReconciliationDisposition::Returned { .. } + | ReconciliationDisposition::Unresolved, + ) + | None => RedispatchEligibility::Blocked, + }, + ) + } } fn disposition_observation( @@ -750,23 +879,28 @@ impl CovenConformanceFixture for UnsupportedCovenFixture { } } - fn supports(&self, _point: CovenFaultPoint) -> bool { - false - } - async fn restart(&mut self) {} async fn select_fault(&mut self, _point: CovenFaultPoint) -> Result<(), FixtureControlError> { Err(FixtureControlError::UnsupportedFault) } - async fn clear_fault(&mut self) {} + async fn clear_fault(&mut self) -> Result<(), FixtureControlError> { + Ok(()) + } async fn reset(&mut self) {} async fn observations(&self) -> CovenConformanceObservations { CovenConformanceObservations::default() } + + async fn redispatch_eligibility( + &self, + _correlation: &ExecutionCorrelation, + ) -> Result { + Ok(RedispatchEligibility::Blocked) + } } #[derive(Debug, Clone, Copy)] @@ -781,6 +915,20 @@ enum UnsupportedCall { Terminate, } +async fn require_fault(fixture: &mut dyn CovenConformanceFixture, point: CovenFaultPoint) { + fixture + .select_fault(point) + .await + .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); +} + +async fn require_clear_fault(fixture: &mut dyn CovenConformanceFixture) { + fixture + .clear_fault() + .await + .unwrap_or_else(|error| panic!("fixture fault must clear: {error}")); +} + async fn expected_unsupported( fixture: &mut dyn CovenConformanceFixture, case: CovenConformanceCase, @@ -795,10 +943,7 @@ async fn expected_unsupported( match call { UnsupportedCall::Negotiate => { assert_eq!( - fixture - .port() - .negotiate(NegotiateRequest::new(CONTRACT)) - .await, + fixture.port().negotiate(exact_negotiation_request()).await, Err(expected) ); } @@ -889,13 +1034,6 @@ fn launch_input() -> ExecutionRequestInput { } } -fn input_input() -> ExecutionRequestInput { - match serde_json::from_slice(INPUT_GOLDEN) { - Ok(input) => input, - Err(error) => panic!("canonical input fixture must decode: {error}"), - } -} - fn launch_request() -> AdoptionRequest { match AdoptionRequest::new(launch_input()) { Ok(request) => request, @@ -903,13 +1041,6 @@ fn launch_request() -> AdoptionRequest { } } -fn input_request() -> AdoptionRequest { - match AdoptionRequest::new(input_input()) { - Ok(request) => request, - Err(error) => panic!("canonical input fixture must validate: {error}"), - } -} - fn session_input_request() -> AdoptionRequest { let mut value: serde_json::Value = match serde_json::from_slice(INPUT_GOLDEN) { Ok(value) => value, @@ -1130,6 +1261,9 @@ fn changed_correlations( ) -> Vec<(&'static str, ExecutionCorrelation)> { let mut changed = Vec::new(); let mut candidate = correlation.clone(); + candidate.request_id = request_id(11); + changed.push(("request_id", candidate)); + let mut candidate = correlation.clone(); candidate.request_digest = digest_of('a'); changed.push(("request_digest", candidate)); let mut candidate = correlation.clone(); @@ -1148,6 +1282,9 @@ fn changed_correlations( candidate.attempt_id = record_id(RecordKind::Attempt, 11); changed.push(("attempt_id", candidate)); let mut candidate = correlation.clone(); + candidate.created_at += time::Duration::seconds(1); + changed.push(("created_at", candidate)); + let mut candidate = correlation.clone(); candidate.valid_until -= time::Duration::seconds(1); changed.push(("validity_window", candidate)); changed @@ -1309,6 +1446,15 @@ pub async fn assert_c_s1_contract_negotiation( capabilities: capability_names(), } ); + match fixture.port().adopt(launch_request()).await { + Ok(AdoptionDisposition::Adopted { .. }) => {} + Err(PortError::CapabilityMissing {}) => { + assert_eq!(fixture.observations().await, before); + panic!("stable_adoption was falsely advertised"); + } + other => panic!("advertised stable_adoption method failed: {other:?}"), + } + fixture.reset().await; for unsupported in ["coven.daemon.v0", "coven.daemon.v2"] { assert_eq!( @@ -1327,12 +1473,12 @@ pub async fn assert_c_s1_contract_negotiation( fixture.port().negotiate(missing).await, Err(PortError::CapabilityMissing {}) ); - let mut false_method = exact_negotiation_request(); - false_method + let mut unknown_method_capability = exact_negotiation_request(); + unknown_method_capability .required_capabilities .insert("falsely_advertised_method".to_owned()); assert_eq!( - fixture.port().negotiate(false_method).await, + fixture.port().negotiate(unknown_method_capability).await, Err(PortError::CapabilityMissing {}) ); assert_eq!(fixture.observations().await, before); @@ -1457,6 +1603,19 @@ pub async fn assert_c_s3_snapshot_attempt_binding( .unwrap_or_else(|error| panic!("snapshot must round-trip: {error}")); assert_eq!(snapshot.correlation, correlation); assert_eq!(snapshot.session_id, "session-1"); + let valid_reconciliation = ReconciliationRequest { + correlation: correlation.clone(), + ambiguity_digest: digest_of('d'), + reason_code: "return_original".to_owned(), + }; + let valid_disposition = fixture + .port() + .reconcile(valid_reconciliation.clone()) + .await + .unwrap_or_else(|error| panic!("valid snapshot correlation must reconcile: {error}")); + valid_disposition + .validate_for(&valid_reconciliation) + .unwrap_or_else(|error| panic!("valid snapshot correlation must echo exactly: {error}")); let changed_correlations = changed_correlations(&correlation); let changed_count = u64::try_from(changed_correlations.len()) @@ -1475,8 +1634,11 @@ pub async fn assert_c_s3_snapshot_attempt_binding( } let observations = fixture.observations().await; assert_eq!(observations.adoption_calls, 1); - assert_eq!(observations.reconciliation_calls, changed_count); - assert!(observations.durable_reconciliation.is_none()); + assert_eq!(observations.reconciliation_calls, changed_count + 1); + assert_eq!( + observations.durable_reconciliation, + disposition_observation(&valid_disposition) + ); ConformanceOutcome::Verified } @@ -1498,17 +1660,14 @@ pub async fn assert_c_s4_stable_adoption( request .validate_digest() .unwrap_or_else(|error| panic!("authority must recompute the canonical request: {error}")); - fixture - .select_fault(CovenFaultPoint::AdoptionAfterCommit) - .await - .unwrap_or_else(|error| panic!("adoption fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::AdoptionAfterCommit).await; assert_eq!( fixture.port().adopt(request.clone()).await, Err(PortError::Unavailable) ); assert_eq!(fixture.observations().await.adoption_calls, 1); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; let disposition = AdoptionDisposition::Adopted { session_id: "session-1".to_owned(), }; @@ -1516,11 +1675,11 @@ pub async fn assert_c_s4_stable_adoption( fixture.port().adopt(request.clone()).await, Ok(disposition.clone()) ); - assert_eq!(fixture.observations().await.adoption_calls, 1); + assert_eq!(fixture.observations().await.adoption_calls, 0); assert_eq!(fixture.port().adopt(request.clone()).await, Ok(disposition)); - assert_eq!(fixture.observations().await.adoption_calls, 1); + assert_eq!(fixture.observations().await.adoption_calls, 0); - for typed in [request, input_request()] { + for typed in [request, session_input_request()] { for (field, forged) in stale_digest_mutations(&typed) { let before = fixture.observations().await; assert_eq!( @@ -1632,10 +1791,7 @@ pub async fn assert_c_s6_ambiguity_fence( fixture.reset().await; let correlation = mark_ambiguous(fixture).await; let request = reconciliation_request(correlation, false); - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; let expected_error = if point == CovenFaultPoint::ReconcileStall { PortError::Stalled } else { @@ -1654,15 +1810,10 @@ pub async fn assert_c_s6_ambiguity_fence( } ); fixture.restart().await; - assert_eq!( - fixture.port().reconcile(request.clone()).await, - Err(expected_error) - ); let blocked = fixture.observations().await; - assert_eq!(blocked.adoption_calls, 1); - assert_eq!(blocked.reconciliation_calls, 2); + assert_eq!(blocked.adoption_calls, 0); + assert_eq!(blocked.reconciliation_calls, 0); assert!(blocked.durable_reconciliation.is_none()); - fixture.clear_fault().await; let recovered = fixture .port() .reconcile(request) @@ -1673,17 +1824,14 @@ pub async fn assert_c_s6_ambiguity_fence( ReconciliationDisposition::Returned { .. } )); let recovered_observations = fixture.observations().await; - assert_eq!(recovered_observations.adoption_calls, 1); - assert_eq!(recovered_observations.reconciliation_calls, 3); + assert_eq!(recovered_observations.adoption_calls, 0); + assert_eq!(recovered_observations.reconciliation_calls, 1); } fixture.reset().await; let correlation = mark_ambiguous(fixture).await; let request = reconciliation_request(correlation, true); - fixture - .select_fault(CovenFaultPoint::ReconcileAfterDisposition) - .await - .unwrap_or_else(|error| panic!("after-disposition fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::ReconcileAfterDisposition).await; assert_eq!( fixture.port().reconcile(request.clone()).await, Err(PortError::Unavailable) @@ -1700,7 +1848,7 @@ pub async fn assert_c_s6_ambiguity_fence( DurableDispositionKind::Fenced { .. } )); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; let replay = fixture .port() .reconcile(request) @@ -1711,33 +1859,27 @@ pub async fn assert_c_s6_ambiguity_fence( Some(committed_observation) ); let replayed = fixture.observations().await; - assert_eq!(replayed.adoption_calls, 1); - assert_eq!(replayed.reconciliation_calls, 2); + assert_eq!(replayed.adoption_calls, 0); + assert_eq!(replayed.reconciliation_calls, 1); ConformanceOutcome::Verified } async fn mark_ambiguous(fixture: &mut dyn CovenConformanceFixture) -> ExecutionCorrelation { let adoption = launch_request(); let correlation = adoption.correlation(); - fixture - .select_fault(CovenFaultPoint::AdoptionAfterCommit) - .await - .unwrap_or_else(|error| panic!("after-adoption fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::AdoptionAfterCommit).await; assert_eq!( fixture.port().adopt(adoption).await, Err(PortError::Unavailable) ); - fixture.clear_fault().await; - fixture - .select_fault(CovenFaultPoint::LookupAfterRead) - .await - .unwrap_or_else(|error| panic!("after-lookup fault must be controllable: {error}")); + require_clear_fault(fixture).await; + require_fault(fixture, CovenFaultPoint::LookupAfterRead).await; let local_disposition = match fixture.port().lookup(&correlation.request_id).await { Err(PortError::Unavailable) => AdoptionDisposition::Unknown, other => panic!("lost lookup response must remain locally unknown: {other:?}"), }; assert_eq!(local_disposition, AdoptionDisposition::Unknown); - fixture.clear_fault().await; + require_clear_fault(fixture).await; assert_eq!(fixture.observations().await.adoption_calls, 1); correlation } @@ -1833,6 +1975,24 @@ async fn assert_reconciliation_terminal(fixture: &mut dyn CovenConformanceFixtur disposition_observation(&disposition), Some(first_observation.clone()) ); + let adoption_calls_before_eligibility = fixture.observations().await.adoption_calls; + let eligibility = fixture + .redispatch_eligibility(&correlation) + .await + .unwrap_or_else(|error| panic!("redispatch eligibility must be observable: {error}")); + assert_eq!( + eligibility, + if fenced { + RedispatchEligibility::EligibleAfterFence + } else { + RedispatchEligibility::Blocked + } + ); + assert_eq!( + fixture.observations().await.adoption_calls, + adoption_calls_before_eligibility, + "eligibility observation must not dispatch" + ); fixture.restart().await; let replay = fixture .port() @@ -1869,8 +2029,8 @@ async fn assert_reconciliation_terminal(fixture: &mut dyn CovenConformanceFixtur "ambiguity_digest" ); let observations = fixture.observations().await; - assert_eq!(observations.adoption_calls, 1); - assert_eq!(observations.reconciliation_calls, 3 + changed_count); + assert_eq!(observations.adoption_calls, 0); + assert_eq!(observations.reconciliation_calls, 2 + changed_count); assert_eq!(observations.durable_reconciliation, Some(first_observation)); } @@ -1902,16 +2062,13 @@ pub async fn assert_c_s7_ordered_cursor( session_id: "session-1".to_owned(), after_sequence: 0, }; - fixture - .select_fault(CovenFaultPoint::CursorBeforePage) - .await - .unwrap_or_else(|error| panic!("before-page fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::CursorBeforePage).await; assert_eq!( fixture.port().events(initial.clone()).await, Err(PortError::Unavailable) ); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; let first = fixture .port() .events(initial.clone()) @@ -1981,16 +2138,13 @@ pub async fn assert_c_s7_ordered_cursor( fixture.reset().await; assert!(fixture.port().adopt(launch_request()).await.is_ok()); - fixture - .select_fault(CovenFaultPoint::CursorAfterPage) - .await - .unwrap_or_else(|error| panic!("after-page fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::CursorAfterPage).await; assert_eq!( fixture.port().events(initial.clone()).await, Err(PortError::Unavailable) ); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; assert_eq!( fixture .port() @@ -2011,7 +2165,7 @@ pub async fn assert_c_s8_terminal_authority( if let Some(outcome) = expected_unsupported( fixture, CovenConformanceCase::C_S8, - UnsupportedCall::Inspect, + UnsupportedCall::Terminate, ) .await { @@ -2052,10 +2206,7 @@ pub async fn assert_c_s8_terminal_authority( unproven.terminal_state = Some("disconnected".to_owned()); assert!(unproven.validate().is_err()); - fixture - .select_fault(CovenFaultPoint::TerminalBeforePersistence) - .await - .unwrap_or_else(|error| panic!("terminal persistence fault must be controllable: {error}")); + require_fault(fixture, CovenFaultPoint::TerminalBeforePersistence).await; let mut persistence = MemoryTerminationPersistence::default(); assert!(matches!( persist_then_terminate(&mut persistence, fixture.port(), requested.clone(),).await, @@ -2073,7 +2224,7 @@ pub async fn assert_c_s8_terminal_authority( still_raw.terminal_state.as_deref(), Some("authoritatively_terminated") ); - fixture.clear_fault().await; + require_clear_fault(fixture).await; let acknowledged = persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) .await .unwrap_or_else(|error| panic!("durable terminal acknowledgement must succeed: {error}")); @@ -2117,7 +2268,7 @@ pub async fn assert_c_s9_cancellation_acknowledgement( assert!(fixture.port().adopt(launch.clone()).await.is_ok()); let mut snapshot_states = Vec::new(); - for _ in 0..6 { + for _ in 0..RAW_LEDGER_STATES.len() { snapshot_states.push( fixture .port() @@ -2128,16 +2279,18 @@ pub async fn assert_c_s9_cancellation_acknowledgement( .unwrap_or_else(|| panic!("scripted snapshot must name its raw state")), ); } - assert_eq!(snapshot_states.last().map(String::as_str), Some("killed")); + assert_eq!(snapshot_states.last().map(String::as_str), Some("orphaned")); fixture.restart().await; - snapshot_states.push( + assert_eq!( fixture .port() .inspect("session-1") .await .unwrap_or_else(|error| panic!("restart snapshot must be readable: {error}")) .terminal_state - .unwrap_or_else(|| panic!("restart snapshot must remain raw")), + .as_deref(), + Some("created"), + "volatile raw-status cursor must restart" ); assert_eq!( snapshot_states, @@ -2180,12 +2333,7 @@ pub async fn assert_c_s9_cancellation_acknowledgement( fixture.reset().await; assert!(fixture.port().adopt(launch.clone()).await.is_ok()); let unresolved_requested = termination_requested_binding(&launch, "force_unresolved"); - fixture - .select_fault(CovenFaultPoint::CancellationBeforeAcknowledgement) - .await - .unwrap_or_else(|error| { - panic!("before-acknowledgement fault must be controllable: {error}") - }); + require_fault(fixture, CovenFaultPoint::CancellationBeforeAcknowledgement).await; let mut unresolved_persistence = MemoryTerminationPersistence::default(); assert!(matches!( persist_then_terminate( @@ -2197,7 +2345,7 @@ pub async fn assert_c_s9_cancellation_acknowledgement( Err(TerminationDispatchError::Port(PortError::Unavailable)) )); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; let unresolved = persist_then_terminate( &mut unresolved_persistence, fixture.port(), @@ -2233,12 +2381,7 @@ pub async fn assert_c_s9_cancellation_acknowledgement( fixture.reset().await; assert!(fixture.port().adopt(launch.clone()).await.is_ok()); let acknowledged_requested = termination_requested_binding(&launch, "operator_request"); - fixture - .select_fault(CovenFaultPoint::CancellationAfterAcknowledgement) - .await - .unwrap_or_else(|error| { - panic!("after-acknowledgement fault must be controllable: {error}") - }); + require_fault(fixture, CovenFaultPoint::CancellationAfterAcknowledgement).await; let mut acknowledged_persistence = MemoryTerminationPersistence::default(); assert!(matches!( persist_then_terminate( @@ -2250,7 +2393,19 @@ pub async fn assert_c_s9_cancellation_acknowledgement( Err(TerminationDispatchError::Port(PortError::Unavailable)) )); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; + let mut conflicting_requested = acknowledged_requested.clone(); + conflicting_requested.termination_reason_code = Some("policy_override".to_owned()); + let mut conflicting_persistence = MemoryTerminationPersistence::default(); + assert!(matches!( + persist_then_terminate( + &mut conflicting_persistence, + fixture.port(), + conflicting_requested, + ) + .await, + Err(TerminationDispatchError::Port(PortError::IntentConflict)) + )); let acknowledged = persist_then_terminate( &mut acknowledged_persistence, fixture.port(), @@ -2379,11 +2534,7 @@ pub async fn assert_c_s10_result_artifact_binding( assert_complete_result(&actual, &expected) .unwrap_or_else(|field| panic!("complete result mismatch: {field}")); - let mut request_id_mismatch = expected.correlation.clone(); - request_id_mismatch.request_id = request_id(11); - let mut correlations = vec![("request_id", request_id_mismatch)]; - correlations.extend(changed_correlations(&expected.correlation)); - for (field, correlation) in correlations { + for (field, correlation) in changed_correlations(&expected.correlation) { let mut bundle_changed = expected.clone(); bundle_changed.correlation = correlation.clone(); assert_complete_result_rejected(&bundle_changed, &expected, field); @@ -2539,80 +2690,137 @@ fn mutate_artifact_expiry(bundle: &mut ResultBundle) { bundle.artifacts[0].content.expires_at -= time::Duration::seconds(1); } -/// Verifies durable-before/after semantics for every declared fixture fault. -pub async fn assert_c_s11_restart_persistence( - fixture: &mut dyn CovenConformanceFixture, -) -> ConformanceOutcome { - if let Some(outcome) = - expected_unsupported(fixture, CovenConformanceCase::C_S11, UnsupportedCall::Adopt).await - { - return outcome; - } - let every_fault = [ +#[derive(Debug, Clone, Copy)] +enum FaultScenario { + Adoption, + Lookup, + Cursor, + Termination, + Result, + Reconciliation, +} + +const MANDATORY_FAULTS: &[(CovenFaultPoint, FaultScenario)] = &[ + ( CovenFaultPoint::AdoptionBeforeCommit, + FaultScenario::Adoption, + ), + ( CovenFaultPoint::AdoptionAfterCommit, - CovenFaultPoint::InputBeforeCommit, - CovenFaultPoint::InputAfterCommit, - CovenFaultPoint::LookupBeforeRead, - CovenFaultPoint::LookupAfterRead, - CovenFaultPoint::CursorBeforePage, - CovenFaultPoint::CursorAfterPage, + FaultScenario::Adoption, + ), + (CovenFaultPoint::InputBeforeCommit, FaultScenario::Adoption), + (CovenFaultPoint::InputAfterCommit, FaultScenario::Adoption), + (CovenFaultPoint::LookupBeforeRead, FaultScenario::Lookup), + (CovenFaultPoint::LookupAfterRead, FaultScenario::Lookup), + (CovenFaultPoint::CursorBeforePage, FaultScenario::Cursor), + (CovenFaultPoint::CursorAfterPage, FaultScenario::Cursor), + ( CovenFaultPoint::CancellationBeforeAcknowledgement, + FaultScenario::Termination, + ), + ( CovenFaultPoint::CancellationAfterAcknowledgement, + FaultScenario::Termination, + ), + ( CovenFaultPoint::TerminalBeforePersistence, + FaultScenario::Termination, + ), + ( CovenFaultPoint::ResultBeforePersistence, + FaultScenario::Result, + ), + ( CovenFaultPoint::ArtifactBeforePersistence, + FaultScenario::Result, + ), + ( CovenFaultPoint::ReconcileBeforeDisposition, + FaultScenario::Reconciliation, + ), + ( CovenFaultPoint::ReconcileAfterDisposition, + FaultScenario::Reconciliation, + ), + ( CovenFaultPoint::ReconcileStall, - ]; - for point in every_fault { - assert!(fixture.supports(point), "{point:?}"); - } + FaultScenario::Reconciliation, + ), +]; - for point in [ - CovenFaultPoint::AdoptionBeforeCommit, - CovenFaultPoint::AdoptionAfterCommit, - CovenFaultPoint::InputBeforeCommit, - CovenFaultPoint::InputAfterCommit, - ] { - assert_adoption_fault_recovery(fixture, point).await; - } - for point in [ - CovenFaultPoint::LookupBeforeRead, - CovenFaultPoint::LookupAfterRead, - ] { - assert_lookup_fault_recovery(fixture, point).await; - } - for point in [ - CovenFaultPoint::CursorBeforePage, - CovenFaultPoint::CursorAfterPage, - ] { - assert_cursor_fault_recovery(fixture, point).await; - } - for point in [ - CovenFaultPoint::CancellationBeforeAcknowledgement, - CovenFaultPoint::CancellationAfterAcknowledgement, - CovenFaultPoint::TerminalBeforePersistence, - ] { - assert_termination_fault_recovery(fixture, point).await; - } - for point in [ - CovenFaultPoint::ResultBeforePersistence, - CovenFaultPoint::ArtifactBeforePersistence, - ] { - assert_result_fault_recovery(fixture, point).await; +/// Verifies durable-before/after semantics for every declared fixture fault. +pub async fn assert_c_s11_restart_persistence( + fixture: &mut dyn CovenConformanceFixture, +) -> ConformanceOutcome { + if let Some(outcome) = expected_unsupported( + fixture, + CovenConformanceCase::C_S11, + UnsupportedCall::Negotiate, + ) + .await + { + return outcome; } - for point in [ - CovenFaultPoint::ReconcileBeforeDisposition, - CovenFaultPoint::ReconcileAfterDisposition, - CovenFaultPoint::ReconcileStall, - ] { - assert_reconciliation_fault_recovery(fixture, point).await; + assert_restart_resets_runtime(fixture).await; + for &(point, scenario) in MANDATORY_FAULTS { + match scenario { + FaultScenario::Adoption => assert_adoption_fault_recovery(fixture, point).await, + FaultScenario::Lookup => assert_lookup_fault_recovery(fixture, point).await, + FaultScenario::Cursor => assert_cursor_fault_recovery(fixture, point).await, + FaultScenario::Termination => assert_termination_fault_recovery(fixture, point).await, + FaultScenario::Result => assert_result_fault_recovery(fixture, point).await, + FaultScenario::Reconciliation => { + assert_reconciliation_fault_recovery(fixture, point).await; + } + } } ConformanceOutcome::Verified } +async fn assert_restart_resets_runtime(fixture: &mut dyn CovenConformanceFixture) { + fixture.reset().await; + let request = launch_request(); + let request_id = request.correlation().request_id; + let adopted = fixture + .port() + .adopt(request) + .await + .unwrap_or_else(|error| panic!("restart setup must adopt: {error}")); + assert_eq!( + fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("restart setup must inspect: {error}")) + .terminal_state + .as_deref(), + Some("created") + ); + require_fault(fixture, CovenFaultPoint::LookupBeforeRead).await; + fixture.restart().await; + assert_eq!( + fixture.observations().await, + CovenConformanceObservations::default() + ); + assert_eq!( + fixture.port().lookup(&request_id).await, + Ok(adopted), + "durable adoption must survive while the selected fault is cleared" + ); + assert_eq!( + fixture + .port() + .inspect("session-1") + .await + .unwrap_or_else(|error| panic!("durable session must survive restart: {error}")) + .terminal_state + .as_deref(), + Some("created"), + "volatile inspection cursor must restart" + ); +} + async fn assert_adoption_fault_recovery( fixture: &mut dyn CovenConformanceFixture, point: CovenFaultPoint, @@ -2630,22 +2838,39 @@ async fn assert_adoption_fault_recovery( } else { launch_request() }; - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; assert_eq!( fixture.port().adopt(request.clone()).await, Err(PortError::Unavailable) ); + let calls_after_fault = fixture.observations().await.adoption_calls; + assert_eq!(calls_after_fault, 1 + u64::from(input_fault), "{point:?}"); fixture.restart().await; - fixture.clear_fault().await; + let durable_lookup = fixture + .port() + .lookup(&request.correlation().request_id) + .await + .unwrap_or_else(|error| panic!("{point:?} lookup observation failed: {error}")); + let after_commit = matches!( + point, + CovenFaultPoint::AdoptionAfterCommit | CovenFaultPoint::InputAfterCommit + ); + if after_commit { + assert!(matches!( + durable_lookup, + AdoptionDisposition::Adopted { .. } + )); + } else { + assert_eq!(durable_lookup, AdoptionDisposition::Unknown); + } + require_clear_fault(fixture).await; let recovered = fixture .port() .adopt(request.clone()) .await .unwrap_or_else(|error| panic!("{point:?} must recover after restart: {error}")); let calls_after_recovery = fixture.observations().await.adoption_calls; + assert_eq!(calls_after_recovery, u64::from(!after_commit), "{point:?}"); fixture.restart().await; assert_eq!( fixture @@ -2655,11 +2880,7 @@ async fn assert_adoption_fault_recovery( .unwrap_or_else(|error| panic!("{point:?} must replay: {error}")), recovered ); - assert_eq!( - fixture.observations().await.adoption_calls, - calls_after_recovery, - "{point:?}" - ); + assert_eq!(fixture.observations().await.adoption_calls, 0, "{point:?}"); } async fn assert_lookup_fault_recovery( @@ -2674,16 +2895,13 @@ async fn assert_lookup_fault_recovery( .adopt(launch) .await .unwrap_or_else(|error| panic!("lookup setup must adopt: {error}")); - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; assert_eq!( fixture.port().lookup(&request_id).await, Err(PortError::Unavailable) ); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; assert_eq!( fixture .port() @@ -2704,16 +2922,39 @@ async fn assert_cursor_fault_recovery( session_id: "session-1".to_owned(), after_sequence: 0, }; - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; assert_eq!( fixture.port().events(cursor.clone()).await, Err(PortError::Unavailable) ); fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; + let regression = EventCursor { + session_id: "session-1".to_owned(), + after_sequence: 1, + }; + if point == CovenFaultPoint::CursorAfterPage { + assert_eq!( + fixture.port().events(regression).await, + Err(PortError::IntentConflict) + ); + } else { + let probe = fixture + .port() + .events(regression) + .await + .unwrap_or_else(|error| panic!("before-page fault persisted a cursor: {error}")); + assert_eq!( + probe + .events + .iter() + .map(|event| event.sequence) + .collect::>(), + vec![2, 3, 4] + ); + fixture.reset().await; + assert!(fixture.port().adopt(launch_request()).await.is_ok()); + } let recovered = fixture .port() .events(cursor.clone()) @@ -2739,19 +2980,33 @@ async fn assert_termination_fault_recovery( assert!(fixture.port().adopt(launch.clone()).await.is_ok()); let requested = termination_requested_binding(&launch, "operator_request"); let mut persistence = MemoryTerminationPersistence::default(); - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; assert!(matches!( persist_then_terminate(&mut persistence, fixture.port(), requested.clone(),).await, Err(TerminationDispatchError::Port(PortError::Unavailable)) )); fixture.restart().await; - fixture.clear_fault().await; - let recovered = persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) - .await - .unwrap_or_else(|error| panic!("{point:?} termination must recover: {error}")); + let recovered = if point == CovenFaultPoint::CancellationBeforeAcknowledgement { + require_fault(fixture, CovenFaultPoint::CancellationBeforeAcknowledgement).await; + assert!(matches!( + persist_then_terminate(&mut persistence, fixture.port(), requested.clone()).await, + Err(TerminationDispatchError::Port(PortError::Unavailable)) + )); + require_clear_fault(fixture).await; + persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) + .await + .unwrap_or_else(|error| panic!("{point:?} termination must recover: {error}")) + } else { + require_fault(fixture, CovenFaultPoint::CancellationBeforeAcknowledgement).await; + let disposition = + persist_then_terminate(&mut persistence, fixture.port(), requested.clone()) + .await + .unwrap_or_else(|error| { + panic!("{point:?} lost its durable acknowledgement: {error}") + }); + require_clear_fault(fixture).await; + disposition + }; fixture.restart().await; assert_eq!( persist_then_terminate(&mut persistence, fixture.port(), requested) @@ -2767,21 +3022,33 @@ async fn assert_result_fault_recovery( ) { fixture.reset().await; assert!(fixture.port().adopt(launch_request()).await.is_ok()); - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; assert_eq!( fixture.port().result("session-1").await, Err(PortError::Unavailable) ); fixture.restart().await; - fixture.clear_fault().await; - let recovered = fixture - .port() - .result("session-1") - .await - .unwrap_or_else(|error| panic!("{point:?} result must recover: {error}")); + require_fault(fixture, CovenFaultPoint::ResultBeforePersistence).await; + let recovered = if point == CovenFaultPoint::ResultBeforePersistence { + assert_eq!( + fixture.port().result("session-1").await, + Err(PortError::Unavailable) + ); + require_clear_fault(fixture).await; + fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("{point:?} result must recover: {error}")) + } else { + let bundle = fixture + .port() + .result("session-1") + .await + .unwrap_or_else(|error| panic!("artifact fault lost primary result: {error}")); + require_clear_fault(fixture).await; + bundle + }; fixture.restart().await; assert_eq!( fixture @@ -2800,10 +3067,7 @@ async fn assert_reconciliation_fault_recovery( fixture.reset().await; let correlation = mark_ambiguous(fixture).await; let request = reconciliation_request(correlation, true); - fixture - .select_fault(point) - .await - .unwrap_or_else(|error| panic!("{point:?} must be controllable: {error}")); + require_fault(fixture, point).await; let expected = if point == CovenFaultPoint::ReconcileStall { PortError::Stalled } else { @@ -2820,7 +3084,7 @@ async fn assert_reconciliation_fault_recovery( assert!(committed_before_restart.is_none()); } fixture.restart().await; - fixture.clear_fault().await; + require_clear_fault(fixture).await; let recovered = fixture .port() .reconcile(request.clone()) @@ -2845,7 +3109,7 @@ async fn assert_reconciliation_fault_recovery( fixture.observations().await.durable_reconciliation, Some(durable) ); - assert_eq!(fixture.observations().await.adoption_calls, 1); + assert_eq!(fixture.observations().await.adoption_calls, 0); } /// Verifies stable typed denials for every public invalid-input class. diff --git a/crates/psyche-test-support/tests/fakes.rs b/crates/psyche-test-support/tests/fakes.rs index 227ac84..f2633ee 100644 --- a/crates/psyche-test-support/tests/fakes.rs +++ b/crates/psyche-test-support/tests/fakes.rs @@ -23,10 +23,16 @@ use psyche_coven::{ use psyche_store::{Store, StoreError}; use psyche_surfaces::{DeliveryDisposition, SurfaceAcceptance, SurfacePort}; use psyche_test_support::{ - CovenConformanceCase, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, + ConformanceOutcome, CovenConformanceFixture, CovenConformanceObservations, CovenFaultPoint, CovenScriptReturn, CovenScriptStep, DurableDispositionKind, DurableDispositionObservation, - FakeBuildError, FakeCoven, FakeOperation, FakeSurface, FixtureAvailability, - FixtureControlError, StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, + FakeBuildError, FakeCoven, FakeOperation, FakeSurface, FixtureControlError, + StoreTerminationPersistence, SurfaceScriptReturn, SurfaceScriptStep, + assert_c_s1_contract_negotiation, assert_c_s2_session_lifecycle, + assert_c_s3_snapshot_attempt_binding, assert_c_s4_stable_adoption, + assert_c_s5_non_adoption_proof, assert_c_s6_ambiguity_fence, assert_c_s7_ordered_cursor, + assert_c_s8_terminal_authority, assert_c_s9_cancellation_acknowledgement, + assert_c_s10_result_artifact_binding, assert_c_s11_restart_persistence, + assert_c_s12_structured_denial, unsupported_fixture, }; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -41,6 +47,36 @@ fn at(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &Rfc3339).unwrap() } +#[tokio::test] +async fn unsupported_coven_fixture_executes_every_structured_public_denial() { + macro_rules! assert_unsupported { + ($suite:path) => {{ + let mut fixture = unsupported_fixture("CapabilityMissing"); + let before = fixture.observations().await; + assert_eq!( + $suite(&mut fixture).await, + ConformanceOutcome::ExpectedUnsupported { + code: "CapabilityMissing".to_owned(), + } + ); + assert_eq!(fixture.observations().await, before); + }}; + } + + assert_unsupported!(assert_c_s1_contract_negotiation); + assert_unsupported!(assert_c_s2_session_lifecycle); + assert_unsupported!(assert_c_s3_snapshot_attempt_binding); + assert_unsupported!(assert_c_s4_stable_adoption); + assert_unsupported!(assert_c_s5_non_adoption_proof); + assert_unsupported!(assert_c_s6_ambiguity_fence); + assert_unsupported!(assert_c_s7_ordered_cursor); + assert_unsupported!(assert_c_s8_terminal_authority); + assert_unsupported!(assert_c_s9_cancellation_acknowledgement); + assert_unsupported!(assert_c_s10_result_artifact_binding); + assert_unsupported!(assert_c_s11_restart_persistence); + assert_unsupported!(assert_c_s12_structured_denial); +} + fn digest_of(character: char) -> Sha256Digest { Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))).unwrap() } @@ -308,23 +344,22 @@ async fn reconcile_after_commit_replays_and_changed_correlation_conflicts() { )) .build() .unwrap(); - let fixture: &mut dyn CovenConformanceFixture = &mut fake; assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Err(PortError::Unavailable) )); - fixture.restart().await; + fake = fake.restart(); assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Ok(ReconciliationDisposition::Returned { .. }) )); let mut changed = request; changed.correlation.request_digest = digest_of('f'); assert!(matches!( - fixture.port().reconcile(changed).await, + fake.reconcile(changed).await, Err(PortError::IntentConflict) )); - assert_eq!(fixture.observations().await.reconciliation_calls, 3); + assert_eq!(fake.observations().await.reconciliation_calls, 3); } #[test] @@ -435,17 +470,12 @@ async fn unresolved_reconciliation_remains_retryable_until_returned_or_fenced() .reconciliation(resolution.clone()) .build() .unwrap(); - let fixture: &dyn CovenConformanceFixture = &fake; - assert_eq!( - fixture.port().reconcile(request.clone()).await.unwrap(), + fake.reconcile(request.clone()).await.unwrap(), ReconciliationDisposition::Unresolved ); - assert_eq!( - fixture.port().reconcile(request.clone()).await.unwrap(), - resolution - ); - assert_eq!(fixture.observations().await.reconciliation_calls, 2); + assert_eq!(fake.reconcile(request.clone()).await.unwrap(), resolution); + assert_eq!(fake.observations().await.reconciliation_calls, 2); } } @@ -473,24 +503,19 @@ async fn reconciliation_disconnect_before_and_stall_leave_ambiguity_retryable() .reconciliation(returned.clone()) .build() .unwrap(); - let fixture: &mut dyn CovenConformanceFixture = &mut fake; - assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Err(PortError::Unavailable) )); - fixture.restart().await; + fake = fake.restart(); assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Err(PortError::Stalled) )); - assert_eq!( - fixture.port().reconcile(request.clone()).await.unwrap(), - returned - ); - fixture.restart().await; - assert_eq!(fixture.port().reconcile(request).await.unwrap(), returned); - assert_eq!(fixture.observations().await.reconciliation_calls, 4); + assert_eq!(fake.reconcile(request.clone()).await.unwrap(), returned); + fake = fake.restart(); + assert_eq!(fake.reconcile(request).await.unwrap(), returned); + assert_eq!(fake.observations().await.reconciliation_calls, 4); } #[tokio::test] @@ -515,28 +540,23 @@ async fn fenced_reconciliation_survives_after_commit_disconnect_and_restart() { )) .build() .unwrap(); - let fixture: &mut dyn CovenConformanceFixture = &mut fake; - assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Err(PortError::Unavailable) )); - fixture.restart().await; - assert_eq!( - fixture.port().reconcile(request.clone()).await.unwrap(), - fenced - ); + fake = fake.restart(); + assert_eq!(fake.reconcile(request.clone()).await.unwrap(), fenced); let mut changed = request; changed.ambiguity_digest = digest_of('f'); assert!(matches!( - fixture.port().reconcile(changed).await, + fake.reconcile(changed).await, Err(PortError::IntentConflict) )); } #[tokio::test] -async fn conformance_observations_match_through_concrete_and_trait_object_without_mutation() { +async fn low_level_observations_are_repeatable_without_mutation() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); let adoption = AdoptionRequest::new(input).unwrap(); let correlation = adoption.correlation(); @@ -563,13 +583,10 @@ async fn conformance_observations_match_through_concrete_and_trait_object_withou fake.adopt(adoption).await.unwrap(); fake.reconcile(request.clone()).await.unwrap(); - let concrete = fake.observations().await; - let fixture: &dyn CovenConformanceFixture = &fake; - let through_trait = fixture.observations().await; - assert_eq!(concrete, through_trait); - assert_eq!(fixture.observations().await, through_trait); + let observations = fake.observations().await; + assert_eq!(fake.observations().await, observations); assert_eq!( - through_trait, + observations, CovenConformanceObservations { adoption_calls: 1, reconciliation_calls: 1, @@ -614,7 +631,7 @@ async fn conformance_observations_are_redacted_and_follow_restart_reset_semantic Err(PortError::Unavailable) )); let before_restart = fake.observations().await; - CovenConformanceFixture::restart(&mut fake).await; + fake = fake.restart(); assert_eq!(fake.observations().await, before_restart); let redacted = format!("{before_restart:?}"); @@ -622,7 +639,7 @@ async fn conformance_observations_are_redacted_and_follow_restart_reset_semantic assert!(!redacted.contains(raw_field), "{raw_field}"); } - CovenConformanceFixture::reset(&mut fake).await; + fake.reset().await; assert_eq!( fake.observations().await, CovenConformanceObservations::default() @@ -679,7 +696,7 @@ async fn observations_follow_reconciliation_commit_order_across_restart() { .disposition_id, "committed-last" ); - CovenConformanceFixture::restart(&mut fake).await; + fake = fake.restart(); assert_eq!( fake.observations() .await @@ -688,12 +705,12 @@ async fn observations_follow_reconciliation_commit_order_across_restart() { .disposition_id, "committed-last" ); - CovenConformanceFixture::reset(&mut fake).await; + fake.reset().await; assert!(fake.observations().await.durable_reconciliation.is_none()); } #[tokio::test] -async fn conformance_fault_controls_are_object_safe_and_resettable() { +async fn low_level_reconciliation_fault_controls_are_resettable() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); let correlation = AdoptionRequest::new(input).unwrap().correlation(); let request = ReconciliationRequest { @@ -705,35 +722,32 @@ async fn conformance_fault_controls_are_object_safe_and_resettable() { .reconciliation(ReconciliationDisposition::Unresolved) .build() .unwrap(); - let fixture: &mut dyn CovenConformanceFixture = &mut fake; - - fixture - .select_fault(CovenFaultPoint::ReconcileStall) + fake.select_fault(CovenFaultPoint::ReconcileStall) .await .unwrap(); assert!(matches!( - fixture.port().reconcile(request.clone()).await, + fake.reconcile(request.clone()).await, Err(PortError::Stalled) )); - assert_eq!(fixture.observations().await.reconciliation_calls, 1); + assert_eq!(fake.observations().await.reconciliation_calls, 1); - fixture.clear_fault().await; + fake.clear_fault().await.unwrap(); assert_eq!( - fixture.port().reconcile(request).await.unwrap(), + fake.reconcile(request).await.unwrap(), ReconciliationDisposition::Unresolved ); - assert_eq!(fixture.observations().await.reconciliation_calls, 2); + assert_eq!(fake.observations().await.reconciliation_calls, 2); - fixture.reset().await; - fixture.restart().await; + fake.reset().await; + fake = fake.restart(); assert_eq!( - fixture.observations().await, + fake.observations().await, CovenConformanceObservations::default() ); } #[tokio::test] -async fn conformance_fixture_truthfully_reports_cases_and_fault_support() { +async fn low_level_fake_rejects_unimplemented_faults() { let input: ExecutionRequestInput = serde_json::from_slice(LAUNCH_GOLDEN).unwrap(); let correlation = AdoptionRequest::new(input).unwrap().correlation(); let request = ReconciliationRequest { @@ -741,67 +755,18 @@ async fn conformance_fixture_truthfully_reports_cases_and_fault_support() { ambiguity_digest: digest_of('e'), reason_code: "adoption_unknown".to_owned(), }; - let mut fake = FakeCoven::builder() + let fake = FakeCoven::builder() .reconciliation(ReconciliationDisposition::Unresolved) .build() .unwrap(); - let fixture: &mut dyn CovenConformanceFixture = &mut fake; - - for case in [ - CovenConformanceCase::C_S1, - CovenConformanceCase::C_S2, - CovenConformanceCase::C_S3, - CovenConformanceCase::C_S4, - CovenConformanceCase::C_S5, - CovenConformanceCase::C_S6, - CovenConformanceCase::C_S7, - CovenConformanceCase::C_S8, - CovenConformanceCase::C_S9, - CovenConformanceCase::C_S10, - CovenConformanceCase::C_S11, - CovenConformanceCase::C_S12, - ] { - assert!(matches!( - fixture.availability(case), - FixtureAvailability::ExpectedUnsupported { .. } - )); - } - - let supported = [ - CovenFaultPoint::ReconcileBeforeDisposition, - CovenFaultPoint::ReconcileAfterDisposition, - CovenFaultPoint::ReconcileStall, - ]; - let unsupported = [ - CovenFaultPoint::AdoptionBeforeCommit, - CovenFaultPoint::AdoptionAfterCommit, - CovenFaultPoint::InputBeforeCommit, - CovenFaultPoint::InputAfterCommit, - CovenFaultPoint::LookupBeforeRead, - CovenFaultPoint::LookupAfterRead, - CovenFaultPoint::CursorBeforePage, - CovenFaultPoint::CursorAfterPage, - CovenFaultPoint::CancellationBeforeAcknowledgement, - CovenFaultPoint::CancellationAfterAcknowledgement, - CovenFaultPoint::TerminalBeforePersistence, - CovenFaultPoint::ResultBeforePersistence, - CovenFaultPoint::ArtifactBeforePersistence, - ]; - for point in supported { - assert!(fixture.supports(point), "{point:?}"); - } - for point in unsupported { - assert!(!fixture.supports(point), "{point:?}"); - } assert_eq!( - fixture - .select_fault(CovenFaultPoint::AdoptionBeforeCommit) + fake.select_fault(CovenFaultPoint::AdoptionBeforeCommit) .await, Err(FixtureControlError::UnsupportedFault) ); assert_eq!( - fixture.port().reconcile(request).await.unwrap(), + fake.reconcile(request).await.unwrap(), ReconciliationDisposition::Unresolved ); } @@ -1378,8 +1343,7 @@ async fn changed_request_with_retained_digest_fails_before_adoption() { fake.adopt(forged).await, Err(PortError::RequestDigestMismatch) )); - let fixture: &dyn CovenConformanceFixture = &fake; - assert_eq!(fixture.observations().await.adoption_calls, 0); + assert_eq!(fake.observations().await.adoption_calls, 0); } } } @@ -1485,8 +1449,7 @@ async fn input_request_digest_binds_every_artifact_field_order_and_content() { ), "{name}" ); - let fixture: &dyn CovenConformanceFixture = &fake; - assert_eq!(fixture.observations().await.adoption_calls, 0, "{name}"); + assert_eq!(fake.observations().await.adoption_calls, 0, "{name}"); } } @@ -1544,8 +1507,7 @@ async fn expired_new_adoption_fails_before_calls_but_durable_replay_survives() { expired.adopt(request.clone()).await, Err(PortError::InvalidRequest) )); - let fixture: &dyn CovenConformanceFixture = &expired; - assert_eq!(fixture.observations().await.adoption_calls, 0); + assert_eq!(expired.observations().await.adoption_calls, 0); assert!( expired .at_time(at("2026-08-05T14:04:00Z")) diff --git a/crates/psyche-test-support/tests/state_machine.rs b/crates/psyche-test-support/tests/state_machine.rs index 6f9bd86..a9c525d 100644 --- a/crates/psyche-test-support/tests/state_machine.rs +++ b/crates/psyche-test-support/tests/state_machine.rs @@ -22,7 +22,8 @@ use psyche_store::{ QuarantineResolutionCode, ResolveQuarantineOutcome, Store, StoreError, Transition, }; use psyche_test_support::{ - CovenConformanceFixture, CovenFaultPoint, DurableDispositionKind, scripted_fixture, + CovenConformanceFixture, CovenFaultPoint, DurableDispositionKind, RedispatchEligibility, + scripted_fixture, }; use serde_json::{Map, json}; use tempfile::TempDir; @@ -96,25 +97,20 @@ impl Arbitrary for FoundationOperation { } } -impl FoundationOperation { +impl OperationOutcome { fn must_preserve_logical_state(&self) -> bool { matches!( self, - Self::IdenticalReinsert { .. } - | Self::ConflictingReinsert { .. } - | Self::InvalidDirectInsertSchema { .. } - | Self::InvalidDirectInsertFieldId { .. } - | Self::ReplayBindingRevision { .. } - | Self::InvalidBindingRevision { .. } - | Self::AppendDuplicateVersion { .. } - | Self::InvalidTransitionDigest { .. } - | Self::InvalidTransitionKind { .. } - | Self::ResolveQuarantineReplay { .. } - | Self::ResolveQuarantineUnknown - | Self::ResolveQuarantineStale { .. } - | Self::ResolveQuarantineConflict { .. } - | Self::Checkpoint - | Self::Reopen + Self::AlreadyPresent + | Self::Conflict + | Self::Invalid + | Self::NoTarget + | Self::AlreadyResolved + | Self::NotFound + | Self::StaleResolution + | Self::ResolutionConflict + | Self::Checkpointed + | Self::Reopened ) } } @@ -373,7 +369,7 @@ impl FoundationModel { outcome: OperationOutcome, ) -> FoundationStep { let snapshot = self.snapshot(); - if operation.must_preserve_logical_state() { + if outcome.must_preserve_logical_state() { assert_eq!(snapshot, before, "{operation:?}"); } FoundationStep { outcome, snapshot } @@ -758,7 +754,7 @@ fn store_step( outcome: OperationOutcome, ) -> FoundationStep { let snapshot = store_snapshot(harness); - if operation.must_preserve_logical_state() { + if outcome.must_preserve_logical_state() { assert_eq!(snapshot, before, "{operation:?}"); } FoundationStep { outcome, snapshot } @@ -1102,7 +1098,7 @@ impl Arbitrary for CovenRecoveryOperation { fn arbitrary_with((): Self::Parameters) -> Self::Strategy { prop_oneof![ 4 => Just(Self::MarkAmbiguous), - 5 => (any::(), 0_u8..3) + 5 => (any::(), 0_u8..11) .prop_map(|(fenced, mutation)| Self::Reconcile { fenced, mutation }), 3 => (any::(), any::()).prop_map(|(fenced, stall)| { Self::DisconnectBeforeDisposition { fenced, stall } @@ -1165,8 +1161,18 @@ fn reconciliation_for(correlation: ExecutionCorrelation, fenced: bool) -> Reconc fn mutate_reconciliation(request: &ReconciliationRequest, mutation: u8) -> ReconciliationRequest { let mut changed = request.clone(); match mutation { - 1 => changed.correlation.project_id = "project:sha256:changed".to_owned(), - 2 => changed.ambiguity_digest = digest_of('e'), + 1 => changed.correlation.request_id = request_id(11), + 2 => changed.correlation.request_digest = digest_of('a'), + 3 => { + changed.correlation.familiar_snapshot_id = record_id(RecordKind::IdentitySnapshot, 11); + } + 4 => changed.correlation.project_id = "project:sha256:changed".to_owned(), + 5 => changed.correlation.graph_id = record_id(RecordKind::Graph, 11), + 6 => changed.correlation.node_id = record_id(RecordKind::GraphNode, 11), + 7 => changed.correlation.attempt_id = record_id(RecordKind::Attempt, 11), + 8 => changed.correlation.created_at += Duration::seconds(1), + 9 => changed.correlation.valid_until -= Duration::seconds(1), + 10 => changed.ambiguity_digest = digest_of('e'), _ => {} } changed @@ -1192,7 +1198,22 @@ async fn compare_c_s6_model_and_fixture( fixture.port().adopt(adoption.clone()).await, Err(PortError::Unavailable) ); - fixture.clear_fault().await; + fixture + .clear_fault() + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + fixture + .select_fault(CovenFaultPoint::LookupAfterRead) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + fixture.port().lookup(&correlation.request_id).await, + Err(PortError::Unavailable) + ); + fixture + .clear_fault() + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; model = CovenRecoveryModel { state: RecoveryState::Ambiguous, adoption_calls: 1, @@ -1217,7 +1238,7 @@ async fn compare_c_s6_model_and_fixture( }; let candidate = mutate_reconciliation(&candidate, mutation); let changed = if model.state == RecoveryState::Ambiguous { - mutation == 1 + (1..=9).contains(&mutation) } else { candidate != exact }; @@ -1258,6 +1279,7 @@ async fn compare_c_s6_model_and_fixture( }; prop_assert_eq!(fixture.port().reconcile(request).await, Err(expected)); fixture.restart().await; + model.adoption_calls = 0; prop_assert!( fixture .observations() @@ -1265,7 +1287,10 @@ async fn compare_c_s6_model_and_fixture( .durable_reconciliation .is_none() ); - fixture.clear_fault().await; + fixture + .clear_fault() + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; } CovenRecoveryOperation::DisconnectAfterDisposition { fenced } if model.state == RecoveryState::Ambiguous => @@ -1285,22 +1310,54 @@ async fn compare_c_s6_model_and_fixture( .durable_reconciliation .ok_or_else(|| TestCaseError::fail("after-commit disposition was lost"))?; fixture.restart().await; - fixture.clear_fault().await; + model.adoption_calls = 0; + fixture + .clear_fault() + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; let disposition = fixture .port() .reconcile(request.clone()) .await .map_err(|error| TestCaseError::fail(error.to_string()))?; - prop_assert_eq!( - committed.disposition_id.as_str(), - match &disposition { - ReconciliationDisposition::Returned { disposition_id, .. } - | ReconciliationDisposition::Fenced { disposition_id, .. } => { - disposition_id.as_str() + let expected_kind = match &disposition { + ReconciliationDisposition::Returned { + disposition_id, + session_id, + correlation, + ambiguity_digest, + recorded_at, + } => { + prop_assert_eq!(&committed.disposition_id, disposition_id); + prop_assert_eq!(&committed.correlation, correlation); + prop_assert_eq!(&committed.ambiguity_digest, ambiguity_digest); + prop_assert_eq!(committed.recorded_at, *recorded_at); + DurableDispositionKind::Returned { + session_id: session_id.clone(), } - ReconciliationDisposition::Unresolved => "", } - ); + ReconciliationDisposition::Fenced { + disposition_id, + fence_token, + correlation, + ambiguity_digest, + recorded_at, + } => { + prop_assert_eq!(&committed.disposition_id, disposition_id); + prop_assert_eq!(&committed.correlation, correlation); + prop_assert_eq!(&committed.ambiguity_digest, ambiguity_digest); + prop_assert_eq!(committed.recorded_at, *recorded_at); + DurableDispositionKind::Fenced { + fence_token: fence_token.clone(), + } + } + ReconciliationDisposition::Unresolved => { + return Err(TestCaseError::fail( + "after-commit terminal replay became unresolved", + )); + } + }; + prop_assert_eq!(committed.kind, expected_kind); model.state = if fenced { RecoveryState::Fenced } else { @@ -1309,7 +1366,10 @@ async fn compare_c_s6_model_and_fixture( model.request = Some(request); model.disposition = Some(disposition); } - CovenRecoveryOperation::Restart => fixture.restart().await, + CovenRecoveryOperation::Restart => { + fixture.restart().await; + model.adoption_calls = 0; + } CovenRecoveryOperation::AttemptRedispatch => { let decision = match model.state { RecoveryState::Fenced => RecoveryDispatchDecision::RedispatchEligible, @@ -1324,9 +1384,16 @@ async fn compare_c_s6_model_and_fixture( prop_assert_eq!(decision, RecoveryDispatchDecision::Rejected); } let before = fixture.observations().await.adoption_calls; - if decision == RecoveryDispatchDecision::RedispatchEligible { - prop_assert_eq!(model.state, RecoveryState::Fenced); - } + let actual = fixture + .redispatch_eligibility(&correlation) + .await + .map_err(|error| TestCaseError::fail(error.to_string()))?; + let expected = if decision == RecoveryDispatchDecision::RedispatchEligible { + RedispatchEligibility::EligibleAfterFence + } else { + RedispatchEligibility::Blocked + }; + prop_assert_eq!(actual, expected); prop_assert_eq!(fixture.observations().await.adoption_calls, before); } _ => {} @@ -1372,7 +1439,7 @@ async fn compare_c_s6_model_and_fixture( #[derive(Debug, Clone)] enum RequestDigestOperation { ConstructRequest { input: bool }, - ReplayRequest, + Replay, MutateRequestFieldRetainDigest { field: u8 }, Restart, } @@ -1384,7 +1451,7 @@ impl Arbitrary for RequestDigestOperation { fn arbitrary_with((): Self::Parameters) -> Self::Strategy { prop_oneof![ 4 => any::().prop_map(|input| Self::ConstructRequest { input }), - 3 => Just(Self::ReplayRequest), + 3 => Just(Self::Replay), 6 => any::().prop_map(|field| Self::MutateRequestFieldRetainDigest { field }), 2 => Just(Self::Restart), ] @@ -1440,9 +1507,10 @@ async fn compare_request_digest_model_and_fixture( model.request = Some(request); model.disposition = Some(disposition); } - RequestDigestOperation::ReplayRequest => { + RequestDigestOperation::Replay => { if let (Some(request), Some(disposition)) = (&model.request, &model.disposition) { fixture.restart().await; + model.adoption_calls = 0; prop_assert_eq!( fixture.port().adopt(request.clone()).await, Ok(disposition.clone()) @@ -1458,14 +1526,28 @@ async fn compare_request_digest_model_and_fixture( fixture.port().adopt(forged.clone()).await, Err(PortError::RequestDigestMismatch) ); - prop_assert_eq!(fixture.observations().await, before); + let after = fixture.observations().await; + prop_assert_eq!(after.adoption_calls, before.adoption_calls); + prop_assert_eq!(after.durable_reconciliation, before.durable_reconciliation); + let forged_id = forged.correlation().request_id; + if forged_id != request.correlation().request_id { + prop_assert_ne!( + fixture.port().lookup(&forged_id).await, + Ok(AdoptionDisposition::Adopted { + session_id: "session-1".to_owned(), + }) + ); + } prop_assert_eq!( fixture.port().adopt(request.clone()).await, Ok(model.disposition.clone().unwrap()) ); } } - RequestDigestOperation::Restart => fixture.restart().await, + RequestDigestOperation::Restart => { + fixture.restart().await; + model.adoption_calls = 0; + } } prop_assert_eq!( fixture.observations().await.adoption_calls, @@ -1631,6 +1713,20 @@ proptest! { } } +#[test] +fn c_s6_fixture_reports_fence_eligibility_without_redispatch() { + runtime() + .block_on(compare_c_s6_model_and_fixture(vec![ + CovenRecoveryOperation::MarkAmbiguous, + CovenRecoveryOperation::Reconcile { + fenced: true, + mutation: 0, + }, + CovenRecoveryOperation::AttemptRedispatch, + ])) + .unwrap(); +} + proptest! { #![proptest_config(ProptestConfig { failure_persistence: None, From 3f485fc3d3ae66d1129675fdef418233f4d7f174 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:34:05 -0500 Subject: [PATCH 52/66] test(g2): prove store crash and migration atomicity --- crates/psyche-store/Cargo.toml | 13 + crates/psyche-store/src/bin/crash_writer.rs | 56 +++ crates/psyche-store/src/execution_bindings.rs | 21 +- crates/psyche-store/src/lib.rs | 3 + .../src/migration_test_support.rs | 253 +++++++++++ crates/psyche-store/src/migrations.rs | 2 +- crates/psyche-store/src/records.rs | 77 ++-- crates/psyche-store/src/transitions.rs | 123 +++--- crates/psyche-store/tests/crash.rs | 405 ++++++++++++++++++ crates/psyche-store/tests/fixtures/v1.sql | 71 +++ 10 files changed, 931 insertions(+), 93 deletions(-) create mode 100644 crates/psyche-store/src/bin/crash_writer.rs create mode 100644 crates/psyche-store/src/migration_test_support.rs create mode 100644 crates/psyche-store/tests/crash.rs create mode 100644 crates/psyche-store/tests/fixtures/v1.sql diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index 544351e..bfee0ef 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -20,5 +20,18 @@ ulid = { workspace = true } proptest = { workspace = true } tempfile = { workspace = true } +[features] +test-fault-injection = [] + +[[bin]] +name = "crash_writer" +path = "src/bin/crash_writer.rs" +required-features = ["test-fault-injection"] + +[[test]] +name = "crash" +path = "tests/crash.rs" +required-features = ["test-fault-injection"] + [lints] workspace = true diff --git a/crates/psyche-store/src/bin/crash_writer.rs b/crates/psyche-store/src/bin/crash_writer.rs new file mode 100644 index 0000000..dbcd3a9 --- /dev/null +++ b/crates/psyche-store/src/bin/crash_writer.rs @@ -0,0 +1,56 @@ +//! Process-abort helper for package-local SQLite atomicity tests. + +use std::path::Path; + +use psyche_store::migration_test_support::{ + MigrationFaultPoint, StoreFaultPoint, run_migration_with_fault, run_store_with_fault, +}; + +const EXIT_BEFORE_COMMIT: &str = "exit-before-commit"; +const EXIT_AFTER_RECORD_BEFORE_TRANSITION: &str = "exit-after-record-before-transition"; +const EXIT_AFTER_BINDING_REVISION_BEFORE_COMMIT: &str = "exit-after-binding-revision-before-commit"; +const EXIT_AFTER_COMMIT_BEFORE_CHECKPOINT: &str = "exit-after-commit-before-checkpoint"; +const EXIT_DURING_MIGRATION: &str = "exit-during-migration"; + +fn main() { + let mut arguments = std::env::args_os().skip(1); + let Some(mode) = arguments.next() else { + fail("expected crash mode and database path"); + }; + let Some(database) = arguments.next() else { + fail("expected crash mode and database path"); + }; + if arguments.next().is_some() { + fail("expected crash mode and database path"); + } + let Some(mode) = mode.to_str() else { + fail("unknown crash mode"); + }; + + let database = Path::new(&database); + let result = match mode { + EXIT_BEFORE_COMMIT => run_store_with_fault(database, StoreFaultPoint::BeforeCommit), + EXIT_AFTER_RECORD_BEFORE_TRANSITION => { + run_store_with_fault(database, StoreFaultPoint::AfterRecordBeforeTransition) + } + EXIT_AFTER_BINDING_REVISION_BEFORE_COMMIT => { + run_store_with_fault(database, StoreFaultPoint::AfterBindingRevisionBeforeCommit) + } + EXIT_AFTER_COMMIT_BEFORE_CHECKPOINT => { + run_store_with_fault(database, StoreFaultPoint::AfterCommitBeforeCheckpoint) + } + EXIT_DURING_MIGRATION => run_migration_with_fault( + database, + MigrationFaultPoint::AfterMigrationSqlBeforeUserVersion, + ), + _ => fail("unknown crash mode"), + }; + if let Err(error) = result { + fail(&error.to_string()); + } +} + +fn fail(message: &str) -> ! { + eprintln!("crash_writer: {message}"); + std::process::exit(2); +} diff --git a/crates/psyche-store/src/execution_bindings.rs b/crates/psyche-store/src/execution_bindings.rs index 25495fb..265d7a2 100644 --- a/crates/psyche-store/src/execution_bindings.rs +++ b/crates/psyche-store/src/execution_bindings.rs @@ -7,7 +7,7 @@ use psyche_core::contracts::{ }; use psyche_core::digest::{Sha256Digest, canonical_bytes, digest}; use psyche_core::id::RecordId; -use rusqlite::{Connection, TransactionBehavior, params}; +use rusqlite::{Connection, Transaction, TransactionBehavior, params}; use time::format_description::well_known::Rfc3339; use crate::records::InsertStatus; @@ -43,6 +43,20 @@ pub(crate) fn insert( connection: &mut Connection, binding: &ExecutionBinding, ) -> Result { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let status = insert_in_transaction(&transaction, binding)?; + transaction.commit()?; + Ok(status) +} + +/// Package-private production primitive for appending within an immediate transaction. +/// +/// The caller owns begin/commit; validation and ledger semantics are shared with `insert`. +pub(crate) fn insert_in_transaction( + transaction: &Transaction<'_>, + binding: &ExecutionBinding, +) -> Result { + binding.validate()?; let canonical_json = canonical_bytes(binding)?; let revision_digest = digest(binding)?; let sql_revision = sql_revision(binding.revision)?; @@ -50,8 +64,7 @@ pub(crate) fn insert( .revision_created_at .format(&Rfc3339) .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; - let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; - let stored = load_stored_revisions(&transaction, &binding.attempt_id)?; + let stored = load_stored_revisions(transaction, &binding.attempt_id)?; let history = validate_revision_chain(stored, &binding.attempt_id)?; if let Some(existing) = history @@ -59,7 +72,6 @@ pub(crate) fn insert( .find(|revision| revision.binding.revision == binding.revision) { if existing.canonical_json == canonical_json { - transaction.commit()?; return Ok(InsertStatus::AlreadyPresent); } return Err(revision_conflict(binding)); @@ -92,7 +104,6 @@ pub(crate) fn insert( created_at, ], )?; - transaction.commit()?; Ok(InsertStatus::Inserted) } diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 824a282..7df4306 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -3,6 +3,9 @@ mod connection; mod error; mod execution_bindings; +#[cfg(feature = "test-fault-injection")] +#[doc(hidden)] +pub mod migration_test_support; mod migrations; mod quarantine; mod records; diff --git a/crates/psyche-store/src/migration_test_support.rs b/crates/psyche-store/src/migration_test_support.rs new file mode 100644 index 0000000..6022e8a --- /dev/null +++ b/crates/psyche-store/src/migration_test_support.rs @@ -0,0 +1,253 @@ +//! Test-only migration crash driver. + +use std::path::Path; + +use psyche_core::contracts::execution::{AdoptionState, CancellationState}; +use psyche_core::contracts::{ + CanonicalDocument, ExecutionBinding, Intent, RecordKind, SchemaKind, SchemaVersion, +}; +use psyche_core::digest::{Sha256Digest, digest}; +use psyche_core::id::{RecordId, RequestId}; +use rusqlite::TransactionBehavior; +use serde_json::Map; +use time::format_description::well_known::Rfc3339; + +use crate::{ + IngestOutcome, QuarantineResolution, QuarantineResolutionCode, Store, StoreError, Transition, + execution_bindings, records, transitions, +}; + +const BASELINE_INTENT_ID: &str = "int_01J00000000000000000000001"; +const COMMITTED_INTENT_ID: &str = "int_01J00000000000000000000002"; +const PENDING_INTENT_ID: &str = "int_01J00000000000000000000003"; +const ATTEMPT_ID: &str = "att_01J00000000000000000000004"; + +/// The migration boundary where the crash test aborts the process. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MigrationFaultPoint { + /// Migration SQL ran inside the exclusive transaction, but its version did not commit. + AfterMigrationSqlBeforeUserVersion, +} + +/// Store transaction boundary where the crash test aborts the process. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreFaultPoint { + /// A valid canonical record was inserted but its transaction did not commit. + BeforeCommit, + /// A valid canonical record was inserted before its transition in the same transaction. + AfterRecordBeforeTransition, + /// The next authenticated binding revision was inserted but did not commit. + AfterBindingRevisionBeforeCommit, + /// A complete record and transition transaction committed before WAL checkpointing. + AfterCommitBeforeCheckpoint, +} + +/// Applies migration one in the production transaction shape and aborts at `fault_point`. +pub fn run_migration_with_fault( + path: &Path, + fault_point: MigrationFaultPoint, +) -> Result<(), StoreError> { + let (database_path, _) = crate::connection::prepare(path)?; + let mut connection = crate::connection::open_read_write(&database_path)?; + crate::connection::configure(&connection)?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; + crate::migrations::apply_migration_sql(&transaction, 1)?; + + match fault_point { + MigrationFaultPoint::AfterMigrationSqlBeforeUserVersion => { + eprintln!("fault-ready:exit-during-migration:sql-applied"); + std::process::abort() + } + } +} + +/// Seeds authenticated state through public APIs, then aborts at `fault_point`. +pub fn run_store_with_fault(path: &Path, fault_point: StoreFaultPoint) -> Result<(), StoreError> { + let mut store = Store::open(path)?; + seed_authenticated_baseline(&mut store)?; + + let pending = CanonicalDocument::Intent(intent(PENDING_INTENT_ID, "pending write")?); + let committed = CanonicalDocument::Intent(intent(COMMITTED_INTENT_ID, "committed write")?); + let pending_transition = Transition::new( + SchemaKind::Intent, + record_id(RecordKind::Intent, PENDING_INTENT_ID)?, + 1, + None, + "accepted".to_owned(), + at("2026-08-08T00:00:03Z")?, + )?; + let committed_transition = Transition::new( + SchemaKind::Intent, + record_id(RecordKind::Intent, COMMITTED_INTENT_ID)?, + 1, + None, + "accepted".to_owned(), + at("2026-08-08T00:00:04Z")?, + )?; + let binding_revision = binding_revision_3()?; + binding_revision.validate()?; + + let transaction = store + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + match fault_point { + StoreFaultPoint::BeforeCommit => { + records::insert_canonical_in_transaction(&transaction, &pending)?; + transitions::append_in_transaction(&transaction, &pending_transition)?; + eprintln!("fault-ready:exit-before-commit:record+transition"); + std::process::abort() + } + StoreFaultPoint::AfterRecordBeforeTransition => { + records::insert_canonical_in_transaction(&transaction, &pending)?; + eprintln!("fault-ready:exit-after-record-before-transition:record-only"); + std::process::abort() + } + StoreFaultPoint::AfterBindingRevisionBeforeCommit => { + execution_bindings::insert_in_transaction(&transaction, &binding_revision)?; + eprintln!("fault-ready:exit-after-binding-revision-before-commit:binding-revision"); + std::process::abort() + } + StoreFaultPoint::AfterCommitBeforeCheckpoint => { + records::insert_canonical_in_transaction(&transaction, &committed)?; + transitions::append_in_transaction(&transaction, &committed_transition)?; + transaction.commit()?; + std::process::abort() + } + } +} + +/// Exercises the shared binding primitive with a deliberately invalid contract. +pub fn insert_invalid_binding_with_production_primitive(path: &Path) -> Result<(), StoreError> { + let mut store = Store::open(path)?; + let mut invalid = binding_revision_1()?; + invalid.request_valid_until = invalid.request_created_at; + let transaction = store + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let result = execution_bindings::insert_in_transaction(&transaction, &invalid); + drop(transaction); + result.map(|_| ()) +} + +fn seed_authenticated_baseline(store: &mut Store) -> Result<(), StoreError> { + let baseline = CanonicalDocument::Intent(intent(BASELINE_INTENT_ID, "baseline")?); + store.insert(&baseline)?; + + let revision_1 = binding_revision_1()?; + let revision_2 = next_binding_revision(&revision_1)?; + store.insert(&CanonicalDocument::ExecutionBinding(revision_1))?; + store.insert(&CanonicalDocument::ExecutionBinding(revision_2))?; + + let baseline_id = record_id(RecordKind::Intent, BASELINE_INTENT_ID)?; + store.append_transition(&Transition::new( + SchemaKind::Intent, + baseline_id.clone(), + 1, + None, + "accepted".to_owned(), + at("2026-08-08T00:00:01Z")?, + )?)?; + store.append_transition(&Transition::new( + SchemaKind::Intent, + baseline_id, + 2, + Some("accepted".to_owned()), + "completed".to_owned(), + at("2026-08-08T00:00:02Z")?, + )?)?; + + let IngestOutcome::Quarantined { quarantine_id } = + store.ingest(br#"{"schema_version":"psyche.intent.v2"}"#)? + else { + return Err(StoreError::DatabaseCorruption); + }; + let quarantined = store + .quarantine_record(&quarantine_id)? + .ok_or(StoreError::DatabaseCorruption)?; + store.resolve_quarantine( + &quarantine_id, + &QuarantineResolution { + code: QuarantineResolutionCode::ConfirmedInvalid, + resolved_at: quarantined.discovered_at + time::Duration::seconds(1), + }, + )?; + Ok(()) +} + +fn intent(id: &str, requested_outcome: &str) -> Result { + Ok(Intent { + schema_version: SchemaVersion::parse("psyche.intent.v1")?, + intent_id: record_id(RecordKind::Intent, id)?, + principal_id: "principal-crash-test".to_owned(), + familiar_snapshot_id: record_id( + RecordKind::IdentitySnapshot, + "ids_01J00000000000000000000005", + )?, + project_id: "project-crash-test".to_owned(), + requested_outcome: requested_outcome.to_owned(), + constraints: Map::new(), + required_evidence: vec!["review".to_owned()], + surface_event_id: None, + created_at: at("2026-08-08T00:00:00Z")?, + digest: fixture_digest('a')?, + }) +} + +fn binding_revision_1() -> Result { + Ok(ExecutionBinding { + schema_version: SchemaVersion::parse("psyche.execution_binding.v1")?, + attempt_id: record_id(RecordKind::Attempt, ATTEMPT_ID)?, + revision: 1, + previous_revision_digest: None, + revision_created_at: at("2026-08-08T00:00:00Z")?, + familiar_snapshot_id: record_id( + RecordKind::IdentitySnapshot, + "ids_01J00000000000000000000005", + )?, + project_id: "project-crash-test".to_owned(), + request_id: RequestId::parse("req_01J00000000000000000000006")?, + request_digest: fixture_digest('b')?, + request_created_at: at("2026-08-07T23:59:00Z")?, + request_valid_until: at("2026-08-08T00:05:00Z")?, + coven_contract_version: "coven.v1".to_owned(), + coven_session_id: None, + adoption_state: AdoptionState::Adopted, + event_cursor: Some("cursor:1".to_owned()), + cancellation_state: CancellationState::NotRequested, + termination_request: None, + termination_reason_code: None, + cancellation_acknowledgement: None, + cancellation_unresolved: None, + terminal_state: None, + }) +} + +fn next_binding_revision(previous: &ExecutionBinding) -> Result { + let mut next = previous.clone(); + next.revision = previous + .revision + .checked_add(1) + .ok_or(StoreError::DatabaseOperation)?; + next.previous_revision_digest = Some(digest(previous)?); + next.revision_created_at += time::Duration::nanoseconds(1); + Ok(next) +} + +fn binding_revision_3() -> Result { + let revision_1 = binding_revision_1()?; + let revision_2 = next_binding_revision(&revision_1)?; + next_binding_revision(&revision_2) +} + +fn fixture_digest(character: char) -> Result { + Sha256Digest::parse(&format!("sha256:{}", character.to_string().repeat(64))) + .map_err(StoreError::from) +} + +fn record_id(kind: RecordKind, value: &str) -> Result { + RecordId::parse(kind, value).map_err(StoreError::from) +} + +fn at(value: &str) -> Result { + time::OffsetDateTime::parse(value, &Rfc3339).map_err(|_| StoreError::DatabaseOperation) +} diff --git a/crates/psyche-store/src/migrations.rs b/crates/psyche-store/src/migrations.rs index ab60faa..8a1e096 100644 --- a/crates/psyche-store/src/migrations.rs +++ b/crates/psyche-store/src/migrations.rs @@ -5,7 +5,7 @@ use crate::StoreError; /// Latest SQLite schema version understood by this build. pub const CURRENT_DATABASE_VERSION: u32 = 1; -pub(crate) fn apply_migration_sql( +pub(super) fn apply_migration_sql( transaction: &Transaction<'_>, version: u32, ) -> Result<(), StoreError> { diff --git a/crates/psyche-store/src/records.rs b/crates/psyche-store/src/records.rs index 902ec9f..7adbdc5 100644 --- a/crates/psyche-store/src/records.rs +++ b/crates/psyche-store/src/records.rs @@ -138,36 +138,53 @@ impl Store { ) -> Result { document.validate()?; let kind = document.schema_version().kind; - let id = document + document .persistable_record_id() .ok_or(StoreError::NonPersistableKind { kind })?; if let CanonicalDocument::ExecutionBinding(binding) = document { return execution_bindings::insert(&mut self.connection, binding); } - let bytes = canonical_bytes(document)?; - let record_digest = digest(document)?; - let schema_version = document.schema_version().to_string(); - let created_at = time::OffsetDateTime::now_utc() - .format(&Rfc3339) - .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; - if let Some(stored) = stored_canonical_record(&transaction, kind, id)? { - validate_stored_canonical_record(&stored, kind, id)?; - if stored.canonical_json == bytes { - transaction.commit()?; - return Ok(InsertStatus::AlreadyPresent); - } - return Err(StoreError::RecordConflict { - kind, - record_id: id.clone(), - }); + let status = insert_canonical_in_transaction(&transaction, document)?; + transaction.commit()?; + Ok(status) + } +} + +/// Package-private production primitive for canonical insertion in an owned transaction. +/// +/// The caller owns begin/commit so compound production operations can remain atomic. +pub(crate) fn insert_canonical_in_transaction( + transaction: &rusqlite::Transaction<'_>, + document: &CanonicalDocument, +) -> Result { + document.validate()?; + let kind = document.schema_version().kind; + let id = document + .persistable_record_id() + .ok_or(StoreError::NonPersistableKind { kind })?; + let bytes = canonical_bytes(document)?; + let record_digest = digest(document)?; + let schema_version = document.schema_version().to_string(); + let created_at = time::OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; + if let Some(stored) = stored_canonical_record(transaction, kind, id)? { + validate_stored_canonical_record(&stored, kind, id)?; + if stored.canonical_json == bytes { + return Ok(InsertStatus::AlreadyPresent); } + return Err(StoreError::RecordConflict { + kind, + record_id: id.clone(), + }); + } - transaction.execute( - " + transaction.execute( + " INSERT INTO canonical_records ( kind, record_id, @@ -178,18 +195,16 @@ impl Store { ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ", - params![ - kind_key(kind), - id.as_str(), - schema_version, - record_digest.as_str(), - bytes, - created_at, - ], - )?; - transaction.commit()?; - Ok(InsertStatus::Inserted) - } + params![ + kind_key(kind), + id.as_str(), + schema_version, + record_digest.as_str(), + bytes, + created_at, + ], + )?; + Ok(InsertStatus::Inserted) } fn stored_canonical_record( diff --git a/crates/psyche-store/src/transitions.rs b/crates/psyche-store/src/transitions.rs index be42a19..64819e0 100644 --- a/crates/psyche-store/src/transitions.rs +++ b/crates/psyche-store/src/transitions.rs @@ -1,7 +1,7 @@ use psyche_core::contracts::{ContractError, SchemaKind}; use psyche_core::digest::{Sha256Digest, digest}; use psyche_core::id::RecordId; -use rusqlite::{TransactionBehavior, params}; +use rusqlite::{Transaction, TransactionBehavior, params}; use time::format_description::well_known::Rfc3339; use crate::{Store, StoreError, records}; @@ -143,64 +143,10 @@ impl Store { /// Validates and appends one immutable transition. pub fn append_transition(&mut self, transition: &Transition) -> Result<(), StoreError> { transition.validate()?; - let sql_version = i64::try_from(transition.record_version) - .map_err(|_| StoreError::Contract(invalid(transition.kind, "record_version")))?; - let created_at = transition - .created_at - .format(&Rfc3339) - .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; - let history = authenticated_history(&transaction, transition.kind, &transition.record_id)?; - if let Some(stored) = history - .iter() - .find(|stored| stored.record_version == transition.record_version) - { - if stored == transition { - transaction.commit()?; - return Ok(()); - } - return Err(transition_conflict(transition)); - } - - let valid_position = match history.last() { - None => transition.record_version == 1, - Some(previous) => { - previous - .record_version - .checked_add(1) - .is_some_and(|next| next == transition.record_version) - && transition.from_state.as_deref() == Some(previous.to_state.as_str()) - } - }; - if !valid_position { - return Err(transition_conflict(transition)); - } - - transaction.execute( - " - INSERT INTO transitions ( - kind, - record_id, - from_state, - to_state, - record_version, - transition_digest, - created_at - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ", - params![ - records::kind_key(transition.kind), - transition.record_id.as_str(), - transition.from_state.as_deref(), - &transition.to_state, - sql_version, - transition.transition_digest.as_str(), - created_at, - ], - )?; + append_in_transaction(&transaction, transition)?; transaction.commit()?; Ok(()) } @@ -220,6 +166,71 @@ impl Store { } } +/// Package-private production primitive for appending within an owned transaction. +/// +/// The caller owns begin/commit so record and transition writes can be one atomic operation. +pub(crate) fn append_in_transaction( + transaction: &Transaction<'_>, + transition: &Transition, +) -> Result<(), StoreError> { + transition.validate()?; + let sql_version = i64::try_from(transition.record_version) + .map_err(|_| StoreError::Contract(invalid(transition.kind, "record_version")))?; + let created_at = transition + .created_at + .format(&Rfc3339) + .map_err(|_| StoreError::Contract(ContractError::CanonicalizationFailed))?; + let history = authenticated_history(transaction, transition.kind, &transition.record_id)?; + if let Some(stored) = history + .iter() + .find(|stored| stored.record_version == transition.record_version) + { + if stored == transition { + return Ok(()); + } + return Err(transition_conflict(transition)); + } + + let valid_position = match history.last() { + None => transition.record_version == 1, + Some(previous) => { + previous + .record_version + .checked_add(1) + .is_some_and(|next| next == transition.record_version) + && transition.from_state.as_deref() == Some(previous.to_state.as_str()) + } + }; + if !valid_position { + return Err(transition_conflict(transition)); + } + + transaction.execute( + " + INSERT INTO transitions ( + kind, + record_id, + from_state, + to_state, + record_version, + transition_digest, + created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + params![ + records::kind_key(transition.kind), + transition.record_id.as_str(), + transition.from_state.as_deref(), + &transition.to_state, + sql_version, + transition.transition_digest.as_str(), + created_at, + ], + )?; + Ok(()) +} + pub(crate) fn validate_all(connection: &rusqlite::Connection) -> Result<(), StoreError> { let mut statement = connection.prepare( " diff --git a/crates/psyche-store/tests/crash.rs b/crates/psyche-store/tests/crash.rs new file mode 100644 index 0000000..a3094b9 --- /dev/null +++ b/crates/psyche-store/tests/crash.rs @@ -0,0 +1,405 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, missing_docs)] + +use std::path::Path; +use std::process::{Command, Output}; + +use psyche_core::contracts::{CanonicalDocument, ContractError, RecordKind, SchemaKind}; +use psyche_core::digest::digest; +use psyche_core::id::RecordId; +use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; +use rusqlite::{Connection, OptionalExtension}; + +const BASELINE_INTENT_ID: &str = "int_01J00000000000000000000001"; +const COMMITTED_INTENT_ID: &str = "int_01J00000000000000000000002"; +const PENDING_INTENT_ID: &str = "int_01J00000000000000000000003"; +const ATTEMPT_ID: &str = "att_01J00000000000000000000004"; + +#[test] +fn binding_transaction_primitive_rejects_invalid_contract_before_writing() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("private").join("psyche.sqlite3"); + + let error = + psyche_store::migration_test_support::insert_invalid_binding_with_production_primitive( + &database, + ) + .unwrap_err(); + + assert!(matches!( + error, + StoreError::Contract(ContractError::InvalidShape { + schema: SchemaKind::ExecutionBinding, + field: "request_valid_until", + }) + )); + let connection = Connection::open(&database).unwrap(); + assert_eq!(row_count(&connection, "execution_binding_revisions"), 0); +} + +const CRASH_MODES: [&str; 4] = [ + "exit-before-commit", + "exit-after-record-before-transition", + "exit-after-binding-revision-before-commit", + "exit-after-commit-before-checkpoint", +]; + +#[test] +fn killed_writer_exposes_only_committed_state_after_reopen() { + for mode in CRASH_MODES { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("private").join("psyche.sqlite3"); + + let output = run_crash_writer(mode, &database); + if mode != "exit-after-commit-before-checkpoint" { + let expected_witness = match mode { + "exit-before-commit" => "fault-ready:exit-before-commit:record+transition", + "exit-after-record-before-transition" => { + "fault-ready:exit-after-record-before-transition:record-only" + } + "exit-after-binding-revision-before-commit" => { + "fault-ready:exit-after-binding-revision-before-commit:binding-revision" + } + _ => unreachable!(), + }; + assert!( + String::from_utf8_lossy(&output.stderr) + .lines() + .any(|line| line == expected_witness), + "{mode} did not prove the write was attempted" + ); + } + assert_integrity(&database); + + let store = Store::open(&database).unwrap(); + assert_eq!(store.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + assert_authenticated_state(&store, mode); + drop(store); + Store::open(&database).unwrap(); + + let connection = Connection::open(&database).unwrap(); + let committed = mode == "exit-after-commit-before-checkpoint"; + assert_eq!( + row_count(&connection, "canonical_records"), + 1 + i64::from(committed) + ); + assert_eq!(row_count(&connection, "execution_binding_revisions"), 2); + assert_eq!( + row_count(&connection, "transitions"), + 2 + i64::from(committed) + ); + assert_eq!(row_count(&connection, "quarantine_records"), 1); + assert_eq!(row_count(&connection, "audit_events"), 1); + assert_no_identity_conflicts(&connection); + } +} + +#[test] +fn killed_inside_migration_transaction_rolls_back_before_reopen() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("private").join("psyche.sqlite3"); + + let output = run_crash_writer("exit-during-migration", &database); + assert!( + String::from_utf8_lossy(&output.stderr) + .lines() + .any(|line| line == "fault-ready:exit-during-migration:sql-applied"), + "migration helper did not reach the post-SQL pre-version fault boundary" + ); + + let connection = Connection::open(&database).unwrap(); + assert_eq!(user_version(&connection), 0); + assert!(user_tables(&connection).is_empty()); + assert_eq!(integrity_check(&connection), "ok"); + drop(connection); + + let first = Store::open(&database).unwrap(); + assert_eq!(first.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + drop(first); + let second = Store::open(&database).unwrap(); + assert_eq!(second.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + drop(second); + + let migrated = Connection::open(&database).unwrap(); + let expected_path = directory.path().join("private").join("expected.sqlite3"); + let expected = Connection::open(&expected_path).unwrap(); + expected + .execute_batch(include_str!("fixtures/v1.sql")) + .unwrap(); + drop(migrated); + drop(expected); + assert_eq!(schema_snapshot(&database), schema_snapshot(&expected_path)); + let migrated = Connection::open(&database).unwrap(); + assert_eq!(row_count(&migrated, "schema_migrations"), 1); + assert_eq!(integrity_check(&migrated), "ok"); +} + +#[test] +fn crash_writer_rejects_every_unknown_fault_point() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("private").join("psyche.sqlite3"); + let output = Command::new(env!("CARGO_BIN_EXE_crash_writer")) + .arg("unknown-fault") + .arg(&database) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("unknown crash mode")); + assert!(!database.exists()); +} + +fn run_crash_writer(mode: &str, database: &Path) -> Output { + let output = Command::new(env!("CARGO_BIN_EXE_crash_writer")) + .arg(mode) + .arg(database) + .output() + .unwrap(); + assert!( + !output.status.success(), + "{mode} unexpectedly returned success" + ); + assert!(database.exists(), "{mode} did not create a database"); + output +} + +fn assert_authenticated_state(store: &Store, mode: &str) { + let baseline_id = RecordId::parse(RecordKind::Intent, BASELINE_INTENT_ID).unwrap(); + let committed_id = RecordId::parse(RecordKind::Intent, COMMITTED_INTENT_ID).unwrap(); + let pending_id = RecordId::parse(RecordKind::Intent, PENDING_INTENT_ID).unwrap(); + let baseline = store.load(SchemaKind::Intent, &baseline_id).unwrap(); + assert!(matches!(baseline, Some(CanonicalDocument::Intent(_)))); + assert_eq!( + store.load(SchemaKind::Intent, &pending_id).unwrap(), + None, + "uncommitted valid record became visible after {mode}" + ); + assert!( + store.transitions(&pending_id).unwrap().is_empty(), + "uncommitted valid transition became visible after {mode}" + ); + assert_eq!( + store + .load(SchemaKind::Intent, &committed_id) + .unwrap() + .is_some(), + mode == "exit-after-commit-before-checkpoint" + ); + + let attempt_id = RecordId::parse(RecordKind::Attempt, ATTEMPT_ID).unwrap(); + let revisions = store.execution_binding_revisions(&attempt_id).unwrap(); + assert_eq!(revisions.len(), 2); + assert_eq!(revisions[0].revision, 1); + assert_eq!(revisions[0].previous_revision_digest, None); + assert_eq!(revisions[1].revision, 2); + assert_eq!( + revisions[1].previous_revision_digest.as_ref(), + Some(&digest(&revisions[0]).unwrap()) + ); + for revision in &revisions { + revision.validate().unwrap(); + } + + let transitions = store.transitions(&baseline_id).unwrap(); + assert_eq!(transitions.len(), 2); + assert_eq!(transitions[0].record_version, 1); + assert_eq!(transitions[0].from_state, None); + assert_eq!(transitions[1].record_version, 2); + assert_eq!( + transitions[1].from_state.as_deref(), + Some(transitions[0].to_state.as_str()) + ); + for transition in &transitions { + transition.validate().unwrap(); + } + let committed_transitions = store.transitions(&committed_id).unwrap(); + if mode == "exit-after-commit-before-checkpoint" { + assert_eq!(committed_transitions.len(), 1); + assert_eq!(committed_transitions[0].record_version, 1); + assert_eq!(committed_transitions[0].from_state, None); + committed_transitions[0].validate().unwrap(); + } else { + assert!(committed_transitions.is_empty()); + } + + let audit = store.audit_events().unwrap(); + assert_eq!(audit.len(), 1); + let quarantine_id = psyche_store::QuarantineId::parse(&audit[0].correlation_id).unwrap(); + let quarantine = store.quarantine_record(&quarantine_id).unwrap().unwrap(); + assert!(quarantine.resolved_at.is_some()); + assert!(quarantine.resolution_code.is_some()); + assert!(quarantine.resolution_digest.is_some()); +} + +fn assert_integrity(path: &Path) { + let connection = Connection::open(path).unwrap(); + assert_eq!(integrity_check(&connection), "ok"); +} + +fn integrity_check(connection: &Connection) -> String { + connection + .pragma_query_value(None, "integrity_check", |row| row.get(0)) + .unwrap() +} + +fn user_version(connection: &Connection) -> u32 { + connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap() +} + +fn row_count(connection: &Connection, table: &str) -> i64 { + connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() +} + +fn user_tables(connection: &Connection) -> Vec { + let mut statement = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .unwrap(); + statement + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>>() + .unwrap() +} + +#[derive(Debug, PartialEq, Eq)] +struct SchemaSnapshot { + objects: Vec<(String, String, String, String)>, + tables: Vec, + indexes: Vec<(String, Vec)>, +} + +#[derive(Debug, PartialEq, Eq)] +struct TableSnapshot { + name: String, + columns: Vec, + foreign_keys: Vec, + indexes: Vec, +} + +fn schema_snapshot(path: &Path) -> SchemaSnapshot { + let connection = Connection::open(path).unwrap(); + let mut objects_statement = connection + .prepare( + " + SELECT type, name, tbl_name, COALESCE(sql, '') + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + ", + ) + .unwrap(); + let objects = objects_statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .unwrap() + .collect::>>() + .unwrap(); + + let table_names = objects + .iter() + .filter(|(kind, _, _, _)| kind == "table") + .map(|(_, name, _, _)| name.clone()) + .collect::>(); + let mut index_names = objects + .iter() + .filter(|(kind, _, _, _)| kind == "index") + .map(|(_, name, _, _)| name.clone()) + .collect::>(); + let tables = table_names + .into_iter() + .map(|table| { + let columns = pragma_rows(&connection, &format!("PRAGMA table_info('{table}')")); + let foreign_keys = + pragma_rows(&connection, &format!("PRAGMA foreign_key_list('{table}')")); + let index_list = pragma_rows(&connection, &format!("PRAGMA index_list('{table}')")); + let mut statement = connection + .prepare("SELECT name FROM pragma_index_list(?1) ORDER BY name") + .unwrap(); + let names = statement + .query_map([&table], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + index_names.extend(names); + TableSnapshot { + name: table, + columns, + foreign_keys, + indexes: index_list, + } + }) + .collect(); + + let indexes = index_names + .into_iter() + .map(|index| { + let columns = pragma_rows(&connection, &format!("PRAGMA index_xinfo('{index}')")); + (index, columns) + }) + .collect(); + + SchemaSnapshot { + objects, + tables, + indexes, + } +} + +fn pragma_rows(connection: &Connection, pragma: &str) -> Vec { + use rusqlite::types::ValueRef; + + let mut statement = connection.prepare(pragma).unwrap(); + let column_count = statement.column_count(); + statement + .query_map([], |row| { + let mut values = Vec::with_capacity(column_count); + for index in 0..column_count { + let value = match row.get_ref(index)? { + ValueRef::Null => "null".to_owned(), + ValueRef::Integer(value) => value.to_string(), + ValueRef::Real(value) => value.to_string(), + ValueRef::Text(value) => String::from_utf8_lossy(value).into_owned(), + ValueRef::Blob(value) => format!("blob:{}", value.len()), + }; + values.push(value); + } + Ok(values.join("\u{1f}")) + }) + .unwrap() + .collect::>>() + .unwrap() +} + +fn assert_no_identity_conflicts(connection: &Connection) { + let record_conflict: Option = connection + .query_row( + "SELECT 1 FROM canonical_records GROUP BY kind, record_id HAVING COUNT(*) > 1 LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .unwrap(); + let binding_conflict: Option = connection + .query_row( + "SELECT 1 FROM execution_binding_revisions GROUP BY attempt_id, revision HAVING COUNT(*) > 1 LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert_eq!(record_conflict, None); + assert_eq!(binding_conflict, None); +} diff --git a/crates/psyche-store/tests/fixtures/v1.sql b/crates/psyche-store/tests/fixtures/v1.sql new file mode 100644 index 0000000..4ad7b42 --- /dev/null +++ b/crates/psyche-store/tests/fixtures/v1.sql @@ -0,0 +1,71 @@ +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +) STRICT; +CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id), + UNIQUE (kind, record_id, digest) +) STRICT; +CREATE TABLE execution_binding_revisions ( + attempt_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + previous_revision_digest TEXT, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (attempt_id, revision), + UNIQUE (attempt_id, digest), + CHECK ( + (revision = 1 AND previous_revision_digest IS NULL) + OR + (revision > 1 AND previous_revision_digest IS NOT NULL) + ), + FOREIGN KEY (attempt_id, previous_revision_digest) + REFERENCES execution_binding_revisions(attempt_id, digest) +) STRICT; +CREATE TABLE transitions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + from_state TEXT, + to_state TEXT NOT NULL, + record_version INTEGER NOT NULL, + transition_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (kind, record_id, record_version) +) STRICT; +CREATE TABLE quarantine_records ( + quarantine_id TEXT PRIMARY KEY, + schema_version TEXT, + payload_digest TEXT NOT NULL, + original_payload_len INTEGER NOT NULL CHECK (original_payload_len >= 0), + retained_payload_digest TEXT NOT NULL, + integrity_digest TEXT NOT NULL, + bounded_payload BLOB NOT NULL, + reason TEXT NOT NULL, + discovered_at TEXT NOT NULL, + resolved_at TEXT, + resolution_code TEXT, + resolution_digest TEXT, + CHECK ( + (resolved_at IS NULL AND resolution_code IS NULL AND resolution_digest IS NULL) + OR + (resolved_at IS NOT NULL AND resolution_code IS NOT NULL AND resolution_digest IS NOT NULL) + ) +) STRICT; +CREATE TABLE audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_code TEXT NOT NULL, + correlation_id TEXT NOT NULL, + public_details_json BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; +INSERT INTO schema_migrations (version, applied_at) VALUES (1, 'fixture'); +PRAGMA user_version = 1; From 86d2a0acc6401c6e0b1bc45467803f601bfc19f5 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:00:11 -0500 Subject: [PATCH 53/66] feat(runtime): bind lifecycle to durable store --- Cargo.lock | 4 + crates/psyche-cli/Cargo.toml | 3 +- crates/psyche-cli/src/daemon.rs | 10 +- crates/psyche-cli/src/doctor.rs | 82 +++++++-- crates/psyche-cli/tests/cli.rs | 59 +++++- crates/psyche-runtime/Cargo.toml | 9 +- crates/psyche-runtime/src/lib.rs | 225 ++++++++++++++++++----- crates/psyche-runtime/tests/lifecycle.rs | 60 ++++++ crates/psyche-store/src/connection.rs | 33 ++-- crates/psyche-store/src/lib.rs | 14 ++ crates/psyche-store/tests/migrations.rs | 51 ++++- docs/CLI.md | 16 +- 12 files changed, 477 insertions(+), 89 deletions(-) create mode 100644 crates/psyche-runtime/tests/lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index 1ae62ca..1428ece 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "predicates", "psyche-config", "psyche-runtime", + "psyche-store", "serde_json", "tempfile", "tokio", @@ -681,6 +682,9 @@ name = "psyche-runtime" version = "0.0.0" dependencies = [ "psyche-config", + "psyche-store", + "rusqlite", + "tempfile", "thiserror", "tokio", "tracing", diff --git a/crates/psyche-cli/Cargo.toml b/crates/psyche-cli/Cargo.toml index 3832884..6d8713c 100644 --- a/crates/psyche-cli/Cargo.toml +++ b/crates/psyche-cli/Cargo.toml @@ -31,8 +31,10 @@ path = "src/bin/psyched.rs" [dependencies] psyche-config = { workspace = true } psyche-runtime = { workspace = true } +psyche-store = { workspace = true } clap = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -45,7 +47,6 @@ tracing-subscriber = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } -tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/psyche-cli/src/daemon.rs b/crates/psyche-cli/src/daemon.rs index 8e4c5b8..e890876 100644 --- a/crates/psyche-cli/src/daemon.rs +++ b/crates/psyche-cli/src/daemon.rs @@ -127,10 +127,9 @@ pub async fn run(config: Config, shutdown_after_start: bool) -> ExitCode { let runtime = match Runtime::start(config).await { Ok(runtime) => runtime, - // Unreachable, and permanently so: `RuntimeError` has no variants, so - // this arm cannot be constructed and cannot be covered by a test. It is - // written out anyway because the signature is what absorbs the first - // real startup failure — see the type's docs in psyche-runtime. + // Store open and migration failures happen before `Running` is + // published. Render the stable runtime error without exposing SQLite + // internals or configuration contents. Err(e) => { // Display, not `{:?}` — see the note on `psyche`'s `main`. eprintln!("{e}"); @@ -153,7 +152,8 @@ pub async fn run(config: Config, shutdown_after_start: bool) -> ExitCode { match runtime.shutdown().await { Ok(()) => ExitCode::SUCCESS, - // Unreachable for the same reason as the arm above. + // A checkpoint failure is terminal: the runtime still publishes + // `Stopped`, and every shutdown caller receives the same failure. Err(e) => { eprintln!("{e}"); ExitCode::FAILURE diff --git a/crates/psyche-cli/src/doctor.rs b/crates/psyche-cli/src/doctor.rs index 671b447..919d775 100644 --- a/crates/psyche-cli/src/doctor.rs +++ b/crates/psyche-cli/src/doctor.rs @@ -88,7 +88,8 @@ pub struct Check { /// freeze it. pub const DOCTOR_SCHEMA: &str = "psyche.doctor.v1"; -/// Writes and removes a file inside `dir`, creating `dir` if it is absent. +/// Exclusively creates and removes a temporary file inside `dir`, creating +/// `dir` if it is absent. /// /// Returns whether `dir` already existed. `create_dir_all` alone proves nothing: /// it returns `Ok(())` for a directory that exists at any mode, so a mode-500 @@ -98,14 +99,16 @@ pub const DOCTOR_SCHEMA: &str = "psyche.doctor.v1"; /// The distinction between created and pre-existing matters just as much: on a /// typo'd path the old code silently *created* the directory and blessed it, /// hiding the exact misconfiguration `doctor` exists to surface. -fn probe(dir: &Path) -> std::io::Result { - let existed = dir.try_exists()?; - std::fs::create_dir_all(dir)?; - // A fixed name, not a random one: a probe file left behind by a killed - // `doctor` should be overwritten by the next run rather than accumulating. - let probe = dir.join(".psyche-doctor-probe"); - std::fs::write(&probe, b"")?; - std::fs::remove_file(probe)?; +fn probe(dir: &Path) -> Result { + let existed = psyche_store::prepare_data_dir(dir).map_err(|error| error.to_string())?; + // `tempfile` uses exclusive creation with randomized names and bounded + // collision retries, so this never opens or truncates a pre-existing + // entry. Closing removes that randomized path, and reports cleanup failure. + let probe = tempfile::Builder::new() + .prefix(".psyche-doctor-probe-") + .tempfile_in(dir) + .map_err(|error| error.to_string())?; + probe.close().map_err(|error| error.to_string())?; Ok(existed) } @@ -312,6 +315,12 @@ required_api_version = "coven.daemon.v1" )) } + fn prepared_data_dir(root: &Path) -> Result { + let data_dir = root.join("data"); + psyche_store::prepare_data_dir(&data_dir)?; + Ok(data_dir) + } + fn check<'a>(checks: &'a [Check], name: &str) -> Option<&'a Check> { checks.iter().find(|c| c.name == name) } @@ -353,7 +362,8 @@ required_api_version = "coven.daemon.v1" #[test] fn every_check_is_named_and_explained() { let tmp = tempfile::tempdir().unwrap(); - let config = config_for(tmp.path()).unwrap(); + let data_dir = prepared_data_dir(tmp.path()).unwrap(); + let config = config_for(&data_dir).unwrap(); let checks = run(Path::new("psyche.toml"), Ok(&config)); let names: Vec<&str> = checks.iter().map(|c| c.name).collect(); @@ -373,7 +383,8 @@ required_api_version = "coven.daemon.v1" #[test] fn an_existing_writable_data_dir_passes() { let tmp = tempfile::tempdir().unwrap(); - let config = config_for(tmp.path()).unwrap(); + let prepared = prepared_data_dir(tmp.path()).unwrap(); + let config = config_for(&prepared).unwrap(); let checks = run(Path::new("psyche.toml"), Ok(&config)); let data_dir = check(&checks, "data_dir").unwrap(); @@ -386,7 +397,7 @@ required_api_version = "coven.daemon.v1" // The probe cleans up after itself; a leftover file in an operator's // data directory is litter, and one that persisted would also make the // "did not exist" branch below unreachable on a second run. - assert!(!tmp.path().join(".psyche-doctor-probe").exists()); + assert!(std::fs::read_dir(prepared).unwrap().next().is_none()); } /// A typo'd path used to be silently created and blessed with a green line. @@ -411,6 +422,47 @@ required_api_version = "coven.daemon.v1" assert_eq!(failures(&checks), 0); } + #[test] + fn probe_does_not_touch_a_preexisting_regular_file() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = prepared_data_dir(tmp.path()).unwrap(); + let existing_probe = data_dir.join(".psyche-doctor-probe"); + std::fs::write(&existing_probe, b"operator-owned").unwrap(); + let config = config_for(&data_dir).unwrap(); + + let checks = run(Path::new("psyche.toml"), Ok(&config)); + + assert_eq!(check(&checks, "data_dir").unwrap().status, Status::Ok); + assert_eq!(std::fs::read(existing_probe).unwrap(), b"operator-owned"); + assert_eq!(std::fs::read_dir(data_dir).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn probe_does_not_follow_or_remove_a_preexisting_symlink() { + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let data_dir = prepared_data_dir(tmp.path()).unwrap(); + let target = tmp.path().join("operator-owned"); + std::fs::write(&target, b"preserve").unwrap(); + let existing_probe = data_dir.join(".psyche-doctor-probe"); + symlink(&target, &existing_probe).unwrap(); + let config = config_for(&data_dir).unwrap(); + + let checks = run(Path::new("psyche.toml"), Ok(&config)); + + assert_eq!(check(&checks, "data_dir").unwrap().status, Status::Ok); + assert!( + std::fs::symlink_metadata(&existing_probe) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(std::fs::read(target).unwrap(), b"preserve"); + assert_eq!(std::fs::read_dir(data_dir).unwrap().count(), 1); + } + /// Mode 500: readable and traversable, not writable. `create_dir_all` /// returns `Ok(())` for it, which is exactly how "writable" came to be /// printed about a directory that is not. @@ -485,7 +537,8 @@ required_api_version = "coven.daemon.v1" #[test] fn checks_that_verify_nothing_are_marked_info() { let tmp = tempfile::tempdir().unwrap(); - let config = config_for(tmp.path()).unwrap(); + let data_dir = prepared_data_dir(tmp.path()).unwrap(); + let config = config_for(&data_dir).unwrap(); let checks = run(Path::new("psyche.toml"), Ok(&config)); for name in ["coven_socket_path", "extensions"] { @@ -526,7 +579,8 @@ required_api_version = "coven.daemon.v1" #[test] fn the_text_rendering_names_every_check_and_its_status() { let tmp = tempfile::tempdir().unwrap(); - let config = config_for(tmp.path()).unwrap(); + let data_dir = prepared_data_dir(tmp.path()).unwrap(); + let config = config_for(&data_dir).unwrap(); let rendered = render_text(&run(Path::new("psyche.toml"), Ok(&config))); assert!(rendered.contains("config: ok ("), "{rendered}"); diff --git a/crates/psyche-cli/tests/cli.rs b/crates/psyche-cli/tests/cli.rs index 479545f..544b507 100644 --- a/crates/psyche-cli/tests/cli.rs +++ b/crates/psyche-cli/tests/cli.rs @@ -66,7 +66,8 @@ fn write_config(dir: &std::path::Path) -> std::io::Result { fn write_config_with(dir: &std::path::Path, extra: &str) -> std::io::Result { let data_dir = dir.join("data"); - std::fs::create_dir_all(&data_dir)?; + psyche_store::prepare_data_dir(&data_dir) + .map_err(|error| std::io::Error::other(error.to_string()))?; let path = dir.join("psyche.toml"); std::fs::write(&path, format!("{}{extra}", config_body(&data_dir)))?; Ok(path) @@ -100,6 +101,62 @@ fn doctor_succeeds_without_any_telegram_credentials() { .stdout(contains("config: ok").and(contains("data_dir: ok"))); } +#[test] +fn doctor_created_data_dir_can_immediately_start() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("fresh-data"); + let config = tmp.path().join("psyche.toml"); + std::fs::write(&config, config_body(&data_dir)).unwrap(); + + Command::cargo_bin("psyche") + .unwrap() + .args(["doctor", "--config", config.to_str().unwrap()]) + .assert() + .success() + .stdout(contains("data_dir: warn").and(contains("created"))); + + Command::cargo_bin("psyche") + .unwrap() + .args([ + "start", + "--config", + config.to_str().unwrap(), + "--shutdown-after-start", + ]) + .assert() + .success(); +} + +#[cfg(unix)] +#[test] +fn doctor_rejects_an_existing_insecure_nonempty_data_dir_without_changing_it() { + use std::os::unix::fs::PermissionsExt as _; + + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("shared-data"); + std::fs::create_dir(&data_dir).unwrap(); + std::fs::write(data_dir.join("operator-data"), b"preserve").unwrap(); + std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = tmp.path().join("psyche.toml"); + std::fs::write(&config, config_body(&data_dir)).unwrap(); + + Command::cargo_bin("psyche") + .unwrap() + .args(["doctor", "--config", config.to_str().unwrap()]) + .assert() + .code(i32::from(EXIT_CHECK_FAILED)) + .stdout(contains("data_dir: fail")); + + assert_eq!( + std::fs::metadata(&data_dir).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!( + std::fs::read(data_dir.join("operator-data")).unwrap(), + b"preserve" + ); +} + /// `status` emits no `state` at all, because it observed none. /// /// The document used to say `{"state":"stopped","observed":false}`, which is a diff --git a/crates/psyche-runtime/Cargo.toml b/crates/psyche-runtime/Cargo.toml index 75e5e5e..a550ea6 100644 --- a/crates/psyche-runtime/Cargo.toml +++ b/crates/psyche-runtime/Cargo.toml @@ -11,11 +11,10 @@ publish.workspace = true [dependencies] psyche-config = { workspace = true } +psyche-store = { workspace = true } thiserror = { workspace = true } -# Declared even though this slice awaits nothing: `start` and `shutdown` are -# `async fn` because the drain seam they bracket becomes a real await in the -# follow-on G2 plan, and widening a sync signature to async later is a breaking -# change for psyche-cli. The dependency is what makes that signature honest. +# `shutdown` observers wait asynchronously for the elected synchronous driver +# to publish its terminal outcome. tokio = { workspace = true } tracing = { workspace = true } @@ -23,6 +22,8 @@ tracing = { workspace = true } # `#[tokio::test]` needs the `macros` and `rt` features; the workspace entry # carries both. tokio = { workspace = true } +rusqlite = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/psyche-runtime/src/lib.rs b/crates/psyche-runtime/src/lib.rs index 5583fac..7c98143 100644 --- a/crates/psyche-runtime/src/lib.rs +++ b/crates/psyche-runtime/src/lib.rs @@ -4,6 +4,7 @@ use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use psyche_config::Config; +use psyche_store::{Store, StoreError}; use tokio::sync::watch; /// Graceful shutdown stops intake, then drains, then exits. `Draining` is @@ -59,20 +60,64 @@ impl fmt::Display for LifecycleState { /// Failures from driving the runtime lifecycle. /// -/// Deliberately empty. Nothing in this slice can fail: [`Runtime::start`] does -/// no I/O yet, and a losing [`Runtime::shutdown`] caller waits for the winner -/// and returns `Ok` rather than erroring. The type and the `Result` signatures -/// exist so that the first real failure — opening `data_dir`, binding the Coven -/// socket, acquiring a lease — is an added variant rather than a breaking -/// signature change. -/// -/// Do not add a variant speculatively. Add it with the code that returns it. An -/// earlier draft carried a `ShutdownInProgress` variant that nothing -/// constructed; it implied to every reader that `shutdown` refuses a second -/// caller, which is the behaviour the waiting loser deliberately replaced. #[derive(Debug, thiserror::Error)] #[non_exhaustive] -pub enum RuntimeError {} +pub enum RuntimeError { + /// Opening or migrating the durable store failed before startup completed. + #[error("runtime store startup failed")] + Store(#[source] StoreError), + /// The elected shutdown driver could not checkpoint the durable store. + #[error("runtime store checkpoint failed")] + Checkpoint(#[source] Arc), +} + +#[derive(Debug, Clone)] +enum ShutdownOutcome { + Clean, + CheckpointFailed(Arc), +} + +impl ShutdownOutcome { + fn result(&self, role: ShutdownRole) -> Result { + match self { + Self::Clean => Ok(role), + Self::CheckpointFailed(error) => Err(RuntimeError::Checkpoint(Arc::clone(error))), + } + } +} + +trait CheckpointBackend: fmt::Debug + Send { + fn checkpoint(&mut self) -> Result<(), StoreError>; +} + +impl CheckpointBackend for Store { + fn checkpoint(&mut self) -> Result<(), StoreError> { + Store::checkpoint(self) + } +} + +#[derive(Debug)] +enum RuntimeStore { + Durable(Mutex), + #[cfg(test)] + Test(Mutex>), +} + +impl RuntimeStore { + fn checkpoint(&self) -> Result<(), StoreError> { + match self { + Self::Durable(store) => { + let mut store = store.lock().unwrap_or_else(PoisonError::into_inner); + CheckpointBackend::checkpoint(&mut *store) + } + #[cfg(test)] + Self::Test(backend) => backend + .lock() + .unwrap_or_else(PoisonError::into_inner) + .checkpoint(), + } + } +} /// Which side of the shutdown election a caller ended up on. /// @@ -104,6 +149,7 @@ struct Lifecycle { /// holds at most three entries for the life of the process. An unguarded /// `push` would be a slow leak in a daemon that runs for months. history: Vec, + terminal_outcome: Option, } impl Lifecycle { @@ -141,25 +187,25 @@ pub struct Runtime { /// here instead would lose the single-acquisition property that the /// 24,000-attempt concurrency test exists to protect. state_tx: watch::Sender, + store: RuntimeStore, config: Config, } impl Runtime { /// Builds the composition root and brings it to [`LifecycleState::Running`]. /// - /// `async` although nothing is awaited yet, and fallible although nothing - /// fails yet, for the same reason: the store and lease wiring in the - /// follow-on G2 plan starts here, and that work — opening `data_dir`, - /// binding the Coven socket, acquiring a lease — is exactly what fails. - /// Widening either signature later would break every caller, which is the - /// break these two decisions exist to avoid. + /// Opening and migrating SQLite is intentionally synchronous in this + /// bounded G2 seam. The later W3-W9 store actor should own general blocking + /// SQLite work; widening this elected shutdown path with `spawn_blocking` + /// first would introduce a cancellation point that could strand `Draining`. /// /// # Errors /// - /// None are possible in this build — [`RuntimeError`] has no variants, so - /// this always returns `Ok`. The signature is the point: the first thing - /// startup acquires becomes a variant, not a breaking change. + /// Returns [`RuntimeError::Store`] if the configured durable store cannot be + /// opened or migrated. The runtime is not published as `Running` first. pub async fn start(config: Config) -> Result { + let database = config.data_dir.join("psyche.sqlite3"); + let store = Store::open(&database).map_err(RuntimeError::Store)?; // `Sender::new`, not `watch::channel(..)`: `channel` also hands back a // `Receiver` that this type has no use for, and dropping it would leave // a sender with no receivers. See `transition_to` for why that matters. @@ -169,12 +215,29 @@ impl Runtime { lifecycle: Arc::new(Mutex::new(Lifecycle { current: LifecycleState::Running, history: vec![LifecycleState::Running], + terminal_outcome: None, })), state_tx, + store: RuntimeStore::Durable(Mutex::new(store)), config, }) } + #[cfg(test)] + fn start_with_checkpoint_backend(config: Config, backend: Box) -> Self { + let state_tx = watch::Sender::new(LifecycleState::Running); + Self { + lifecycle: Arc::new(Mutex::new(Lifecycle { + current: LifecycleState::Running, + history: vec![LifecycleState::Running], + terminal_outcome: None, + })), + state_tx, + store: RuntimeStore::Test(Mutex::new(backend)), + config, + } + } + /// Takes the lifecycle lock, recovering from poisoning instead of panicking. /// /// `expect` is denied outside tests, and the shutdown path is the last place @@ -241,6 +304,9 @@ impl Runtime { if !lifecycle.advance(next) { return false; } + if next == LifecycleState::Stopped && lifecycle.terminal_outcome.is_none() { + lifecycle.terminal_outcome = Some(ShutdownOutcome::Clean); + } // Published while the guard is still held, so a subscriber can never // observe a state the machine has not entered, and two transitions // cannot be published in the opposite order to the one they were made. @@ -264,22 +330,38 @@ impl Runtime { /// /// Returns immediately if it already has, which is the common case for a /// caller arriving after the runtime has fully stopped. - async fn await_stopped(&self) { + async fn await_stopped(&self) -> ShutdownOutcome { let mut receiver = self.subscribe(); // `borrow_and_update` before `changed`, in that order and in a loop: // `subscribe` marks the current value as seen, so awaiting `changed` // first would miss a `Stopped` that was published before this caller // subscribed, and wait for a transition that can never come. - while *receiver.borrow_and_update() != LifecycleState::Stopped { + loop { + if *receiver.borrow_and_update() == LifecycleState::Stopped { + if let Some(outcome) = self.lifecycle().terminal_outcome.clone() { + return outcome; + } + } if receiver.changed().await.is_err() { // Unreachable: the sender lives in `self`, which this call // borrows. Breaking rather than panicking anyway — this is the // shutdown path. - break; + continue; } } } + fn publish_stopped(&self, outcome: ShutdownOutcome) -> ShutdownOutcome { + let mut lifecycle = self.lifecycle(); + let stored_outcome = lifecycle.terminal_outcome.insert(outcome).clone(); + if lifecycle.advance(LifecycleState::Stopped) { + self.state_tx.send_replace(LifecycleState::Stopped); + } + drop(lifecycle); + tracing::info!(state = %LifecycleState::Stopped, "psyche lifecycle transition"); + stored_outcome + } + /// Stops intake, drains in-flight work, then exits. There is no forced /// path — a caller wanting immediate exit terminates the process. /// @@ -291,16 +373,10 @@ impl Runtime { /// out of `main` and exit the process mid-drain — graceful shutdown /// defeated by ordinary operator behaviour. /// - /// Returns `Ok(())` for both roles. A caller that merely waited still has - /// the answer it asked for — the runtime is stopped, and it stopped - /// gracefully. An error would be false by the time it was returned, and - /// every caller would have to translate it back into success. - /// /// # Errors /// - /// None are possible in this build — [`RuntimeError`] has no variants, so - /// this always returns `Ok`. Losing the election is explicitly *not* an - /// error; that is what the paragraph above is about. + /// Returns the elected driver's checkpoint failure to every concurrent and + /// later caller. Losing the election is not itself an error. pub async fn shutdown(&self) -> Result<(), RuntimeError> { match self.shutdown_inner().await? { // Both roles, one answer — see the note above. @@ -316,18 +392,17 @@ impl Runtime { // and both drive the machine, which once there is real drain work means // running it twice. if !self.transition_to(LifecycleState::Draining) { - self.await_stopped().await; - return Ok(ShutdownRole::Observer); + return self.await_stopped().await.result(ShutdownRole::Observer); } - // The drain seam. Nothing durable is in flight in this slice; the store - // and lease work in the follow-on G2 plan awaits here. No lifecycle - // guard is live across it — `transition_to` takes and drops its own — - // which is what keeps this future `Send`; the assertion below fails to - // compile if that stops being true. - - self.transition_to(LifecycleState::Stopped); - Ok(ShutdownRole::Driver) + // From election through terminal publication this path contains no + // await. The store guard is released before the lifecycle guard is + // acquired, so lock order cannot overlap or invert. + let outcome = match self.store.checkpoint() { + Ok(()) => ShutdownOutcome::Clean, + Err(error) => ShutdownOutcome::CheckpointFailed(Arc::new(error)), + }; + self.publish_stopped(outcome).result(ShutdownRole::Driver) } } @@ -649,6 +724,71 @@ required_api_version = "coven.daemon.v1" } } + #[derive(Debug)] + struct FailingCheckpoint { + calls: Arc, + } + + impl CheckpointBackend for FailingCheckpoint { + fn checkpoint(&mut self) -> Result<(), StoreError> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(StoreError::UnsupportedDatabaseVersion { found: 99 }) + } + } + + #[test] + fn checkpoint_failure_is_shared_by_every_shutdown_caller() { + use std::sync::Barrier; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const CALLERS: usize = 64; + + let calls = Arc::new(AtomicUsize::new(0)); + let runtime = Arc::new(Runtime::start_with_checkpoint_backend( + test_config(), + Box::new(FailingCheckpoint { + calls: Arc::clone(&calls), + }), + )); + let barrier = Arc::new(Barrier::new(CALLERS)); + let executor = tokio::runtime::Builder::new_multi_thread().build().unwrap(); + + let errors = std::thread::scope(|scope| { + let handles: Vec<_> = (0..CALLERS) + .map(|_| { + let runtime = Arc::clone(&runtime); + let barrier = Arc::clone(&barrier); + let handle = executor.handle().clone(); + scope.spawn(move || { + barrier.wait(); + match handle.block_on(runtime.shutdown()) { + Err(RuntimeError::Checkpoint(error)) => error, + other => panic!("expected checkpoint failure, got {other:?}"), + } + }) + }) + .collect(); + + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>() + }); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(runtime.state(), LifecycleState::Stopped); + for error in &errors[1..] { + assert!(Arc::ptr_eq(&errors[0], error)); + } + + let later = match executor.block_on(runtime.shutdown()) { + Err(RuntimeError::Checkpoint(error)) => error, + other => panic!("expected checkpoint failure, got {other:?}"), + }; + assert!(Arc::ptr_eq(&errors[0], &later)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + // `Runtime` derives `Debug` purely on the strength of `Config` redacting its // untyped extensions table. Asserting it here means replacing the field with // something that renders differently fails a test rather than quietly @@ -683,6 +823,7 @@ looks_like_a_secret = "{secretish}" let mut lifecycle = Lifecycle { current: LifecycleState::Running, history: vec![LifecycleState::Running], + terminal_outcome: None, }; assert!(!lifecycle.advance(LifecycleState::Running)); assert!(lifecycle.advance(LifecycleState::Stopped)); diff --git a/crates/psyche-runtime/tests/lifecycle.rs b/crates/psyche-runtime/tests/lifecycle.rs new file mode 100644 index 0000000..a27167f --- /dev/null +++ b/crates/psyche-runtime/tests/lifecycle.rs @@ -0,0 +1,60 @@ +//! Durable-store lifecycle integration tests. +#![allow(clippy::unwrap_used)] + +use psyche_config::Config; +use psyche_runtime::{LifecycleState, Runtime, RuntimeError}; +use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; + +fn test_config(data_dir: &std::path::Path) -> Config { + psyche_config::load_str(&format!( + r#" +schema_version = "psyche.config.v1" +data_dir = "{}" + +[coven] +socket = "/run/coven.sock" +required_api_version = "coven.daemon.v1" +"#, + data_dir.display() + )) + .unwrap() +} + +#[tokio::test] +async fn start_opens_the_configured_store_and_shutdown_leaves_schema_v1_reopenable() { + let directory = tempfile::tempdir().unwrap(); + let data_dir = directory.path().join("private"); + let database = data_dir.join("psyche.sqlite3"); + + let runtime = Runtime::start(test_config(&data_dir)).await.unwrap(); + assert_eq!(runtime.state(), LifecycleState::Running); + assert!(database.is_file(), "{} was not opened", database.display()); + + runtime.shutdown().await.unwrap(); + drop(runtime); + + let reopened = Store::open(&database).unwrap(); + assert_eq!(reopened.schema_version().unwrap(), CURRENT_DATABASE_VERSION); + assert_eq!(CURRENT_DATABASE_VERSION, 1); +} + +#[tokio::test] +async fn future_database_version_fails_start_before_running_is_published() { + let directory = tempfile::tempdir().unwrap(); + let data_dir = directory.path().join("private"); + let database = data_dir.join("psyche.sqlite3"); + drop(Store::open(&database).unwrap()); + let connection = rusqlite::Connection::open(&database).unwrap(); + connection + .pragma_update(None, "user_version", CURRENT_DATABASE_VERSION + 1) + .unwrap(); + drop(connection); + + let result = Runtime::start(test_config(&data_dir)).await; + assert!(matches!( + result, + Err(RuntimeError::Store( + StoreError::UnsupportedDatabaseVersion { .. } + )) + )); +} diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 2968871..8d83943 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -28,7 +28,11 @@ pub(crate) enum DatabaseFileState { pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), StoreError> { validate_path(path)?; - prepare_parent_directory(path)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + prepare_data_dir(parent)?; let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; Ok((open_path, state)) @@ -393,22 +397,23 @@ fn validate_path(path: &Path) -> Result<(), StoreError> { Ok(()) } -fn prepare_parent_directory(path: &Path) -> Result<(), StoreError> { - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - - match fs::symlink_metadata(parent) { - Ok(metadata) => validate_parent_metadata(&metadata)?, - Err(error) if error.kind() == ErrorKind::NotFound => create_parent_directory(parent)?, +pub(crate) fn prepare_data_dir(path: &Path) -> Result { + let existed = match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_parent_metadata(&metadata)?; + true + } + Err(error) if error.kind() == ErrorKind::NotFound => { + create_parent_directory(path)?; + false + } Err(error) => return Err(StoreError::directory_operation(error)), - } + }; - let metadata = fs::symlink_metadata(parent).map_err(StoreError::directory_operation)?; + let metadata = fs::symlink_metadata(path).map_err(StoreError::directory_operation)?; validate_parent_metadata(&metadata)?; - Ok(()) + Ok(existed) } fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { @@ -420,7 +425,7 @@ fn validate_parent_metadata(metadata: &fs::Metadata) -> Result<(), StoreError> { { use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o777 != 0o700 { + if metadata.permissions().mode() & 0o7777 != 0o700 { return Err(StoreError::InvalidDatabasePath); } } diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 7df4306..54471c2 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -29,6 +29,20 @@ pub use records::IngestOutcome; pub use retention::PruneReport; pub use transitions::Transition; +/// Prepares a directory suitable for the durable store. +/// +/// Missing directories are created with owner-only permissions on Unix. +/// Existing directories are validated but never have their permissions +/// changed. Returns whether the directory already existed. +/// +/// # Errors +/// +/// Returns [`StoreError`] when the path cannot be created or does not satisfy +/// the store's directory safety requirements. +pub fn prepare_data_dir(path: &Path) -> Result { + connection::prepare_data_dir(path) +} + /// A configured connection to Psyche's durable SQLite substrate. #[derive(Debug)] pub struct Store { diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 53ce1bf..8643b78 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -9,7 +9,7 @@ use std::{ thread, }; -use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; +use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError, prepare_data_dir}; use rusqlite::Connection; use support::{ FOUNDATION_TABLES, Fixture, execute_batch, fixture_db, foundation_tables, journal_mode, @@ -40,6 +40,53 @@ fn fresh_store_applies_v1_once_and_reopens() { assert_private_file(&path); } +#[test] +fn data_dir_initializer_creates_a_runtime_compatible_directory() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("nested").join("psyche"); + + assert!(!prepare_data_dir(&data_dir).unwrap()); + assert_private_directory(&data_dir); + assert!(prepare_data_dir(&data_dir).unwrap()); + + drop(Store::open(&data_dir.join("psyche.sqlite3")).unwrap()); +} + +#[cfg(unix)] +#[test] +fn data_dir_initializer_rejects_an_existing_insecure_nonempty_directory_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("shared"); + std::fs::create_dir(&data_dir).unwrap(); + std::fs::write(data_dir.join("operator-data"), b"preserve").unwrap(); + set_mode(&data_dir, 0o755); + + assert!(matches!( + prepare_data_dir(&data_dir), + Err(StoreError::InvalidDatabasePath) + )); + assert_eq!(mode(&data_dir), 0o755); + assert_eq!( + std::fs::read(data_dir.join("operator-data")).unwrap(), + b"preserve" + ); +} + +#[cfg(unix)] +#[test] +fn data_dir_initializer_rejects_special_permission_bits_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("setuid"); + std::fs::create_dir(&data_dir).unwrap(); + set_mode(&data_dir, 0o1700); + + assert!(matches!( + prepare_data_dir(&data_dir), + Err(StoreError::InvalidDatabasePath) + )); + assert_eq!(mode(&data_dir), 0o1700); +} + #[test] fn version_zero_fixture_migrates_without_losing_existing_data() { let dir = tempfile::tempdir().unwrap(); @@ -744,7 +791,7 @@ fn assert_private_file(path: &Path) { fn mode(path: &Path) -> u32 { use std::os::unix::fs::PermissionsExt; - std::fs::metadata(path).unwrap().permissions().mode() & 0o777 + std::fs::metadata(path).unwrap().permissions().mode() & 0o7777 } #[cfg(unix)] diff --git a/docs/CLI.md b/docs/CLI.md index ad1e53a..f46c9b4 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -160,12 +160,16 @@ validates anything, so neither can fail. - **`config`** — whether the configuration loaded, and which schema it declares. On failure the detail names the path and the reason. `fail` here means the command exits 3. -- **`data_dir`** — creates the directory if absent, then writes and removes a - probe file inside it. `ok` means it existed and a write succeeded; `warn` means - this run created it, which usually means the path is not the one you meant; - `fail` means the write failed, and the command exits 5. The word "writable" is - earned by an actual write — checking only that the directory exists reports a - mode-500 directory as writable. +- **`data_dir`** — prepares the directory with the same safety contract used by + runtime startup, then writes and removes a probe file inside it. A missing + directory is created owner-only on Unix and reported as `warn`; an existing + Unix directory must have exactly mode `0700`, with no special permission + bits. An insecure existing directory (including a writable mode-`0755` + directory or mode `01700`) is left unchanged: `doctor` + exits 5 and runtime startup also fails closed rather than auto-`chmod`ing + operator data. `ok` means the directory already existed, passed those safety + checks, and the probe write succeeded. On Windows, the platform's ACL and + directory semantics apply; no Unix mode assertion is made. - **`coven_socket_path`** — reports the configured path. Nothing is contacted at this gate. - **`extensions`** — reports how many extension tables are present. Values are From 0195401a7fbd45a0be90f74baa9bc549f169174c Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:35:32 -0500 Subject: [PATCH 54/66] test(runtime): align G2 checkpoint evidence name --- crates/psyche-runtime/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/psyche-runtime/src/lib.rs b/crates/psyche-runtime/src/lib.rs index 7c98143..f78fc0f 100644 --- a/crates/psyche-runtime/src/lib.rs +++ b/crates/psyche-runtime/src/lib.rs @@ -737,7 +737,7 @@ required_api_version = "coven.daemon.v1" } #[test] - fn checkpoint_failure_is_shared_by_every_shutdown_caller() { + fn checkpoint_failure_stops_and_releases_every_shutdown_waiter() { use std::sync::Barrier; use std::sync::atomic::{AtomicUsize, Ordering}; From b39b6d73d61af453dc02b36b38ffd82057e2242e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:53:59 -0500 Subject: [PATCH 55/66] chore(msrv): raise Psyche MSRV to 1.88 --- .github/workflows/ci.yml | 33 +++++++-------------------------- Cargo.lock | 16 ++++++++-------- Cargo.toml | 15 ++++++--------- crates/psyche-cli/tests/cli.rs | 2 -- rust-toolchain.toml | 2 +- 5 files changed, 22 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69b7f8..f837a82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,20 +23,12 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - # Pinned to the MSRV rather than `@stable`, and stated explicitly rather - # than left to the action's default: `rust-toolchain.toml` already pins - # 1.85.0 and takes precedence over whatever cargo is invoked through, so - # `@stable` would download a toolchain the build then never uses — a - # slower job that silently tests nothing about the version we ship. - # The two pins must agree; Step 4 is what catches it if they drift. - # - # Verified: this action runs `rustup default ` and does NOT - # export RUSTUP_TOOLCHAIN, so `rust-toolchain.toml` really does win. That - # is why the pin here is documentation rather than mechanism — and why the - # `supply-chain` job below has to escape it explicitly. + # `rust-toolchain.toml` controls the cargo/rustc version used in this + # checkout. The action pin avoids a redundant download; keep both pins + # aligned with the workspace MSRV. - uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85.0" + toolchain: "1.88.0" components: rustfmt, clippy # `RUSTFLAGS` is part of this action's cache key (it hashes every env var # whose name starts with CARGO/CC/CFLAGS/CXX/CMAKE/RUST). Setting it at @@ -56,21 +48,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # NOT the MSRV pin, unlike the `rust` job. cargo-deny is a tool we run - # against the tree, not something we ship, so its build toolchain says - # nothing about what we support. It also cannot use the pin: cargo-deny - # 0.19.8 declares rust-version 1.88.0, and `cargo install` under 1.85.0 - # refuses outright — - # error: cannot install package `cargo-deny`, it requires rustc 1.88.0 - # or newer, while the currently active rustc version is 1.85.0 - # No `components`: this job never runs fmt or clippy. + # cargo-deny is tooling, not a shipped dependency. The action and the + # explicit `+stable` install select its toolchain independently of the + # workspace pin. No components are needed here. - uses: dtolnay/rust-toolchain@stable - # `+stable` is load-bearing, not decoration. `rust-toolchain.toml` sits at - # the repo root and outranks `rustup default`, so a bare `cargo install` - # here would run under 1.85.0 and hit the error above no matter which - # toolchain the step above installed. An explicit `+toolchain` is the one - # thing that outranks the toolchain file. - # # Installed directly rather than via a third-party action: it is the same # binary and the same command engineers run locally in Task 7 Step 2, so # there is no CI-only path, and it adds no extra action to trust. Pinned diff --git a/Cargo.lock b/Cargo.lock index 1428ece..216060c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,9 +512,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1065,9 +1065,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -1080,15 +1080,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index bfe37d9..80f0d6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] -# Resolver 3 is MSRV-aware. clap, assert_cmd, and toml all declare rust-version -# 1.85 — exactly our pin — so under resolver 2 a routine `cargo update` would -# select a release requiring a newer compiler and break the build. +# Resolver 3 is MSRV-aware, so a routine `cargo update` respects the workspace +# compiler floor instead of selecting a release that requires a newer compiler. resolver = "3" # Members are added by the task that creates each crate. Cargo loads every # declared member's manifest on ANY command — `--no-deps` and `--manifest-path` @@ -20,12 +19,10 @@ members = [ [workspace.package] version = "0.0.0" -# Edition 2024 stabilised in Rust 1.85 — the version pinned above. Adopting it -# now costs nothing; deferring means migrating four crates of real code later, -# and the 2024 `if let` temporary-scope change alters when guards drop across -# awaits, which is a behavioural migration best done at 50 lines. +# Edition 2024 predates this workspace compiler floor. Rust 1.88 is required by +# the patched `time` release used by the audited dependency graph. edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT" repository = "https://github.com/OpenCoven/psyche" publish = false # distributed via npm, never crates.io @@ -44,7 +41,7 @@ async-trait = "0.1" rusqlite = { version = "0.32", features = ["bundled"] } serde_json_canonicalizer = "0.3" sha2 = "0.10" -time = { version = "0.3", features = ["formatting", "parsing", "serde"] } +time = { version = "0.3.47", features = ["formatting", "parsing", "serde"] } ulid = { version = "1", features = ["serde"] } proptest = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/psyche-cli/tests/cli.rs b/crates/psyche-cli/tests/cli.rs index 544b507..e397f40 100644 --- a/crates/psyche-cli/tests/cli.rs +++ b/crates/psyche-cli/tests/cli.rs @@ -534,8 +534,6 @@ fn psyche_start_and_psyched_accept_the_same_flags() { let mut found = std::collections::BTreeSet::new(); for token in help.split_whitespace() { let token = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'); - // Nested rather than a `let` chain: those stabilised after this - // workspace's 1.85 MSRV. if let Some(name) = token.strip_prefix("--") { let plausible = !name.is_empty() && name diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7e885f7..0893b52 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.85.0" +channel = "1.88.0" components = ["rustfmt", "clippy", "rust-src"] # rust-src: rust-analyzer stdlib support profile = "minimal" From 782208def0822e47c6bf5dc865d4abea88b4d472 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:55:09 -0500 Subject: [PATCH 56/66] docs: wire Psyche G2 evidence --- .github/workflows/ci.yml | 19 + docs/ARCHITECTURE.md | 25 + docs/G2-EVIDENCE.md | 52 ++ docs/SCHEMAS.md | 69 +++ docs/TESTING.md | 58 ++ scripts/check-g2-evidence-test.py | 553 +++++++++++++++++++ scripts/check-g2-evidence.py | 890 ++++++++++++++++++++++++++++++ scripts/g2-test-manifest.json | 136 +++++ 8 files changed, 1802 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/G2-EVIDENCE.md create mode 100644 docs/SCHEMAS.md create mode 100644 docs/TESTING.md create mode 100644 scripts/check-g2-evidence-test.py create mode 100644 scripts/check-g2-evidence.py create mode 100644 scripts/g2-test-manifest.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f837a82..5253bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,25 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Tests run: cargo test --workspace --locked + - name: G2 state machine (fixed seed) + env: + PROPTEST_CASES: "2048" + PROPTEST_RNG_SEED: "00000000000000000000000000000000" + run: cargo test -p psyche-test-support --test state_machine + - name: G2 reusable conformance + run: cargo test -p psyche-test-support --test conformance + - name: G2 migrations + run: cargo test -p psyche-store --test migrations + - name: G2 crash recovery + run: cargo test -p psyche-store --features test-fault-injection --test crash + - name: G2 fault-injection clippy + run: cargo clippy -p psyche-store --all-targets --features test-fault-injection -- -D warnings + - name: G2 evidence checker unit tests + run: python3 scripts/check-g2-evidence-test.py + - name: G2 evidence relationships + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/check-g2-evidence.py supply-chain: name: Dependency audit diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..39b013a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# Architecture + +Psyche keeps contracts and authority in Rust. Dependency arrows point from a +dependency to its consumer: + +```text +psyche-core <- psyche-config +psyche-core <- psyche-store +psyche-core <- psyche-coven +psyche-core <- psyche-surfaces +psyche-core + psyche-coven + psyche-surfaces + psyche-store <- psyche-test-support +psyche-config + psyche-store <- psyche-runtime <- psyche-cli +psyche-config <- psyche-cli +psyche-store <- psyche-cli +``` + +`psyche-core` owns canonical contracts, validation, IDs, digests, and error +vocabulary. `psyche-store` owns durable records, transitions, quarantine, and +retention. `psyche-coven` owns the typed Coven boundary but delegates canonical +contract decisions to core. `psyche-surfaces` owns bounded surface ports. +`psyche-test-support` depends on the complete boundary so its reusable suites +can test adapters without becoming production authority. Runtime composes +configuration and storage. Runtime owns opening the store during startup. The +CLI is its outer process boundary and reads configuration. +The CLI uses its direct store dependency only for doctor data-directory preparation. diff --git a/docs/G2-EVIDENCE.md b/docs/G2-EVIDENCE.md new file mode 100644 index 0000000..81bf23a --- /dev/null +++ b/docs/G2-EVIDENCE.md @@ -0,0 +1,52 @@ +# G2 Contract Foundation Evidence + +**Status:** candidate +**Tested source commit:** not recorded before remote review +**CI attestation:** not recorded before remote review +**Coven plan source commit:** not recorded before plan approval +**Coven plan URL:** not recorded before plan approval +**Coven plan SHA-256:** not recorded before plan approval +**Coven specification source commit:** `42dcbc43-34cb48ec-af63efb5-50345e3e-ea2fb7ad` + +| Coven source | Immutable URL | SHA-256 | +|---|---|---| +| PLAN | `https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/PLAN.md` | `sha256:01382f8a-0d2bca95-ddd53563-4dd6a9f0-9ac4a80d-588ccbeb-d72f163e-af56bc1e` | +| RUNTIME_DESIGN | `https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/RUNTIME_DESIGN.md` | `sha256:ab8c9222-14b8f117-9ebf71fb-8dfb55bd-6d0ff2d6-dfced455-1bf90503-767bb6b8` | +| TECH | `https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/TECH.md` | `sha256:1d00fb2b-725f384c-a027db60-d0afbd0a-62a7ec6c-7dcbb563-7bf14d30-d40e2e1c` | +| COVEN_PREREQUISITES | `https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/COVEN_PREREQUISITES.md` | `sha256:33994a28-921e70f8-24b0260c-e08231b2-117c5043-0c54e996-ed47582d-060e72f9` | +| COVEN_W1_AUDIT | `https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/COVEN_W1_AUDIT.md` | `sha256:eab9028b-f7ef9c8a-96d4c6be-d69e4ef0-b3497b47-0ca26589-cb3ffcd8-0677322d` | + +| Criterion | Command | Result | Artifact | +|---|---|---|---| +| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | not run remotely | none | +| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | not run remotely | none | +| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | not run remotely | none | +| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | not run remotely | none | +| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | not run remotely | none | +| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | not run remotely | none | +| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | not run remotely | none | +| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | not run remotely | none | +| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | not run remotely | none | +| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | not run remotely | none | +| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | not run remotely | none | +| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | not run remotely | none | +| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | not run remotely | none | +| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | not run remotely | none | +| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | not run remotely | none | +| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | not run remotely | none | +| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | not run remotely | none | +| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | not run remotely | none | +| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | not run remotely | none | +| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | not run remotely | none | +| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | not run remotely | none | +| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | not run remotely | none | +| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | not run remotely | none | +| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | not run remotely | none | +| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | not run remotely | none | +| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | not run remotely | none | +| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | not run remotely | none | +| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | not run remotely | none | +| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | not run remotely | none | +| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | not run remotely | none | +| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | not run remotely | none | +| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | not run remotely | none | diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md new file mode 100644 index 0000000..af21854 --- /dev/null +++ b/docs/SCHEMAS.md @@ -0,0 +1,69 @@ +# G2 Schemas + +## Registry and decoding + +The closed v1 registry is `psyche.identity_snapshot.v1`, `psyche.intent.v1`, +`psyche.surface_event.v1`, `psyche.graph.v1`, `psyche.graph_node.v1`, +`psyche.delegation.v1`, `psyche.budget.v1`, `psyche.approval.v1`, +`psyche.execution_binding.v1`, `psyche.evidence.v1`, `psyche.verdict.v1`, +`psyche.recovery.v1`, `psyche.addon.v1`, `psyche.surface_effect.v1`, +`psyche.delivery.v1`, and `psyche.error.v1`. + +Typed G2 records deny unknown fields. An unknown kind, unknown major version, +or unknown enum value is a strict decode failure and becomes a quarantinable +document, never a dispatchable record. `psyche.error.v1` exhaustively decodes +every `ErrorCode::ALL` value but is not persistable. Canonical JSON follows RFC +8785; every digest is SHA-256 over complete canonical typed content, and a +claimed digest is recomputed before authority or persistence accepts it. + +## Stored records and identity + +`RecordKind` has exactly fifteen identities. An execution binding is the one +`Attempt` record with the `att_` prefix: `SchemaKind::ExecutionBinding -> +RecordKind::Attempt -> att_`. There is no duplicate binding-named record kind. +Delivery is authoritative at `del_`; the related delegation identity is the +distinct derived `dlg_` prefix. + +The canonical delivery v1 fields are `schema_version`, `delivery_id`, +`intent_id`, `surface`, `target`, `state`, `attempt`, `created_at`, +`updated_at`, and `last_error`. Surface event/effect envelopes are core-owned, +bounded, schema-versioned types; adapters cannot add fields or widen payloads. + +The store-owned `Transition` validates record identity, nonempty from/to state, +strictly increasing version, canonical UTC `created_at`, and its canonical +digest. Transition history is append-only. + +## Quarantine and retention + +`QuarantineId` is the owned strict `qua_` identity with one canonical uppercase +ULID suffix. A resolution records resolver, reason, canonical resolution +details and digest, and resolution time. Exact replay is idempotent and +concurrent resolution has one durable winner. Unresolved quarantine, +execution-binding revisions, transition-history rows, and audit-event rows are +excluded from automated retention. Content referenced by an unresolved or +live record remains retained. + +## Cancellation and results + +The G2 provisional `CancellationState` vocabulary is core-owned. A claimed O5 +acknowledged state requires matching core-owned +`CancellationAcknowledgementEvidence`, including request, session, execution +request, digest, authority evidence, kind, and acknowledgement time. Raw Coven +ledger statuses (`created`, `running`, `idle`, `completed`, `failed`, `killed`, +and `orphaned`) never manufacture that evidence. Unresolved outcomes use the +separate core-owned unresolved evidence contract. + +`ResultBundle` owns `session_id`, complete execution correlation, one primary +content reference, and ordered artifacts. Every result/artifact content +reference contains canonical `digest`, `media_type`, `size_bytes`, and +`expires_at`; every artifact repeats the bundle lifetime/correlation and +session. The retention owner is the durable store, not an adapter. + +## Deferred owners + +G2 deliberately defers routing policy, delivery retry policy, budget policy, +approval policy, evidence/verdict policy, recovery policy, addon policy, +surface-specific presentation, artifact blob transport, and automated +quarantine adjudication to their later named authority owners. The schemas +freeze interoperability; they do not silently assign those decisions to a +boundary adapter. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..e8443f4 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,58 @@ +# G2 Testing + +G2 tests use deterministic fake scripts at adapter-neutral boundaries. A +fixture advertises availability, consumes an exact script, exposes typed fault +points, supports restart, and reports durable observations. Positive paths +must return `Verified`; negative paths reject malformed, widened, stale, +unordered, or non-durable behavior. `ExpectedUnsupported` is only a diagnostic +for a real adapter and never counts as passed scripted evidence. + +Property runs set `PROPTEST_CASES` and `PROPTEST_RNG_SEED`; CI fixes them to +2048 cases and the all-zero seed. The state-machine models compare durable +store state after each operation. Crash tests inject named before/after-commit +points, kill a writer, reopen the database, and observe only committed state. +The C-S6 matrix covers immutable correlation, durable return, durable fence, +fault injection, restart, and a no-redispatch decision unless a fence makes a +new dispatch eligible. + +The full-request digest suite constructs digests only through +`AdoptionRequest::new`, forces both owners to recompute them, and retains a +stale digest while mutating every typed field. Launch and input goldens require +RFC3339 string timestamps, byte-for-byte canonical JSON, no trailing newline, +and pinned SHA-256. `scripts/g2-test-manifest.json` maps each allowlisted atomic +matrix command to an exact test name. The checker lists every Cargo target and +rejects zero tests, missing names, substring filters, or unused entries. + +O5 tests reject raw statuses `created`, `running`, `idle`, `completed`, +`failed`, `killed`, and `orphaned` as cancellation acknowledgement. Immutable Coven +blob URLs and SHA-256 values bind the evidence to the reviewed sources. + +## Reusable conformance matrix + +- C-S1 positive: exact contract/capability negotiation; negative: version or + capability widening is a structured denial. +- C-S2 positive: session lifecycle is correlation-stable; negative: invalid + request/session transitions do not persist. +- C-S3 positive: snapshot and Attempt binding agree; negative: mismatched + snapshot, attempt, project, or graph correlation is rejected. +- C-S4 positive: stable adoption replays one disposition; negative: every + full-request digest mutation and post-commit ambiguity is rejected/reconciled. +- C-S5 positive: durable non-adoption proof permits the modeled decision; + negative: unknown or adopted dispositions never masquerade as proof. +- C-S6 positive: immutable correlation yields durable return or durable fence; + negative: faults, restart, unresolved state, and no-redispatch without fence + remain blocked. +- C-S7 positive: cursor pages are ordered and restart-stable; negative: gaps, + duplicates, drift, and before/after-page faults are rejected. +- C-S8 positive: typed terminal authority persists before use; negative: raw + terminal strings and unpersisted terminal observations are non-authoritative. +- C-S9 positive: core-owned O5 acknowledgement/unresolved evidence persists; + negative: every raw status, correlation mismatch, and lifetime violation is + rejected. +- C-S10 positive: strict result/artifact digest, media type, size, expiry, + correlation, and lifetime match; negative: each independent mutation fails. +- C-S11 positive: durable state survives every declared crash point and + restart; negative: volatile observations reset and indeterminate persistence + cannot claim success. +- C-S12 positive: known denials preserve the canonical structured error; + negative: unknown enums/kinds/majors quarantine rather than dispatch. diff --git a/scripts/check-g2-evidence-test.py b/scripts/check-g2-evidence-test.py new file mode 100644 index 0000000..89dcf34 --- /dev/null +++ b/scripts/check-g2-evidence-test.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +"""Mutation tests for the G2 evidence relationship checker.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import pathlib +import subprocess +import sys +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CHECKER_PATH = ROOT / "scripts/check-g2-evidence.py" +sys.dont_write_bytecode = True + + +def load_checker(): + spec = importlib.util.spec_from_file_location("check_g2_evidence", CHECKER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {CHECKER_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class G2EvidenceCheckerTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.checker = load_checker() + cls.manifest = json.loads((ROOT / "scripts/g2-test-manifest.json").read_text()) + cls.evidence = (ROOT / "docs/G2-EVIDENCE.md").read_text() + cls.listed = { + target: "".join(f"{name}: test\n" for name in definition["tests"]) + for target, definition in cls.manifest["targets"].items() + } + + def assert_valid( + self, + *, + manifest=None, + evidence=None, + listed=None, + overrides=None, + ) -> None: + self.checker.validate_repository( + ROOT, + manifest=manifest or self.manifest, + evidence=evidence or self.evidence, + listed_tests=listed or self.listed, + source_overrides=overrides or {}, + verify_remote=False, + ) + + def assert_rejected(self, *, hash_only: bool = False, **kwargs) -> None: + overrides = kwargs.get("overrides") + if not hash_only and isinstance(overrides, dict) and ".github/workflows/ci.yml" in overrides: + self.assert_structure_rejected(overrides[".github/workflows/ci.yml"]) + with self.assertRaises(self.checker.EvidenceError): + self.assert_valid(**kwargs) + + def assert_structure_rejected(self, workflow: str) -> None: + with self.assertRaises(self.checker.EvidenceError): + self.checker.validate_ci_structure(workflow) + + def passed_evidence(self) -> str: + run_url = "https://github.com/OpenCoven/psyche/actions/runs/123456" + passed = self.evidence.replace("**Status:** candidate", "**Status:** passed") + passed = passed.replace( + "**Tested source commit:** not recorded before remote review", + "**Tested source commit:** 0123456789abcdef0123456789abcdef01234567", + ) + passed = passed.replace( + "**CI attestation:** not recorded before remote review", + f"**CI attestation:** {run_url}", + ) + passed = passed.replace( + "**Coven plan source commit:** not recorded before plan approval", + f"**Coven plan source commit:** {self.checker.APPROVED_PLAN_COMMIT}", + ) + passed = passed.replace( + "**Coven plan URL:** not recorded before plan approval", + "**Coven plan URL:** " + f"https://github.com/OpenCoven/coven/blob/{self.checker.APPROVED_PLAN_COMMIT}/" + f"{self.checker.PLAN_PATH}", + ) + passed = passed.replace( + "**Coven plan SHA-256:** not recorded before plan approval", + f"**Coven plan SHA-256:** sha256:{self.checker.APPROVED_PLAN_SHA256}", + ) + return passed.replace("not run remotely | none", f"passed | {run_url}") + + def test_valid_exact_manifest_and_candidate_evidence(self) -> None: + self.assert_valid() + + def test_zero_listed_tests_is_rejected(self) -> None: + listed = dict(self.listed) + listed["psyche-core/contracts"] = "" + self.assert_rejected(listed=listed) + + def test_missing_manifest_name_is_rejected(self) -> None: + manifest = copy.deepcopy(self.manifest) + manifest["targets"]["psyche-core/contracts"]["tests"][0] = "not_a_real_test" + self.assert_rejected(manifest=manifest) + + def test_substring_filter_without_exact_is_rejected(self) -> None: + evidence = self.evidence.replace( + "-- --exact delivery_keeps_the_canonical_del_prefix", + "-- delivery_keeps_the_canonical_del_prefix", + 1, + ) + self.assert_rejected(evidence=evidence) + + def test_unused_manifest_entry_is_rejected(self) -> None: + manifest = copy.deepcopy(self.manifest) + manifest["targets"]["psyche-core/contracts"]["tests"].append("unused_test") + listed = dict(self.listed) + listed["psyche-core/contracts"] += "unused_test: test\n" + self.assert_rejected(manifest=manifest, listed=listed) + + def test_duplicate_matrix_row_is_rejected(self) -> None: + row = next(line for line in self.evidence.splitlines() if line.startswith("| Complete canonical error enum |")) + self.assert_rejected(evidence=self.evidence.replace(row + "\n", row + "\n" + row + "\n")) + + def test_relative_or_mutable_coven_url_is_rejected(self) -> None: + immutable = "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/PLAN.md" + for replacement in ("../specs/psyche/PLAN.md", immutable.replace("42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad", "main")): + with self.subTest(replacement=replacement): + self.assert_rejected(evidence=self.evidence.replace(immutable, replacement)) + + def test_coven_source_sha256_mismatch_is_rejected(self) -> None: + self.assert_rejected( + evidence=self.evidence.replace( + "sha256:01382f8a-0d2bca95-ddd53563-4dd6a9f0-9ac4a80d-588ccbeb-d72f163e-af56bc1e", + "sha256:11382f8a-0d2bca95-ddd53563-4dd6a9f0-9ac4a80d-588ccbeb-d72f163e-af56bc1e", + ) + ) + + def test_every_c_s_wrapper_and_evidence_row_is_required(self) -> None: + conformance_path = "crates/psyche-test-support/tests/conformance.rs" + source = (ROOT / conformance_path).read_text() + for number in range(1, 13): + wrapper = f"c_s{number}_" + with self.subTest(wrapper=wrapper): + self.assert_rejected(overrides={conformance_path: source.replace(wrapper, f"removed_c_s{number}_")}) + row = next( + line + for line in self.evidence.splitlines() + if line.startswith(f"| C-S{number} scripted ") + ) + with self.subTest(row=number): + self.assert_rejected(evidence=self.evidence.replace(row + "\n", "")) + + def test_expected_unsupported_never_counts_as_passed(self) -> None: + passed = self.passed_evidence().replace( + "passed | https://github.com/OpenCoven/psyche/actions/runs/123456", + "ExpectedUnsupported | https://github.com/OpenCoven/psyche/actions/runs/123456", + 1, + ) + with self.assertRaisesRegex(self.checker.EvidenceError, "every passed matrix result"): + self.assert_valid(evidence=passed) + + def test_every_scalar_evidence_field_must_occur_exactly_once(self) -> None: + labels = ( + "Status", + "Tested source commit", + "CI attestation", + "Coven plan source commit", + "Coven plan URL", + "Coven plan SHA-256", + "Coven specification source commit", + ) + for label in labels: + line = next(line for line in self.evidence.splitlines() if line.startswith(f"**{label}:**")) + for suffix in (line, f"**{label}:** conflicting-value"): + with self.subTest(label=label, duplicate=suffix == line): + self.assert_rejected(evidence=self.evidence.replace(line + "\n", line + "\n" + suffix + "\n", 1)) + + def test_commented_ci_command_does_not_count_as_an_active_run(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + command = "cargo test -p psyche-test-support --test conformance" + mutated = workflow.replace(f" run: {command}", f" # run: {command}", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_fixed_seed_ci_step_requires_portable_env_mapping(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + self.assertIn(' env:\n PROPTEST_CASES: "2048"\n', workflow) + self.assertIn(' PROPTEST_RNG_SEED: "00000000000000000000000000000000"\n', workflow) + self.assertIn(" run: cargo test -p psyche-test-support --test state_machine\n", workflow) + mutated = workflow.replace(' PROPTEST_CASES: "2048"\n', "", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_step_rejects_if_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: G2 reusable conformance\n" + mutated = workflow.replace(anchor, anchor + " if: ${{ false }}\n", 1) + self.assert_rejected(overrides={path: mutated}) + commented = workflow.replace(anchor, anchor + " # if: ${{ false }}\n", 1) + self.checker.validate_ci_structure(commented) + + def test_required_ci_step_rejects_continue_on_error_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: G2 reusable conformance\n" + mutated = workflow.replace(anchor, anchor + " continue-on-error: true\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_rust_ci_job_rejects_if_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" rust:\n", " rust:\n if: ${{ false }}\n", 1) + self.assert_rejected(overrides={path: mutated}) + other_job = workflow.replace(" npm:\n", " npm:\n if: ${{ always() }}\n", 1) + self.checker.validate_ci_structure(other_job) + + def test_rust_ci_job_rejects_continue_on_error_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" rust:\n", " rust:\n continue-on-error: true\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_g2_step_must_remain_in_rust_matrix_job(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + step = ( + " - name: G2 reusable conformance\n" + " run: cargo test -p psyche-test-support --test conformance\n" + ) + mutated = workflow.replace(step, "", 1).replace(" - name: Wrapper tests\n", step + " - name: Wrapper tests\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_supply_chain_ci_job_rejects_if_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" supply-chain:\n", " supply-chain:\n if: ${{ false }}\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_supply_chain_ci_job_rejects_continue_on_error_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" supply-chain:\n", " supply-chain:\n continue-on-error: true\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_secrets_ci_job_rejects_if_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" secrets:\n", " secrets:\n if: ${{ false }}\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_secrets_ci_job_rejects_continue_on_error_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" secrets:\n", " secrets:\n continue-on-error: true\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_rust_ci_job_requires_active_matrix_runs_on(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" runs-on: ${{ matrix.os }}\n", " runs-on: ubuntu-latest\n", 1) + self.assert_rejected(overrides={path: mutated}) + commented = workflow.replace(" runs-on: ${{ matrix.os }}\n", " # runs-on: ubuntu-latest\n runs-on: ${{ matrix.os }}\n", 1) + self.checker.validate_ci_structure(commented) + + def test_rust_ci_matrix_requires_every_supported_os(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + matrix = " os: [ubuntu-latest, macos-latest, windows-latest]\n" + for removed in ("macos-latest", "windows-latest"): + remaining = [os for os in ("ubuntu-latest", "macos-latest", "windows-latest") if os != removed] + replacement = f" os: [{', '.join(remaining)}]\n # os: [{removed}]\n" + with self.subTest(removed=removed): + self.assert_rejected(overrides={path: workflow.replace(matrix, replacement, 1)}) + + def test_rust_ci_matrix_rejects_include_and_exclude(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " matrix:\n" + for key in ("include", "exclude"): + mutation = f" {key}:\n - os: ubuntu-latest\n" + with self.subTest(key=key): + self.assert_rejected(overrides={path: workflow.replace(anchor, anchor + mutation, 1)}) + + def test_rust_ci_strategy_requires_active_fail_fast_false(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" fail-fast: false\n", " fail-fast: true\n # fail-fast: false\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_jobs_reject_needs_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + for job in ("rust", "supply-chain", "secrets"): + with self.subTest(job=job): + mutated = workflow.replace(f" {job}:\n", f" {job}:\n needs: npm\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_step_rejects_quoted_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: G2 reusable conformance\n" + mutated = workflow.replace(anchor, anchor + ' "if": ${{ false }}\n', 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_step_rejects_spaced_key(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: G2 reusable conformance\n" + mutated = workflow.replace(anchor, anchor + " if : ${{ false }}\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_step_rejects_shell_override(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: G2 reusable conformance\n" + mutated = workflow.replace(anchor, anchor + " shell: echo {0}\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_ci_job_rejects_defaults_shell_override(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutations = ( + workflow.replace(" rust:\n", " rust:\n defaults: { run: { shell: echo {0} } }\n", 1), + workflow.replace( + " rust:\n", + " rust:\n defaults:\n run:\n shell: echo {0}\n", + 1, + ), + ) + for nested, mutated in enumerate(mutations): + with self.subTest(nested=bool(nested)): + self.assert_rejected(overrides={path: mutated}) + + def test_workflow_rejects_defaults(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace("jobs:\n", "defaults: { run: { shell: echo {0} } }\njobs:\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_workflow_env_rejects_command_override(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace("env:\n", "env:\n CARGO: /bin/true\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_required_workflow_regions_reject_yaml_anchors_aliases_and_merges(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutations = ( + workflow.replace("env:\n", "env: &global_env\n", 1), + workflow.replace(" rust:\n", " rust:\n <<: *required_job\n", 1), + ) + for index, mutated in enumerate(mutations): + with self.subTest(index=index): + self.assert_rejected(overrides={path: mutated}) + + def test_workflow_rejects_noncanonical_inline_triggers(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + for trigger in ("workflow_dispatch", "push"): + with self.subTest(trigger=trigger): + self.assert_rejected(overrides={path: workflow.replace("on:\n", f"on: {trigger}\n", 1)}) + + def test_workflow_requires_active_pull_request_trigger(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + for replacement in ("", " pull-requests:\n"): + with self.subTest(replacement=replacement): + self.assert_rejected(overrides={path: workflow.replace(" pull_request:\n", replacement, 1)}) + + def test_workflow_push_trigger_requires_main_branch(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace(" branches: [main]\n", " branches: [develop]\n", 1) + self.assert_rejected(overrides={path: mutated}) + + def test_workflow_hash_rejects_setup_action_drift(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + mutated = workflow.replace("dtolnay/rust-toolchain@master", "dtolnay/rust-toolchain@stable", 1) + self.assert_rejected(hash_only=True, overrides={path: mutated}) + + def test_workflow_hash_rejects_github_path_poison_step(self) -> None: + path = ".github/workflows/ci.yml" + workflow = (ROOT / path).read_text() + anchor = " - name: Format\n" + poison = " - name: Poison PATH\n run: echo /tmp/poison >> $GITHUB_PATH\n" + self.assert_rejected(hash_only=True, overrides={path: workflow.replace(anchor, poison + anchor, 1)}) + + def test_passed_remote_verifier_accepts_exact_ci_attestation(self) -> None: + passed = self.passed_evidence() + completed = ( + subprocess.CompletedProcess([], 0, "", ""), + subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), + ) + run = { + "conclusion": "success", + "event": "pull_request", + "headSha": "0123456789abcdef0123456789abcdef01234567", + "url": "https://github.com/OpenCoven/psyche/actions/runs/123456", + "workflowName": "CI", + } + rest = { + "conclusion": "success", + "event": "pull_request", + "head_repository": {"full_name": "OpenCoven/psyche"}, + "head_sha": "0123456789abcdef0123456789abcdef01234567", + "html_url": "https://github.com/OpenCoven/psyche/actions/runs/123456", + "id": 123456, + "path": ".github/workflows/ci.yml", + "repository": {"full_name": "OpenCoven/psyche"}, + "status": "completed", + "workflow_id": 326408880, + } + workflow = { + "id": 326408880, + "name": "CI", + "path": ".github/workflows/ci.yml", + "state": "active", + } + with mock.patch.object(self.checker.subprocess, "run", side_effect=completed), mock.patch.object( + self.checker, "run_json", side_effect=(run, rest, workflow) + ) as run_json, mock.patch.object(self.checker, "verify_coven_blob") as verify_blob: + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + self.assertEqual(run_json.call_count, 3) + self.assertEqual(verify_blob.call_count, 6) + + def test_remote_verifier_rejects_wrong_workflow_or_event(self) -> None: + passed = self.passed_evidence() + baseline = { + "conclusion": "success", + "event": "pull_request", + "headSha": "0123456789abcdef0123456789abcdef01234567", + "url": "https://github.com/OpenCoven/psyche/actions/runs/123456", + "workflowName": "CI", + } + for field, value in (("workflowName", "Other"), ("event", "push")): + completed = ( + subprocess.CompletedProcess([], 0, "", ""), + subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), + ) + with self.subTest(field=field), mock.patch.object( + self.checker.subprocess, "run", side_effect=completed + ), mock.patch.object(self.checker, "run_json", return_value={**baseline, field: value}), mock.patch.object( + self.checker, "verify_coven_blob" + ): + with self.assertRaisesRegex(self.checker.EvidenceError, "CI attestation"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_remote_verifier_rejects_wrong_rest_workflow_path_or_repository(self) -> None: + passed = self.passed_evidence() + view = { + "conclusion": "success", + "event": "pull_request", + "headSha": "0123456789abcdef0123456789abcdef01234567", + "url": "https://github.com/OpenCoven/psyche/actions/runs/123456", + "workflowName": "CI", + } + rest = { + "conclusion": "success", + "event": "pull_request", + "head_repository": {"full_name": "OpenCoven/psyche"}, + "head_sha": "0123456789abcdef0123456789abcdef01234567", + "html_url": "https://github.com/OpenCoven/psyche/actions/runs/123456", + "id": 123456, + "path": ".github/workflows/ci.yml", + "repository": {"full_name": "OpenCoven/psyche"}, + "status": "completed", + "workflow_id": 326408880, + } + mutations = ( + {**rest, "workflow_id": 1}, + {**rest, "path": ".github/workflows/other.yml"}, + {**rest, "repository": {"full_name": "fork/psyche"}}, + ) + for index, mutated in enumerate(mutations): + completed = ( + subprocess.CompletedProcess([], 0, "", ""), + subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), + ) + with self.subTest(index=index), mock.patch.object( + self.checker.subprocess, "run", side_effect=completed + ), mock.patch.object(self.checker, "run_json", side_effect=(view, mutated)), mock.patch.object( + self.checker, "verify_coven_blob" + ): + with self.assertRaisesRegex(self.checker.EvidenceError, "REST attestation"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_remote_verifier_rejects_inactive_workflow_metadata(self) -> None: + passed = self.passed_evidence() + view = { + "conclusion": "success", "event": "pull_request", + "headSha": "0123456789abcdef0123456789abcdef01234567", + "url": "https://github.com/OpenCoven/psyche/actions/runs/123456", "workflowName": "CI", + } + rest = { + "conclusion": "success", "event": "pull_request", + "head_repository": {"full_name": "OpenCoven/psyche"}, + "head_sha": "0123456789abcdef0123456789abcdef01234567", + "html_url": "https://github.com/OpenCoven/psyche/actions/runs/123456", "id": 123456, + "path": ".github/workflows/ci.yml", "repository": {"full_name": "OpenCoven/psyche"}, + "status": "completed", "workflow_id": 326408880, + } + workflow = {"id": 326408880, "name": "CI", "path": ".github/workflows/ci.yml", "state": "disabled_manually"} + completed = ( + subprocess.CompletedProcess([], 0, "", ""), + subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), + ) + with mock.patch.object(self.checker.subprocess, "run", side_effect=completed), mock.patch.object( + self.checker, "run_json", side_effect=(view, rest, workflow) + ), mock.patch.object(self.checker, "verify_coven_blob"): + with self.assertRaisesRegex(self.checker.EvidenceError, "workflow metadata"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_architecture_lists_cli_direct_dependencies(self) -> None: + architecture = (ROOT / "docs/ARCHITECTURE.md").read_text() + self.assertIn("psyche-config <- psyche-cli", architecture) + self.assertIn("psyche-store <- psyche-cli", architecture) + self.assertIn("Runtime owns opening the store during startup.", architecture) + self.assertIn("The CLI uses its direct store dependency only for doctor data-directory preparation.", architecture) + self.assertNotIn("CLI is its outer process boundary and also reads\nconfiguration and opens the store", architecture) + + def test_c_s10_requires_every_content_reference_field(self) -> None: + path = "crates/psyche-coven/tests/fixtures/result-bundle.json" + fixture = json.loads((ROOT / path).read_text()) + for section in ("result", "artifacts"): + for field in ("digest", "media_type", "size_bytes", "expires_at"): + mutated = copy.deepcopy(fixture) + target = mutated[section] if section == "result" else mutated[section][0]["content"] + target.pop(field) + with self.subTest(section=section, field=field): + self.assert_rejected(overrides={path: json.dumps(mutated, separators=(",", ":"))}) + + def test_record_kind_identity_mutations_are_rejected(self) -> None: + path = "crates/psyche-core/src/contracts/mod.rs" + source = (ROOT / path).read_text() + mutations = ( + source.replace(" Attempt,\n", " Attempt,\n ExecutionBinding,\n", 1), + source.replace("SchemaKind::ExecutionBinding => Some(RecordKind::Attempt)", "SchemaKind::ExecutionBinding => Some(RecordKind::Session)", 1), + source.replace('RecordKind::Intent => "int_"', 'RecordKind::Intent => "att_"', 1), + ) + for index, mutation in enumerate(mutations): + with self.subTest(index=index): + self.assert_rejected(overrides={path: mutation}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/check-g2-evidence.py b/scripts/check-g2-evidence.py new file mode 100644 index 0000000..32fd16c --- /dev/null +++ b/scripts/check-g2-evidence.py @@ -0,0 +1,890 @@ +#!/usr/bin/env python3 +"""Verify that G2 source, tests, CI, and review evidence remain connected.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import pathlib +import re +import subprocess +import sys +import urllib.parse +from collections.abc import Mapping + + +class EvidenceError(RuntimeError): + """A G2 evidence relationship is absent or inconsistent.""" + + +FIXED_SPEC_COMMIT = "42dcbc4334cb48ecaf63efb550345e3eea2fb7ad" +APPROVED_PLAN_COMMIT = "5f22ebef1e23d045a10f2ec0a3c87be029446cf6" +APPROVED_PLAN_SHA256 = "4fba002ad9f969cd01866ea08f270654f82b53c7d90b73d28643a9abb12cba68" +PLAN_PATH = "docs/superpowers/plans/2026-08-05-psyche-w2-g2-foundation.md" +SOURCE_ROWS = { + "PLAN": ( + "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/PLAN.md", + "01382f8a0d2bca95ddd535634dd6a9f09ac4a80d588ccbebd72f163eaf56bc1e", + ), + "RUNTIME_DESIGN": ( + "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/RUNTIME_DESIGN.md", + "ab8c922214b8f1179ebf71fb8dfb55bd6d0ff2d6dfced4551bf90503767bb6b8", + ), + "TECH": ( + "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/TECH.md", + "1d00fb2b725f384ca027db60d0afbd0a62a7ec6c7dcbb5637bf14d30d40e2e1c", + ), + "COVEN_PREREQUISITES": ( + "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/COVEN_PREREQUISITES.md", + "33994a28921e70f824b0260ce08231b2117c50430c54e996ed47582d060e72f9", + ), + "COVEN_W1_AUDIT": ( + "https://github.com/OpenCoven/coven/blob/42dcbc43%334cb48ec%61f63efb5%350345e3e%65a2fb7ad/specs/psyche/COVEN_W1_AUDIT.md", + "eab9028bf7ef9c8a96d4c6bed69e4ef0b3497b470ca26589cb3ffcd80677322d", + ), +} + +# The commands are plan-owned literals. Hashing keeps this checker readable +# while still rejecting any byte of drift in a command cell. +MATRIX_COMMAND_SHA256 = { + "Canonical ID prefixes and execution-binding identity": "99555c557ce768a2691ea7a7e9e238d73596f0d6d302152fd128e7e2c1f55356", + "Complete canonical error enum": "8f65d986781fb10422a0261e17138201e7b3379ef8c0a09defab7be815dd4bee", + "Canonical delivery v1 shape": "832a79d4126a7cb77b8e0b0341674ca7348ef356091dab02257ccd390a69a384", + "Surface and quarantine owned types": "c5915caab82a15c37e3af5ddc41ec948043daf158fe78c4decc8ed677a17f1f9", + "Package-local nullable-binding fixtures": "2ac01fe93e9fb2580b65464cefaf2a1e942c2240048130d24597688e7d7b8d7a", + "Exhaustive registered decode": "7f744575d04e26cdc6a798d0e315371fcf9bf1b598c4aca9e8052da9d34cd6aa", + "Unknown kind/version/enum denial and quarantine": "ec7296a6b7fb26e7df19fb2c7147bb6225f45c279937b02046110773e468427e", + "Quarantine resolution": "0529a41bff3a7533173055d8d015d1d8a46ae922b0772c8f19fd0bde752bb5d3", + "Direct typed insert validation": "77f4e53c38d571775bf74280f50ea56ce8f802296d1e075425417e269210f5b4", + "Append-only execution-binding revisions": "a3823e0c95245f67996706bc37171569e9369e72166a4b42643da4c90deef71b", + "Transition contract and append-only rules": "65a4b8ac3d7e29f39d2d896ad222477576830b7a2829b41b5c147f0118dde3be", + "Checkpoint-failure shutdown": "979c2261ae0b36b6a1ab5bd34c70a5ec253c5f3120f87876fe33835d18207a69", + "Migrations": "f6d8857ebfc786480c20c2d693ed6cd4398bb5ad5979bb6454d15564d3532580", + "State-machine/property": "ec8c6e799d722b7a382d019853a8405949b464f44f68fb9c6fdb097a789b73bd", + "Crash/restart": "88e020450df496ffb6d915efacb4fc640a2bc325f023ac6332870ec847b71d16", + "Fake boundaries and durable termination ordering": "71355a2738db952c35c0fc18734eb80287602dbf0c10e2aabc96293c05ed9b15", + "Execution request RFC3339 golden bytes": "5315ed856e8dd813132a338a8024779707d8251efd0bdf71b92c2eae57c3a6e1", + "Validated termination dispatch": "68d7ac817187f977be07ffa7668a340222eba600a116380e46ba2b2cd43a67c1", + "G2 cancellation-state vocabulary": "b1822ccbdbd9cb0488227c16b94fd9ad75809c479bbfb01c3b9b8dba08b87caa", + "Full execution-request digest binding": "1ae9e298ddfd97efbd0b673655fbc90fbd78ef650f75fb20a4aaa803d8e166ff", + "C-S1 scripted contract negotiation": "0a0749461006ab5af3c1efd18cbff9b03b05e55045894153ce081160de337a83", + "C-S2 scripted session lifecycle": "19ee8df21953e783e4fa7ac82cee53728deb944ee333a19646b0b4b52cda80b6", + "C-S3 scripted snapshot/attempt binding": "02b6ecde06ab9bb935b81f7d1c003058ed7519ae272b139f1e82e10664510c3c", + "C-S4 scripted stable adoption": "7fa24fdf7b9465a457d33aa1c61c00b6372de8509b4142dbe109b9e6bdc9eb5c", + "C-S5 scripted non-adoption proof": "d25d4488d7d4d8832dca0ac72eec5fbaf09fbb870ff9ff035aeec21b6c039b55", + "C-S6 scripted ambiguity reconciliation/fence": "228e9790d023509f64a3871dfd58c2fd9b9afb4c1c18efa8e827d4a07f93dd84", + "C-S7 scripted ordered cursor": "220c990305d5ab9f080aafbab8b5224da1fc67e29f3a5f5a3d5cf141365675a8", + "C-S8 scripted terminal authority": "5e3223be4f8d8280792509a78661ae892691c6a0513e71fe35ea6752bbb86dc9", + "C-S9 scripted O5 cancellation acknowledgement": "fe4d01e7088f7376df97614522b4eebe3ee3cb7493d216c748d30bc2bf99b26a", + "C-S10 scripted result/artifact binding": "6bcfe3d642acc1a0c258cbaa3ddefd2257bba8a8d8b6e2251d93ce8d07366af3", + "C-S11 scripted restart persistence": "24866d81ddab9d0131cef455721cd2aee7f06fe61dd0320420bdf2617c128860", + "C-S12 scripted structured denial": "3e38ff6947e6e91504727847c5adae7cd3471f998d90c3980b67f520ce955a4a", +} + +CI_COMMANDS = ( + "cargo fmt --all -- --check", + "cargo clippy --workspace --all-targets -- -D warnings", + "cargo test --workspace --locked", + "cargo test -p psyche-test-support --test state_machine", + "cargo test -p psyche-test-support --test conformance", + "cargo test -p psyche-store --test migrations", + "cargo test -p psyche-store --features test-fault-injection --test crash", + "cargo clippy -p psyche-store --all-targets --features test-fault-injection -- -D warnings", + "cargo deny check licenses advisories bans sources", + 'gitleaks detect --no-banner --redact --log-opts="--all"', + "python3 scripts/check-g2-evidence-test.py", + "python3 scripts/check-g2-evidence.py", +) +NON_RUST_CI_COMMAND_JOBS = { + "cargo deny check licenses advisories bans sources": "supply-chain", + 'gitleaks detect --no-banner --redact --log-opts="--all"': "secrets", +} +# 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" +CI_WORKFLOW_ID = 326408880 + + +def fail(message: str) -> None: + raise EvidenceError(message) + + +def read_text(root: pathlib.Path, path: str, overrides: Mapping[str, str]) -> str: + if path in overrides: + return overrides[path] + candidate = root / path + if not candidate.is_file(): + fail(f"required file is absent: {path}") + return candidate.read_text(encoding="utf-8") + + +def parse_tables(markdown: str) -> tuple[list[list[str]], list[list[str]]]: + source: list[list[str]] = [] + matrix: list[list[str]] = [] + active: list[list[str]] | None = None + for line in markdown.splitlines(): + if line == "| Coven source | Immutable URL | SHA-256 |": + active = source + continue + if line == "| Criterion | Command | Result | Artifact |": + active = matrix + continue + if active is not None and line.startswith("|---"): + continue + if active is not None and line.startswith("|"): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if not all(cells): + fail("evidence table contains an empty cell") + active.append(cells) + elif active is not None: + active = None + if any(len(row) != 3 for row in source) or any(len(row) != 4 for row in matrix): + fail("evidence table has an invalid column count") + return source, matrix + + +def field(markdown: str, label: str) -> str: + matches = re.findall(rf"^\*\*{re.escape(label)}:\*\* (.+)$", markdown, re.MULTILINE) + if not matches: + fail(f"missing evidence field: {label}") + if len(matches) != 1: + fail(f"evidence field must occur exactly once: {label}") + return matches[0].strip().strip("`") + + +def yaml_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def parse_workflow_steps(workflow: str) -> list[dict[str, object]]: + """Extract active named/uses steps without treating YAML comments as data.""" + lines = workflow.splitlines() + try: + jobs_at = lines.index("jobs:") + except ValueError: + fail("workflow jobs mapping is absent") + job_starts = [ + (index, match.group("job")) + for index, line in enumerate(lines[jobs_at + 1:], start=jobs_at + 1) + if (match := re.fullmatch(r" (?P[A-Za-z0-9_-]+):\s*", line)) + ] + starts = [ + (index, len(match.group("indent")), match.group("key")) + for index, line in enumerate(lines) + if (match := re.match(r"^(?P\s*)-\s+(?Pname|uses):", line)) + ] + steps: list[dict[str, object]] = [] + for position, (start, indent, header_key) in enumerate(starts): + owners = [(index, job) for index, job in job_starts if index < start] + if not owners: + fail("workflow step is not contained by a job") + owner_at, owner = owners[-1] + end = next((index for index, _ in job_starts if index > owner_at), len(lines)) + for candidate, candidate_indent, _ in starts[position + 1:]: + if candidate < end and candidate_indent == indent: + end = candidate + break + child = " " * (indent + 2) + grandchild = " " * (indent + 4) + run_values: list[str] = [] + env: dict[str, str] = {} + env_counts: dict[str, int] = {} + direct_counts = {header_key: 1} + invalid = False + in_env = False + for line in lines[start + 1:end]: + if not line.strip() or line.lstrip().startswith("#"): + continue + line_indent = len(line) - len(line.lstrip()) + if in_env and line_indent == indent + 4: + match = re.fullmatch(r"(?P[A-Za-z_][A-Za-z0-9_-]*):(?P.*)", line[len(grandchild):]) + if not match: + invalid = True + continue + key = match.group("key") + env_counts[key] = env_counts.get(key, 0) + 1 + env[key] = yaml_scalar(match.group("value")) + continue + if line_indent == indent + 2: + in_env = False + match = re.fullmatch(r"(?P[A-Za-z_][A-Za-z0-9_-]*):(?P.*)", line[len(child):]) + if not match: + invalid = True + continue + key = match.group("key") + value = match.group("value") + direct_counts[key] = direct_counts.get(key, 0) + 1 + if key == "env": + if value.strip(): + invalid = True + else: + in_env = True + elif key == "run": + run_values.append(yaml_scalar(value)) + continue + if line_indent <= indent + 2: + in_env = False + if len(run_values) > 1: + fail("workflow step contains more than one active run value") + steps.append({ + "run": run_values[0] if run_values else None, + "env": env, + "env_counts": env_counts, + "direct_counts": direct_counts, + "invalid": invalid, + "job": owner, + }) + return steps + + +def workflow_job_lines(workflow: str, job: str) -> list[str]: + lines = workflow.splitlines() + job_pattern = re.compile(rf"^ {re.escape(job)}:\s*$") + starts = [index for index, line in enumerate(lines) if job_pattern.fullmatch(line)] + if len(starts) != 1: + fail(f"workflow must contain exactly one {job} job") + start = starts[0] + end = len(lines) + for index in range(start + 1, len(lines)): + if re.match(r"^ [A-Za-z0-9_-]+:\s*$", lines[index]): + end = index + break + return lines[start + 1:end] + + +def mapping_values(lines: list[str], indent: int) -> dict[str, list[str]]: + values: dict[str, list[str]] = {} + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue + if len(line) - len(line.lstrip()) == indent: + match = re.fullmatch(r"(?P[A-Za-z_][A-Za-z0-9_-]*):(?P.*)", line[indent:]) + if not match: + fail(f"workflow contains a noncanonical key at indentation {indent}: {line.strip()}") + values.setdefault(match.group("key"), []).append(yaml_scalar(match.group("value"))) + return values + + +def nested_mapping_lines(lines: list[str], indent: int, key: str) -> list[str]: + header = " " * indent + key + ":" + starts = [index for index, line in enumerate(lines) if line == header] + if len(starts) != 1: + fail(f"workflow mapping must contain exactly one active {key}") + start = starts[0] + end = len(lines) + for index in range(start + 1, len(lines)): + line = lines[index] + if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent: + end = index + break + return lines[start + 1:end] + + +def validate_workflow_scope(workflow: str) -> None: + lines = workflow.splitlines() + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue + if re.search(r"(?:^|[\s:\[\{,])(?:&|\*)[A-Za-z_][A-Za-z0-9_-]*", line) or re.match(r"^\s*<<\s*:", line): + fail("workflow anchors, aliases, and merge keys are not allowed") + root = mapping_values(lines, 0) + expected_root = {"name", "on", "concurrency", "env", "jobs"} + if set(root) != expected_root or any(len(values) != 1 for values in root.values()): + fail("workflow root must use the exact canonical CI structure") + if root["on"] != [""]: + fail("workflow triggers must use the canonical block mapping") + triggers = nested_mapping_lines(lines, 0, "on") + trigger_values = mapping_values(triggers, 2) + if trigger_values != {"push": [""], "pull_request": [""]}: + fail("workflow must run only for main pushes and pull requests") + push = mapping_values(nested_mapping_lines(triggers, 2, "push"), 4) + if push != {"branches": ["[main]"]}: + fail("workflow push trigger must target exactly main") + if mapping_values(nested_mapping_lines(triggers, 2, "pull_request"), 4): + fail("workflow pull_request trigger must be unqualified") + global_env = mapping_values(nested_mapping_lines(lines, 0, "env"), 2) + if global_env != {"CARGO_TERM_COLOR": ["always"], "RUSTFLAGS": ["-D warnings"]}: + fail("workflow global env must contain only fixed non-overriding values") + + +def validate_required_job_shapes(workflow: str) -> None: + expected = { + "rust": {"name", "runs-on", "strategy", "steps"}, + "supply-chain": {"name", "runs-on", "steps"}, + "secrets": {"name", "runs-on", "steps"}, + } + for job, keys in expected.items(): + values = mapping_values(workflow_job_lines(workflow, job), 4) + if set(values) != keys or any(len(entries) != 1 for entries in values.values()): + fail(f"CI required-command job {job} must use its exact canonical direct keys") + + +def validate_rust_matrix(workflow: str) -> None: + rust = workflow_job_lines(workflow, "rust") + direct = mapping_values(rust, 4) + if direct.get("runs-on") != ["${{ matrix.os }}"]: + fail("CI rust job must run on the active matrix.os value") + strategy = nested_mapping_lines(rust, 4, "strategy") + strategy_values = mapping_values(strategy, 6) + if strategy_values.get("fail-fast") != ["false"]: + fail("CI rust strategy must actively set fail-fast to false") + matrix = nested_mapping_lines(strategy, 6, "matrix") + matrix_values = mapping_values(matrix, 8) + if set(matrix_values) != {"os"} or len(matrix_values["os"]) != 1: + fail("CI rust matrix must contain only the supported os axis") + os_value = matrix_values["os"][0] + if not (os_value.startswith("[") and os_value.endswith("]")): + fail("CI rust matrix os axis must be an inline list") + systems = [yaml_scalar(item) for item in os_value[1:-1].split(",") if item.strip()] + expected = {"ubuntu-latest", "macos-latest", "windows-latest"} + if len(systems) != 3 or set(systems) != expected: + fail("CI rust matrix must cover exactly ubuntu, macOS, and Windows") + + +def validate_ci_structure(workflow: str) -> None: + validate_workflow_scope(workflow) + validate_required_job_shapes(workflow) + steps = parse_workflow_steps(workflow) + env_requirements = { + "cargo test -p psyche-test-support --test state_machine": { + "PROPTEST_CASES": "2048", + "PROPTEST_RNG_SEED": "0" * 32, + }, + "python3 scripts/check-g2-evidence.py": {"GH_TOKEN": "${{ github.token }}"}, + } + for command in CI_COMMANDS: + matching = [step for step in steps if step["run"] == command] + if len(matching) != 1: + fail(f"CI workflow must run exact G2 command once: {command}") + expected_job = NON_RUST_CI_COMMAND_JOBS.get(command, "rust") + if matching[0]["job"] != expected_job: + fail(f"CI workflow runs required command outside {expected_job}: {command}") + step = matching[0] + required_env = env_requirements.get(command, {}) + required_direct = {"name": 1, "run": 1} + if required_env: + required_direct["env"] = 1 + if step["invalid"] or step["direct_counts"] != required_direct: + fail(f"CI required command step has noncanonical direct keys: {command}") + if step["env"] != required_env or step["env_counts"] != {key: 1 for key in required_env}: + fail(f"CI required command step has noncanonical env: {command}") + validate_rust_matrix(workflow) + + +def validate_ci_workflow(workflow: str) -> None: + normalized = workflow.replace("\r\n", "\n").replace("\r", "\n") + if hashlib.sha256(normalized.encode("utf-8")).hexdigest() != REVIEWED_WORKFLOW_SHA256: + fail("CI workflow differs from the complete reviewed workflow") + validate_ci_structure(normalized) + + +def normalize_grouped(value: str, prefix: str = "") -> str: + value = value.removeprefix(prefix).replace("-", "") + if not re.fullmatch(r"[0-9a-f]+", value): + fail(f"invalid hexadecimal value: {value}") + return value + + +def parse_blob_url(url: str) -> tuple[str, str]: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.netloc != "github.com": + fail(f"Coven source URL is not immutable HTTPS: {url}") + match = re.fullmatch(r"/OpenCoven/coven/blob/([^/]+)/(.+)", parsed.path) + if not match: + fail(f"Coven source URL is not an OpenCoven/coven blob URL: {url}") + commit = urllib.parse.unquote(match.group(1)).replace("-", "") + path = urllib.parse.unquote(match.group(2)) + if not re.fullmatch(r"[0-9a-f]{40}", commit) or path.startswith("/") or ".." in pathlib.PurePosixPath(path).parts: + fail(f"Coven source URL does not name a 40-hex commit and safe path: {url}") + return commit, path + + +def validate_evidence(markdown: str) -> tuple[str, list[list[str]], list[list[str]]]: + status = field(markdown, "Status") + if status not in {"candidate", "passed"}: + fail("evidence status must be candidate or passed") + source, matrix = parse_tables(markdown) + source_names = [row[0] for row in source] + if len(source_names) != len(set(source_names)) or set(source_names) != set(SOURCE_ROWS): + fail("Coven source table must contain each fixed source exactly once") + for name, url_cell, digest_cell in source: + url = url_cell.strip("`") + digest = normalize_grouped(digest_cell.strip("`"), "sha256:") + if (url, digest) != SOURCE_ROWS[name]: + fail(f"immutable Coven source row drifted: {name}") + commit, _ = parse_blob_url(url) + if commit != FIXED_SPEC_COMMIT: + fail(f"Coven specification source commit drifted: {name}") + if normalize_grouped(field(markdown, "Coven specification source commit")) != FIXED_SPEC_COMMIT: + fail("Coven specification source field drifted") + + criteria = [row[0] for row in matrix] + if len(criteria) != len(set(criteria)) or set(criteria) != set(MATRIX_COMMAND_SHA256): + fail("evidence matrix must contain every required criterion exactly once") + for criterion, command_cell, result, artifact in matrix: + if not (command_cell.startswith("`") and command_cell.endswith("`")): + fail(f"matrix command is not a code literal: {criterion}") + command = command_cell[1:-1] + if hashlib.sha256(command.encode()).hexdigest() != MATRIX_COMMAND_SHA256[criterion]: + fail(f"matrix command differs from the plan allowlist: {criterion}") + for atomic in command.split(" && "): + if not re.search(r" -- --exact [A-Za-z0-9_:]+$", atomic): + fail(f"matrix command is not an exact filtered test: {atomic}") + + if status == "candidate": + expected = { + "Tested source commit": "not recorded before remote review", + "CI attestation": "not recorded before remote review", + "Coven plan source commit": "not recorded before plan approval", + "Coven plan URL": "not recorded before plan approval", + "Coven plan SHA-256": "not recorded before plan approval", + } + for label, value in expected.items(): + if field(markdown, label) != value: + fail(f"candidate field must use its exact placeholder: {label}") + if any(row[2:] != ["not run remotely", "none"] for row in matrix): + fail("candidate matrix must use the exact result and artifact placeholders") + else: + tested = field(markdown, "Tested source commit") + run_url = field(markdown, "CI attestation") + plan_commit = normalize_grouped(field(markdown, "Coven plan source commit")) + plan_url = field(markdown, "Coven plan URL") + plan_digest = normalize_grouped(field(markdown, "Coven plan SHA-256"), "sha256:") + if not re.fullmatch(r"[0-9a-f]{40}", tested): + fail("passed evidence requires a 40-hex tested source") + if plan_commit != APPROVED_PLAN_COMMIT or plan_digest != APPROVED_PLAN_SHA256: + fail("passed evidence does not name the approved Coven plan provenance") + url_commit, url_path = parse_blob_url(plan_url) + if url_commit != plan_commit or url_path != PLAN_PATH: + fail("passed evidence plan URL does not match the approved plan") + if not re.fullmatch(r"https://github\.com/OpenCoven/psyche/actions/runs/[0-9]+", run_url): + fail("passed evidence requires an immutable Actions run URL") + if any(row[2] != "passed" or row[3] != run_url for row in matrix): + fail("every passed matrix result and artifact must attest the same run") + placeholders = re.compile(r"not run remotely|\bnone\b|not recorded|pending|placeholder|TBD|TODO", re.IGNORECASE) + if placeholders.search(markdown): + fail("passed evidence contains a candidate placeholder") + return status, source, matrix + + +def manifest_atomic_commands(manifest: Mapping[str, object]) -> tuple[dict[str, tuple[str, str]], dict[str, str]]: + targets = manifest.get("targets") + if not isinstance(targets, dict) or not targets: + fail("manifest.targets must be a non-empty object") + atomic: dict[str, tuple[str, str]] = {} + lists: dict[str, str] = {} + for target, raw in targets.items(): + if not isinstance(target, str) or not isinstance(raw, dict): + fail("manifest target entries must be objects") + list_command = raw.get("list_command") + tests = raw.get("tests") + if not isinstance(list_command, str) or not list_command.endswith(" -- --list --format terse"): + fail(f"invalid list command for {target}") + if not isinstance(tests, list) or not tests or any(not isinstance(name, str) or not name for name in tests): + fail(f"manifest target has no exact tests: {target}") + if len(tests) != len(set(tests)): + fail(f"manifest target repeats a test: {target}") + prefix = list_command.removesuffix(" -- --list --format terse") + lists[target] = list_command + for name in tests: + command = f"{prefix} -- --exact {name}" + if command in atomic: + fail(f"manifest maps an atomic command more than once: {command}") + atomic[command] = (target, name) + return atomic, lists + + +def list_tests(root: pathlib.Path, commands: Mapping[str, str]) -> dict[str, str]: + outputs: dict[str, str] = {} + for target, command in commands.items(): + completed = subprocess.run(command.split(), cwd=root, text=True, capture_output=True, check=False) + if completed.returncode: + fail(f"test listing failed for {target}:\n{completed.stdout}{completed.stderr}") + outputs[target] = completed.stdout + return outputs + + +def validate_manifest( + root: pathlib.Path, + manifest: Mapping[str, object], + matrix: list[list[str]], + listed_tests: Mapping[str, str] | None, +) -> None: + atomic_manifest, list_commands = manifest_atomic_commands(manifest) + matrix_commands: list[str] = [] + for row in matrix: + matrix_commands.extend(row[1][1:-1].split(" && ")) + if len(matrix_commands) != len(set(matrix_commands)): + fail("an atomic matrix command is duplicated") + if set(matrix_commands) != set(atomic_manifest): + missing = sorted(set(matrix_commands) - set(atomic_manifest)) + unused = sorted(set(atomic_manifest) - set(matrix_commands)) + fail(f"matrix/manifest mismatch; missing={missing}, unused={unused}") + outputs = dict(listed_tests) if listed_tests is not None else list_tests(root, list_commands) + if set(outputs) != set(list_commands): + fail("test listing output does not cover exactly the manifest targets") + for target, output in outputs.items(): + names = { + line.rsplit(": test", 1)[0] + for line in output.splitlines() + if line.endswith(": test") + } + if not names: + fail(f"test target lists zero tests: {target}") + for name in manifest["targets"][target]["tests"]: # type: ignore[index] + if name not in names: + fail(f"exact manifest test is absent from {target}: {name}") + + +def require_terms(path: str, text: str, terms: tuple[str, ...]) -> None: + missing = [term for term in terms if term not in text] + if missing: + fail(f"{path} is missing required relationships: {missing}") + + +def require_digest_mutation_matrix(path: str, text: str) -> None: + common = ( + "schema_version", "request_id", "graph_id", "node_id", "attempt_id", + "principal_id", "familiar_snapshot_id", "project_id", + "context_manifest_digest", "required_artifact_bindings", "payload_digest", + "created_at", "valid_until", + ) + launch = common + ("project_root", "cwd", "harness", "delegation_digest", "budget_digest") + input_request = common + ("session_id", "input_digest") + artifact = ("artifact_id", "digest", "media_type", "size") + for name in launch + input_request: + if f'"/input/{name}"' not in text: + fail(f"{path} does not stale-digest mutate request field: {name}") + for name in artifact: + if f'"/input/required_artifact_bindings/0/{name}"' not in text: + fail(f"{path} does not stale-digest mutate artifact field: {name}") + if 'mutations.push(("/input", other_input))' not in text: + fail(f"{path} does not stale-digest mutate the request variant") + + +def validate_record_kinds(source: str) -> None: + enum = re.search(r"pub enum RecordKind \{(.*?)\n\}", source, re.DOTALL) + if not enum: + fail("RecordKind declaration is absent") + variants = re.findall(r"^\s{4}([A-Z][A-Za-z0-9]+),$", enum.group(1), re.MULTILINE) + if len(variants) != 15 or "Attempt" not in variants or "ExecutionBinding" in variants: + fail(f"RecordKind must have exactly 15 variants with Attempt only: {variants}") + all_block = re.search(r"pub const ALL: \[RecordKind; 15\] = \[(.*?)\];", source, re.DOTALL) + prefixes = re.search(r"pub const fn prefix\(self\).*?match self \{(.*?)\n\s*\}", source, re.DOTALL) + if not all_block or re.findall(r"RecordKind::([A-Za-z0-9]+)", all_block.group(1)) != variants: + fail("RecordKind::ALL is not exhaustive and declaration-ordered") + if not prefixes: + fail("RecordKind prefix match is absent") + pairs = re.findall(r'RecordKind::([A-Za-z0-9]+) => "([a-z]{3}_)"', prefixes.group(1)) + if [name for name, _ in pairs] != variants: + fail("RecordKind prefix match does not use the exact variant set") + if dict(pairs).get("Attempt") != "att_" or sum(prefix == "att_" for _, prefix in pairs) != 1: + fail("Attempt must be the only att_ record kind") + if source.count("SchemaKind::ExecutionBinding => Some(RecordKind::Attempt)") != 1: + fail("ExecutionBinding must map exactly once to RecordKind::Attempt") + + +def validate_result_fixture(text: str) -> None: + try: + bundle = json.loads(text) + except json.JSONDecodeError as error: + fail(f"result-bundle fixture is invalid JSON: {error}") + if set(bundle) != {"artifacts", "correlation", "result", "session_id"}: + fail("result-bundle fixture is not strict and complete") + if not isinstance(bundle["artifacts"], list) or not bundle["artifacts"]: + fail("result-bundle fixture has no artifact") + references = [bundle["result"]] + [artifact.get("content", {}) for artifact in bundle["artifacts"]] + for reference in references: + if set(reference) != {"digest", "expires_at", "media_type", "size_bytes"}: + fail("every result/artifact content reference needs digest, media_type, size_bytes, expires_at") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(reference["digest"])): + fail("content reference digest is not canonical") + if not isinstance(reference["size_bytes"], int) or reference["size_bytes"] <= 0: + fail("content reference size is invalid") + if not re.fullmatch(r"[^/\s]+/[^/\s]+", str(reference["media_type"])): + fail("content reference media type is invalid") + if not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", str(reference["expires_at"])): + fail("content reference expiry is not canonical RFC3339 UTC") + correlation = bundle["correlation"] + for artifact in bundle["artifacts"]: + if artifact.get("correlation") != correlation or artifact.get("session_id") != bundle["session_id"]: + fail("artifact lifetime/correlation does not match its result bundle") + + +def validate_golden(path: str, raw: bytes, expected_sha: str) -> None: + if raw.endswith(b"\n"): + fail(f"golden fixture has a trailing newline: {path}") + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + fail(f"golden fixture is invalid JSON: {path}: {error}") + for key in ("created_at", "valid_until"): + if not isinstance(value.get(key), str) or not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", value[key]): + fail(f"golden fixture lacks an RFC3339 string {key}: {path}") + canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + if canonical != raw: + fail(f"golden fixture is not canonical JSON: {path}") + if hashlib.sha256(raw).hexdigest() != expected_sha: + fail(f"golden fixture SHA-256 drifted: {path}") + + +def validate_sources(root: pathlib.Path, overrides: Mapping[str, str]) -> None: + port_path = "crates/psyche-coven/src/port.rs" + port = read_text(root, port_path, overrides) + require_terms(port_path, port, ( + "pub fn new(input: ExecutionRequestInput)", "let request_digest = digest(&input)?;", + "pub fn recompute_digest", "pub trait CovenPort", "async fn reconcile(", + "pub struct ResultBundle", "CancellationAcknowledgementEvidence", + )) + if re.search(r"(?:struct|enum)\s+\w*Acknowledgement\w*", port): + fail("psyche-coven owns a duplicate acknowledgement wire type") + + suite_path = "crates/psyche-test-support/src/suites/coven.rs" + suite = read_text(root, suite_path, overrides) + for number in range(1, 13): + if suite.count(f"pub async fn assert_c_s{number}_") != 1: + fail(f"reusable C-S{number} function is absent or duplicated") + require_terms(suite_path, suite, ( + "stale_digest_mutations", "request.recompute_digest()", "RequestDigestMismatch", + "RAW_LEDGER_STATES", '"killed"', '"orphaned"', "CancellationAcknowledgementEvidence", + "assert_c_s6_ambiguity_fence", "ReconciliationDisposition::Returned", + "ReconciliationDisposition::Fenced", "DurableDispositionKind::Returned", + "DurableDispositionKind::Fenced", "fixture.restart().await", "require_fault(", + "redispatch_eligibility", "EligibleAfterFence", "assert_c_s10_result_artifact_binding", + "mutate_result_digest", "mutate_result_media_type", "mutate_result_size", + "mutate_result_expiry", "mutate_artifact_digest", "mutate_artifact_media_type", + "mutate_artifact_size", "mutate_artifact_expiry", + )) + require_digest_mutation_matrix(suite_path, suite) + fake_path = "crates/psyche-test-support/src/coven.rs" + fake = read_text(root, fake_path, overrides) + require_terms(fake_path, fake, ("async fn adopt", "request.validate_digest()?;")) + if suite.count("request.validate_digest()?;") != 1: + fail("scripted Coven boundary must recompute the request digest exactly once") + conformance_path = "crates/psyche-test-support/tests/conformance.rs" + conformance = read_text(root, conformance_path, overrides) + for number in range(1, 13): + if len(re.findall(rf"async fn c_s{number}_[a-z0-9_]+\(\)", conformance)) != 1: + fail(f"exact C-S{number} wrapper is absent or duplicated") + + state_path = "crates/psyche-test-support/tests/state_machine.rs" + state = read_text(root, state_path, overrides) + require_terms(state_path, state, ( + "c_s6_model_never_redispatches_without_fence", "request_digest_binds_every_typed_field", + "stale_digest_requests", "MutateRequestFieldRetainDigest", "RequestDigestMismatch", + "ReconciliationDisposition::Returned", "ReconciliationDisposition::Fenced", + "fixture.restart().await", "select_fault(", "RedispatchEligibility::EligibleAfterFence", + )) + require_digest_mutation_matrix(state_path, state) + + core_path = "crates/psyche-core/src/contracts/mod.rs" + core = read_text(root, core_path, overrides) + validate_record_kinds(core) + require_terms(core_path, core, ( + "const ALL: [SchemaKind; 16]", "CanonicalDocument::ExecutionBinding", + "SchemaKind::Error => None", "UnknownSchema", "UnsupportedMajor", "UnknownEnumValue", + )) + error_path = "crates/psyche-core/src/contracts/error.rs" + require_terms(error_path, read_text(root, error_path, overrides), ("pub const ALL: [Self; 36]",)) + contracts_path = "crates/psyche-core/tests/contracts.rs" + require_terms(contracts_path, read_text(root, contracts_path, overrides), ( + "all_canonical_error_codes_decode", "delivery_v1_fixture_round_trips_canonically", + "surface_event_and_effect_fixtures_round_trip", "cancellation_state_vocabulary_requires_matching_o5_evidence", + "graph_and_node_accept_only_the_two_frozen_nullable_bindings", + )) + records_path = "crates/psyche-store/tests/records.rs" + require_terms(records_path, read_text(root, records_path, overrides), ( + "direct_insert_rejects_acknowledged_cancellation_without_evidence", + "CancellationAcknowledgementEvidence", "direct_insert_rejects_mismatched_cancellation_evidence", + )) + result_path = "crates/psyche-coven/tests/fixtures/result-bundle.json" + validate_result_fixture(read_text(root, result_path, overrides)) + bindings_path = "crates/psyche-coven/tests/bindings.rs" + require_terms(bindings_path, read_text(root, bindings_path, overrides), ( + "result_bundle_fixture_round_trips_complete_content_references", + "result_bundle_fixture_uses_launch_request_correlation", + "content_reference_rejects_digest_size_media_type_and_lifetime_mismatch", + '"/artifacts/0/correlation/request_digest"', '"/artifacts/0/correlation/valid_until"', + )) + request_test_path = "crates/psyche-coven/tests/request_digest.rs" + request_tests = read_text(root, request_test_path, overrides) + require_terms(request_test_path, request_tests, ( + "execution_request_launch_matches_golden_bytes_and_digest", + "execution_request_input_matches_golden_bytes_and_digest", + "75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04", + "c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61", + )) + for path, digest in ( + ("crates/psyche-coven/tests/fixtures/execution-request-launch.json", "75d651c5eb7f6e3ccd65631fce08afdcb8ac2a800bc0d8db55eaf9cf43519d04"), + ("crates/psyche-coven/tests/fixtures/execution-request-input.json", "c8c3d0cad99f65d0fdac7b2bb577cf1278412a7ea6255d443e45394109311c61"), + ): + raw = overrides[path].encode() if path in overrides else (root / path).read_bytes() + validate_golden(path, raw, digest) + + +def validate_docs(root: pathlib.Path, overrides: Mapping[str, str]) -> None: + architecture = read_text(root, "docs/ARCHITECTURE.md", overrides) + for line in ( + "psyche-core <- psyche-config", "psyche-core <- psyche-store", "psyche-core <- psyche-coven", + "psyche-core <- psyche-surfaces", + "psyche-core + psyche-coven + psyche-surfaces + psyche-store <- psyche-test-support", + "psyche-config + psyche-store <- psyche-runtime <- psyche-cli", + "psyche-config <- psyche-cli", "psyche-store <- psyche-cli", + ): + if architecture.count(line) != 1: + fail(f"architecture dependency direction is absent or duplicated: {line}") + schemas = read_text(root, "docs/SCHEMAS.md", overrides) + require_terms("docs/SCHEMAS.md", schemas, ( + "psyche.identity_snapshot.v1", "psyche.intent.v1", "psyche.surface_event.v1", "psyche.graph.v1", + "psyche.graph_node.v1", "psyche.delegation.v1", "psyche.budget.v1", "psyche.approval.v1", + "psyche.execution_binding.v1", "psyche.evidence.v1", "psyche.verdict.v1", "psyche.recovery.v1", + "psyche.addon.v1", "psyche.surface_effect.v1", "psyche.delivery.v1", "psyche.error.v1", + "unknown kind", "unknown major", "unknown enum", "Transition", "del_", "dlg_", "QuarantineId", + "qua_", "CancellationState", "CancellationAcknowledgementEvidence", "killed", "orphaned", + "ResultBundle", "digest", "media_type", "size_bytes", "expires_at", "Attempt", "att_", + "ExecutionBinding", "retention", "Deferred", + )) + testing = read_text(root, "docs/TESTING.md", overrides) + require_terms("docs/TESTING.md", testing, ( + "scripts", "PROPTEST_CASES", "PROPTEST_RNG_SEED", "crash", "fault", "observation", + "full-request digest", "RFC3339", "g2-test-manifest.json", "killed", "orphaned", + "Immutable Coven", "return", "fence", "restart", "no-redispatch", "ExpectedUnsupported", + ) + tuple(f"C-S{number}" for number in range(1, 13))) + + +def run_json(command: list[str], root: pathlib.Path) -> object: + completed = subprocess.run(command, cwd=root, text=True, capture_output=True, check=False) + if completed.returncode: + fail(f"command failed: {' '.join(command)}\n{completed.stdout}{completed.stderr}") + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as error: + fail(f"command did not return JSON: {' '.join(command)}: {error}") + + +def verify_coven_blob(root: pathlib.Path, url: str, expected_digest: str) -> None: + commit, path = parse_blob_url(url) + response = run_json(["gh", "api", f"repos/OpenCoven/coven/contents/{path}?ref={commit}"], root) + if not isinstance(response, dict) or response.get("type") != "file" or not response.get("sha"): + fail(f"Coven content API did not return a commit-owned blob: {path}") + try: + content = base64.b64decode(str(response["content"]), validate=False) + except (ValueError, TypeError) as error: + fail(f"Coven content API returned invalid base64: {path}: {error}") + if hashlib.sha256(content).hexdigest() != expected_digest: + fail(f"Coven content SHA-256 disagrees with evidence: {path}") + + +def verify_passed(root: pathlib.Path, markdown: str, source_rows: list[list[str]]) -> None: + tested = field(markdown, "Tested source commit") + run_url = field(markdown, "CI attestation") + subprocess.run(["git", "merge-base", "--is-ancestor", tested, "HEAD"], cwd=root, check=True) + changed = subprocess.run( + ["git", "diff", "--name-only", f"{tested}..HEAD"], cwd=root, text=True, capture_output=True, check=True + ).stdout.splitlines() + if changed != ["docs/G2-EVIDENCE.md"]: + fail(f"passed source-to-HEAD diff is not evidence-only: {changed}") + match = re.fullmatch(r"https://github\.com/(OpenCoven)/(psyche)/actions/runs/([0-9]+)", run_url) + if not match: + fail("CI attestation URL is malformed") + owner, repo, run_id = match.groups() + run = run_json( + ["gh", "run", "view", run_id, "--repo", f"{owner}/{repo}", "--json", "conclusion,event,headSha,url,workflowName"], + root, + ) + expected_run = { + "conclusion": "success", + "event": "pull_request", + "headSha": tested, + "url": run_url, + "workflowName": "CI", + } + if not isinstance(run, dict) or run != expected_run: + fail(f"CI attestation does not match the tested source: {run}") + rest = run_json(["gh", "api", f"repos/OpenCoven/psyche/actions/runs/{run_id}"], root) + expected_rest = { + "conclusion": "success", + "event": "pull_request", + "head_sha": tested, + "html_url": run_url, + "id": int(run_id), + "path": ".github/workflows/ci.yml", + "status": "completed", + "workflow_id": CI_WORKFLOW_ID, + } + if ( + not isinstance(rest, dict) + or any(rest.get(key) != value for key, value in expected_rest.items()) + or not isinstance(rest.get("repository"), dict) + or rest["repository"].get("full_name") != "OpenCoven/psyche" + or not isinstance(rest.get("head_repository"), dict) + or rest["head_repository"].get("full_name") != "OpenCoven/psyche" + ): + fail(f"CI REST attestation does not match the tested workflow run: {rest}") + workflow = run_json( + ["gh", "api", f"repos/OpenCoven/psyche/actions/workflows/{CI_WORKFLOW_ID}"], + root, + ) + expected_workflow = { + "id": CI_WORKFLOW_ID, + "name": "CI", + "path": ".github/workflows/ci.yml", + "state": "active", + } + if not isinstance(workflow, dict) or any(workflow.get(key) != value for key, value in expected_workflow.items()): + fail(f"CI workflow metadata is not the active reviewed workflow: {workflow}") + for _, url, digest in source_rows: + verify_coven_blob(root, url.strip("`"), normalize_grouped(digest.strip("`"), "sha256:")) + verify_coven_blob(root, field(markdown, "Coven plan URL"), APPROVED_PLAN_SHA256) + + +def validate_repository( + root: pathlib.Path, + *, + manifest: Mapping[str, object] | None = None, + evidence: str | None = None, + listed_tests: Mapping[str, str] | None = None, + source_overrides: Mapping[str, str] | None = None, + verify_remote: bool = True, +) -> None: + root = root.resolve() + overrides = source_overrides or {} + workflow = read_text(root, ".github/workflows/ci.yml", overrides) + validate_ci_workflow(workflow) + for path in ("crates/psyche-store/tests/migrations.rs", "crates/psyche-store/tests/crash.rs"): + if not (root / path).is_file(): + fail(f"store evidence target is absent: {path}") + + manifest_data = manifest + if manifest_data is None: + try: + manifest_data = json.loads(read_text(root, "scripts/g2-test-manifest.json", overrides)) + except json.JSONDecodeError as error: + fail(f"G2 manifest is invalid JSON: {error}") + evidence_text = evidence if evidence is not None else read_text(root, "docs/G2-EVIDENCE.md", overrides) + status, source_rows, matrix = validate_evidence(evidence_text) + validate_manifest(root, manifest_data, matrix, listed_tests) + validate_sources(root, overrides) + validate_docs(root, overrides) + if status == "passed" and verify_remote: + verify_passed(root, evidence_text, source_rows) + + +def main() -> int: + root = pathlib.Path(__file__).resolve().parents[1] + try: + validate_repository(root) + except (EvidenceError, subprocess.CalledProcessError, OSError) as error: + print(f"G2 evidence check failed: {error}", file=sys.stderr) + return 1 + print("G2 evidence relationships verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/g2-test-manifest.json b/scripts/g2-test-manifest.json new file mode 100644 index 0000000..4ad1c2a --- /dev/null +++ b/scripts/g2-test-manifest.json @@ -0,0 +1,136 @@ +{ + "targets": { + "psyche-core/contracts": { + "list_command": "cargo test -p psyche-core --test contracts -- --list --format terse", + "tests": [ + "delivery_keeps_the_canonical_del_prefix", + "delegation_uses_the_distinct_dlg_prefix", + "execution_binding_uses_attempt_as_its_only_record_kind", + "all_canonical_error_codes_decode", + "delivery_v1_fixture_round_trips_canonically", + "surface_event_and_effect_fixtures_round_trip", + "cancellation_state_vocabulary_requires_matching_o5_evidence", + "graph_and_node_accept_only_the_two_frozen_nullable_bindings" + ] + }, + "psyche-core/decode": { + "list_command": "cargo test -p psyche-core --test decode -- --list --format terse", + "tests": [ + "recognized_error_envelope_decodes_exhaustively", + "unknown_typed_enum_is_a_quarantinable_decode_failure" + ] + }, + "psyche-coven/request_digest": { + "list_command": "cargo test -p psyche-coven --test request_digest -- --list --format terse", + "tests": [ + "execution_request_launch_matches_golden_bytes_and_digest", + "execution_request_input_matches_golden_bytes_and_digest" + ] + }, + "psyche-coven/bindings": { + "list_command": "cargo test -p psyche-coven --test bindings -- --list --format terse", + "tests": [ + "result_bundle_fixture_round_trips_complete_content_references", + "result_bundle_fixture_uses_launch_request_correlation", + "content_reference_rejects_digest_size_media_type_and_lifetime_mismatch", + "termination_dispatch_rejects_invalid_request_before_persistence" + ] + }, + "psyche-store/records": { + "list_command": "cargo test -p psyche-store --test records -- --list --format terse", + "tests": [ + "delivery_direct_insert_round_trips_canonically", + "direct_insert_rejects_wrong_field_id_kind_without_writing", + "direct_insert_rejects_acknowledged_cancellation_without_evidence", + "direct_insert_rejects_acknowledged_state_without_termination_correlation", + "direct_insert_rejects_mismatched_cancellation_evidence", + "direct_insert_rejects_wrong_termination_request_id", + "direct_insert_rejects_termination_before_execution_request", + "direct_insert_rejects_acknowledgement_outside_termination_window", + "direct_insert_rejects_acknowledgement_before_termination_window", + "direct_insert_rejects_unresolved_outside_termination_window", + "direct_insert_rejects_unresolved_before_termination_window", + "direct_insert_accepts_acknowledgement_at_termination_window_boundaries", + "direct_insert_accepts_unresolved_at_termination_window_boundaries", + "direct_insert_accepts_termination_window_after_execution_deadline", + "direct_insert_accepts_termination_at_execution_creation_boundary", + "execution_binding_revision_appends_termination_outcomes_without_record_conflict", + "execution_binding_revision_rejects_forks_gaps_and_changed_correlation", + "execution_binding_revision_replay_is_idempotent", + "execution_binding_revision_rejects_same_revision_changed_bytes", + "execution_binding_revision_rejects_changed_reason_replay", + "execution_binding_revision_rejects_every_frozen_execution_field_change", + "execution_binding_revision_rejects_session_and_termination_rebinding", + "execution_binding_revision_rejects_termination_correlation_removal", + "execution_binding_revision_rejects_timestamp_regression", + "transition_versions_are_monotonic_and_append_only" + ] + }, + "psyche-store/retention": { + "list_command": "cargo test -p psyche-store --test retention -- --list --format terse", + "tests": [ + "quarantine_id_constructor_parser_and_serde_round_trip", + "unknown_enum_is_quarantined_without_dispatchable_record", + "quarantine_resolution_is_durable_and_idempotent", + "concurrent_quarantine_resolution_has_one_durable_winner", + "pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions" + ] + }, + "psyche-store/migrations": { + "list_command": "cargo test -p psyche-store --test migrations -- --list --format terse", + "tests": ["fresh_store_applies_v1_once_and_reopens"] + }, + "psyche-store/crash": { + "list_command": "cargo test -p psyche-store --features test-fault-injection --test crash -- --list --format terse", + "tests": ["killed_writer_exposes_only_committed_state_after_reopen"] + }, + "psyche-runtime/lib": { + "list_command": "cargo test -p psyche-runtime --lib -- --list --format terse", + "tests": ["tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter"] + }, + "psyche-test-support/fakes": { + "list_command": "cargo test -p psyche-test-support --test fakes -- --list --format terse", + "tests": [ + "advertised_adoption_requires_a_scripted_adoption_step", + "termination_dispatch_requires_durable_session_bound_revision", + "termination_dispatch_persists_acknowledged_outcome_before_success", + "termination_dispatch_persists_unresolved_outcome_before_success", + "termination_dispatch_exact_replay_is_idempotent", + "termination_dispatch_crash_after_response_leaves_recoverable_request", + "termination_dispatch_restart_recovers_missing_outcome", + "termination_dispatch_rejects_conflicting_replay_response", + "termination_dispatch_rejects_invalid_outcome_evidence", + "termination_dispatch_rejects_unresolved_outside_termination_window", + "termination_dispatch_reports_indeterminate_outcome_persistence", + "termination_dispatch_accepts_concurrent_exact_outcome_replay", + "termination_dispatch_rejects_concurrent_divergent_outcome", + "termination_dispatch_rejects_outcome_byte_attestation_mismatch" + ] + }, + "psyche-test-support/state_machine": { + "list_command": "cargo test -p psyche-test-support --test state_machine -- --list --format terse", + "tests": [ + "model_and_store_agree_after_any_foundation_operation_sequence", + "c_s6_model_never_redispatches_without_fence", + "request_digest_binds_every_typed_field" + ] + }, + "psyche-test-support/conformance": { + "list_command": "cargo test -p psyche-test-support --test conformance -- --list --format terse", + "tests": [ + "c_s1_contract_negotiation", + "c_s2_session_lifecycle", + "c_s3_snapshot_attempt_binding", + "c_s4_stable_adoption", + "c_s5_non_adoption_proof", + "c_s6_ambiguity_fence", + "c_s7_ordered_cursor", + "c_s8_terminal_authority", + "c_s9_cancellation_acknowledgement", + "c_s10_result_artifact_binding", + "c_s11_restart_persistence", + "c_s12_structured_denial" + ] + } + } +} From 31eba3d4308d6c98524f587449cf111f7e619327 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:29:09 -0500 Subject: [PATCH 57/66] fix: authenticate local store foundation --- Cargo.lock | 1 + Cargo.toml | 1 + crates/psyche-store/Cargo.toml | 3 + crates/psyche-store/src/connection.rs | 184 +++++++++++++++++++++++- crates/psyche-store/src/lib.rs | 1 + crates/psyche-store/src/migrations.rs | 95 +++++++++++- crates/psyche-store/tests/migrations.rs | 82 +++++++++++ 7 files changed, 364 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 216060c..1219c19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,7 @@ dependencies = [ "proptest", "psyche-core", "rusqlite", + "rustix", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 80f0d6e..52d3d2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ serde_json = { version = "1", features = ["arbitrary_precision"] } assert_cmd = "2" predicates = "3" tempfile = "3" +rustix = { version = "1.1.4", features = ["fs"] } # Shared lint policy. Declared at bootstrap because retrofitting it later means # editing every member manifest *and* clearing whatever backlog the new lints diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index bfee0ef..90163e4 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -16,6 +16,9 @@ thiserror = { workspace = true } time = { workspace = true } ulid = { workspace = true } +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true } + [dev-dependencies] proptest = { workspace = true } tempfile = { workspace = true } diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index 8d83943..b6d6611 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -32,12 +32,145 @@ pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), Store .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); + ensure_local_filesystem(parent)?; prepare_data_dir(parent)?; let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; Ok((open_path, state)) } +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn ensure_local_filesystem(path: &Path) -> Result<(), StoreError> { + let existing_ancestor = path + .ancestors() + .map(|candidate| { + if candidate.as_os_str().is_empty() { + Path::new(".") + } else { + candidate + } + }) + .find(|candidate| match fs::symlink_metadata(candidate) { + Ok(_) => true, + Err(error) if error.kind() == ErrorKind::NotFound => false, + Err(_) => true, + }) + .ok_or(StoreError::InvalidDatabasePath)?; + let statistics = rustix::fs::statfs(existing_ancestor) + .map_err(|error| StoreError::directory_operation(error.into()))?; + + if filesystem_is_network(&statistics) { + Err(StoreError::InvalidDatabasePath) + } else { + Ok(()) + } +} + +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" + )) +))] +fn ensure_local_filesystem(_path: &Path) -> Result<(), StoreError> { + Err(StoreError::InvalidDatabasePath) +} + +#[cfg(not(unix))] +fn ensure_local_filesystem(path: &Path) -> Result<(), StoreError> { + if path_is_network_share(path) { + Err(StoreError::InvalidDatabasePath) + } else { + Ok(()) + } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn filesystem_is_network(statistics: &rustix::fs::StatFs) -> bool { + filesystem_magic_is_network(statistics.f_type as u64) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn filesystem_magic_is_network(magic: u64) -> bool { + matches!( + magic, + 0x0000_6969 // NFS + | 0x0000_517b // SMB + | 0xff53_4d42 // CIFS + | 0x7375_7245 // Coda + | 0x5346_414f // AFS + | 0x0000_564c // NCP + | 0x00c3_6400 // Ceph + | 0x0102_1997 // 9P + | 0x6573_5546 // FUSE, including sshfs and other remote transports + ) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn filesystem_is_network(statistics: &rustix::fs::StatFs) -> bool { + let name = statistics + .f_fstypename + .iter() + .take_while(|character| **character != 0) + .map(|character| *character as u8) + .collect::>(); + match std::str::from_utf8(&name) { + Ok(name) => filesystem_name_is_network(name), + Err(_) => true, + } +} + +fn filesystem_name_is_network(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "nfs" + | "nfs4" + | "smb" + | "smbfs" + | "cifs" + | "afp" + | "afpfs" + | "webdav" + | "9p" + | "ceph" + | "sshfs" + | "fusefs" + | "osxfuse" + | "macfuse" + ) +} + +#[cfg(not(unix))] +fn path_is_network_share(path: &Path) -> bool { + use std::path::{Component, Prefix}; + + matches!( + path.components().next(), + Some(Component::Prefix(prefix)) + if matches!(prefix.kind(), Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _)) + ) +} + pub(crate) fn open_read_only(path: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX @@ -507,7 +640,56 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{configure, open_read_write, prepare}; + use super::{ + configure, ensure_local_filesystem, filesystem_name_is_network, open_read_write, prepare, + }; + + #[test] + fn network_filesystem_name_classifier_is_fail_closed_for_known_remote_types() { + for name in [ + "nfs", "NFS4", "smbfs", "cifs", "afpfs", "webdav", "9p", "ceph", "sshfs", "fusefs", + "osxfuse", "macfuse", + ] { + assert!( + filesystem_name_is_network(name), + "expected {name} to be remote" + ); + } + for name in ["apfs", "ext4", "tmpfs", "xfs", "btrfs", "zfs"] { + assert!( + !filesystem_name_is_network(name), + "expected {name} to be local" + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn network_filesystem_magic_classifier_rejects_remote_and_fuse_types() { + use super::filesystem_magic_is_network; + + for magic in [ + 0x0000_6969, + 0x0000_517b, + 0xff53_4d42, + 0x7375_7245, + 0x5346_414f, + 0x0000_564c, + 0x00c3_6400, + 0x0102_1997, + 0x6573_5546, + ] { + assert!(filesystem_magic_is_network(magic)); + } + assert!(!filesystem_magic_is_network(0xef53)); + assert!(!filesystem_magic_is_network(0x0102_1994)); + } + + #[test] + fn local_temp_directory_passes_filesystem_integration_check() { + let dir = tempfile::tempdir().unwrap(); + ensure_local_filesystem(dir.path()).unwrap(); + } #[test] fn configure_sets_every_required_pragma() { diff --git a/crates/psyche-store/src/lib.rs b/crates/psyche-store/src/lib.rs index 54471c2..ffee01c 100644 --- a/crates/psyche-store/src/lib.rs +++ b/crates/psyche-store/src/lib.rs @@ -99,6 +99,7 @@ impl Store { } transaction.pragma_update(None, "user_version", CURRENT_DATABASE_VERSION)?; } + migrations::validate_current_schema(&transaction)?; transaction.commit()?; Ok(Self { connection }) diff --git a/crates/psyche-store/src/migrations.rs b/crates/psyche-store/src/migrations.rs index 8a1e096..1446384 100644 --- a/crates/psyche-store/src/migrations.rs +++ b/crates/psyche-store/src/migrations.rs @@ -1,16 +1,27 @@ -use rusqlite::Transaction; +use rusqlite::{OptionalExtension, Transaction}; use crate::StoreError; /// Latest SQLite schema version understood by this build. pub const CURRENT_DATABASE_VERSION: u32 = 1; +const FOUNDATION_TABLES: [&str; 6] = [ + "schema_migrations", + "canonical_records", + "execution_binding_revisions", + "transitions", + "quarantine_records", + "audit_events", +]; + +const FOUNDATION_SQL: &str = include_str!("../migrations/001_foundation.sql"); + pub(super) fn apply_migration_sql( transaction: &Transaction<'_>, version: u32, ) -> Result<(), StoreError> { let sql = match version { - 1 => include_str!("../migrations/001_foundation.sql"), + 1 => FOUNDATION_SQL, _ => return Err(StoreError::MigrationUnavailable { version }), }; @@ -24,3 +35,83 @@ pub(super) fn apply_migration_sql( )?; Ok(()) } + +pub(super) fn validate_current_schema(transaction: &Transaction<'_>) -> Result<(), StoreError> { + match current_schema_matches(transaction) { + Ok(true) => Ok(()), + Ok(false) | Err(_) => Err(StoreError::DatabaseCorruption), + } +} + +fn current_schema_matches(transaction: &Transaction<'_>) -> rusqlite::Result { + let user_version = + transaction.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))?; + if user_version != CURRENT_DATABASE_VERSION { + return Ok(false); + } + + for table in FOUNDATION_TABLES { + let actual_sql = transaction + .query_row( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?1 AND tbl_name = ?1", + [table], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + let Some(actual_sql) = actual_sql else { + return Ok(false); + }; + let Some(expected_sql) = foundation_table_sql(table) else { + return Ok(false); + }; + if normalize_sql(&actual_sql) != normalize_sql(expected_sql) { + return Ok(false); + } + + let mut index_statement = transaction.prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND tbl_name = ?1 ORDER BY name", + )?; + let indexes = index_statement + .query_map([table], |row| row.get::<_, String>(0))? + .collect::>>()?; + if indexes != foundation_indexes(table) { + return Ok(false); + } + } + + let mut statement = + transaction.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let versions = statement + .query_map([], |row| row.get::<_, u32>(0))? + .collect::>>()?; + Ok(versions == [CURRENT_DATABASE_VERSION]) +} + +fn foundation_indexes(table: &str) -> &'static [&'static str] { + match table { + "canonical_records" => &[ + "sqlite_autoindex_canonical_records_1", + "sqlite_autoindex_canonical_records_2", + ], + "execution_binding_revisions" => &[ + "sqlite_autoindex_execution_binding_revisions_1", + "sqlite_autoindex_execution_binding_revisions_2", + ], + "transitions" => &["sqlite_autoindex_transitions_1"], + "quarantine_records" => &["sqlite_autoindex_quarantine_records_1"], + _ => &[], + } +} + +fn foundation_table_sql(table: &str) -> Option<&'static str> { + let prefix = format!("CREATE TABLE {table} "); + FOUNDATION_SQL + .split(';') + .map(str::trim) + .find(|statement| statement.starts_with(&prefix)) +} + +fn normalize_sql(sql: &str) -> String { + sql.split_whitespace().collect::>().join(" ") +} diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index 8643b78..f931629 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -117,6 +117,75 @@ fn existing_v1_fixture_opens_without_reapplying_migration() { assert_eq!(foundation_tables(&path), FOUNDATION_TABLES); } +#[test] +fn user_version_one_without_foundation_schema_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + set_mode(dir.path(), 0o700); + let path = dir.path().join("forged-v1.sqlite3"); + execute_batch( + &path, + " + CREATE TABLE unrelated_operator_data (value TEXT NOT NULL) STRICT; + INSERT INTO unrelated_operator_data (value) VALUES ('preserve-me'); + PRAGMA user_version = 1; + ", + ); + + assert_database_corruption(Store::open(&path)); + assert_eq!( + scalar_text(&path, "SELECT value FROM unrelated_operator_data"), + "preserve-me" + ); +} + +#[test] +fn altered_foundation_columns_indexes_and_constraints_are_rejected_as_corruption() { + for tamper in ["column", "index", "constraint"] { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + match tamper { + "column" => execute_batch( + &path, + "ALTER TABLE canonical_records ADD COLUMN injected TEXT", + ), + "index" => execute_batch( + &path, + "CREATE INDEX injected_index ON canonical_records(kind)", + ), + "constraint" => execute_batch( + &path, + " + PRAGMA foreign_keys = OFF; + ALTER TABLE canonical_records RENAME TO canonical_records_original; + CREATE TABLE canonical_records ( + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + digest TEXT NOT NULL, + canonical_json BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (kind, record_id) + ) STRICT; + DROP TABLE canonical_records_original; + ", + ), + _ => unreachable!(), + } + + assert_database_corruption(Store::open(&path)); + } +} + +#[test] +fn user_version_and_migration_ledger_disagreement_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + execute_batch(&path, "DELETE FROM schema_migrations WHERE version = 1"); + + assert_database_corruption(Store::open(&path)); +} + #[test] fn v1_quarantine_schema_contains_durable_integrity_metadata() { let dir = tempfile::tempdir().unwrap(); @@ -699,6 +768,19 @@ fn assert_invalid_database_path(path: &Path) { assert_eq!(error.to_string(), "store database path is invalid"); } +fn assert_database_corruption(result: Result) { + let error = result.unwrap_err(); + assert!(matches!(error, StoreError::DatabaseCorruption)); + assert_eq!( + error.to_string(), + "stored database content failed integrity validation" + ); + assert_eq!( + format!("{error:?}"), + "StoreError(stored database content failed integrity validation)" + ); +} + #[cfg(unix)] fn run_crash_helper(path: &Path, helper: &str) { use std::process::Command; From 25abbfb5c81e255105d9495bfd40600b2b6e7a6e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:30:12 -0500 Subject: [PATCH 58/66] docs: freeze delivery v1 field order --- docs/SCHEMAS.md | 8 +++++--- scripts/check-g2-evidence-test.py | 11 +++++++++++ scripts/check-g2-evidence.py | 10 ++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index af21854..427a16e 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -25,9 +25,11 @@ Delivery is authoritative at `del_`; the related delegation identity is the distinct derived `dlg_` prefix. The canonical delivery v1 fields are `schema_version`, `delivery_id`, -`intent_id`, `surface`, `target`, `state`, `attempt`, `created_at`, -`updated_at`, and `last_error`. Surface event/effect envelopes are core-owned, -bounded, schema-versioned types; adapters cannot add fields or widen payloads. +`intent_id`, `action_class`, `account_id`, `chat_id`, `topic`, `relationship`, +`effect`, `effect_digest`, `surface_decision`, `logical_response_id`, +`logical_part`, `state`, `attempt_count`, and `telegram_message_id`. Surface +event/effect envelopes are core-owned, bounded, schema-versioned types; +adapters cannot add fields or widen payloads. The store-owned `Transition` validates record identity, nonempty from/to state, strictly increasing version, canonical UTC `created_at`, and its canonical diff --git a/scripts/check-g2-evidence-test.py b/scripts/check-g2-evidence-test.py index 89dcf34..dce6a94 100644 --- a/scripts/check-g2-evidence-test.py +++ b/scripts/check-g2-evidence-test.py @@ -96,6 +96,17 @@ def passed_evidence(self) -> str: def test_valid_exact_manifest_and_candidate_evidence(self) -> None: self.assert_valid() + def test_delivery_v1_documentation_rejects_field_order_mutation(self) -> None: + path = "docs/SCHEMAS.md" + schemas = (ROOT / path).read_text() + mutated = schemas.replace( + "`effect`, `effect_digest`, `surface_decision`", + "`effect_digest`, `effect`, `surface_decision`", + 1, + ) + self.assertNotEqual(mutated, schemas) + self.assert_rejected(overrides={path: mutated}) + def test_zero_listed_tests_is_rejected(self) -> None: listed = dict(self.listed) listed["psyche-core/contracts"] = "" diff --git a/scripts/check-g2-evidence.py b/scripts/check-g2-evidence.py index 32fd16c..fa46cfc 100644 --- a/scripts/check-g2-evidence.py +++ b/scripts/check-g2-evidence.py @@ -749,6 +749,16 @@ def validate_docs(root: pathlib.Path, overrides: Mapping[str, str]) -> None: "ResultBundle", "digest", "media_type", "size_bytes", "expires_at", "Attempt", "att_", "ExecutionBinding", "retention", "Deferred", )) + delivery_fields = ( + "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", "telegram_message_id", + ) + ordered_delivery_shape = "The canonical delivery v1 fields are " + ", ".join( + f"`{field}`" for field in delivery_fields[:-1] + ) + f", and `{delivery_fields[-1]}`." + if ordered_delivery_shape not in " ".join(schemas.split()): + fail("docs/SCHEMAS.md does not freeze the exact ordered delivery v1 fields") testing = read_text(root, "docs/TESTING.md", overrides) require_terms("docs/TESTING.md", testing, ( "scripts", "PROPTEST_CASES", "PROPTEST_RNG_SEED", "crash", "fault", "observation", From a22e9953ec44165f1717281f04a398612f248e7e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:41:28 -0500 Subject: [PATCH 59/66] fix: harden store trust boundary --- Cargo.lock | 8 + Cargo.toml | 2 + crates/psyche-runtime/tests/lifecycle.rs | 27 +++ crates/psyche-store/Cargo.toml | 6 + crates/psyche-store/src/connection.rs | 264 ++++++++++++++--------- crates/psyche-store/src/migrations.rs | 52 +++++ crates/psyche-store/tests/migrations.rs | 63 ++++++ 7 files changed, 318 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1219c19..6249121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,7 @@ dependencies = [ name = "psyche-store" version = "0.0.0" dependencies = [ + "libc", "proptest", "psyche-core", "rusqlite", @@ -704,6 +705,7 @@ dependencies = [ "thiserror", "time", "ulid", + "winsafe", ] [[package]] @@ -1386,6 +1388,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 52d3d2b..49aeac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,8 @@ assert_cmd = "2" predicates = "3" tempfile = "3" rustix = { version = "1.1.4", features = ["fs"] } +libc = "0.2" +winsafe = { version = "=0.0.19", features = ["kernel"] } # Shared lint policy. Declared at bootstrap because retrofitting it later means # editing every member manifest *and* clearing whatever backlog the new lints diff --git a/crates/psyche-runtime/tests/lifecycle.rs b/crates/psyche-runtime/tests/lifecycle.rs index a27167f..58a2a3d 100644 --- a/crates/psyche-runtime/tests/lifecycle.rs +++ b/crates/psyche-runtime/tests/lifecycle.rs @@ -58,3 +58,30 @@ async fn future_database_version_fails_start_before_running_is_published() { )) )); } + +#[tokio::test] +async fn malformed_current_store_fails_start_before_running_is_published() { + let directory = tempfile::tempdir().unwrap(); + let data_dir = directory.path().join("private"); + let database = data_dir.join("psyche.sqlite3"); + drop(Store::open(&database).unwrap()); + let connection = rusqlite::Connection::open(&database).unwrap(); + connection + .execute_batch( + " + CREATE TRIGGER injected_runtime_trigger + AFTER INSERT ON canonical_records + BEGIN + SELECT 1; + END; + ", + ) + .unwrap(); + drop(connection); + + let result = Runtime::start(test_config(&data_dir)).await; + assert!(matches!( + result, + Err(RuntimeError::Store(StoreError::DatabaseCorruption)) + )); +} diff --git a/crates/psyche-store/Cargo.toml b/crates/psyche-store/Cargo.toml index 90163e4..4037f5b 100644 --- a/crates/psyche-store/Cargo.toml +++ b/crates/psyche-store/Cargo.toml @@ -19,6 +19,12 @@ ulid = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true } +[target.'cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd", target_os = "openbsd", target_os = "dragonfly"))'.dependencies] +libc = { workspace = true } + +[target.'cfg(windows)'.dependencies] +winsafe = { workspace = true } + [dev-dependencies] proptest = { workspace = true } tempfile = { workspace = true } diff --git a/crates/psyche-store/src/connection.rs b/crates/psyche-store/src/connection.rs index b6d6611..77c9255 100644 --- a/crates/psyche-store/src/connection.rs +++ b/crates/psyche-store/src/connection.rs @@ -32,10 +32,10 @@ pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), Store .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - ensure_local_filesystem(parent)?; prepare_data_dir(parent)?; let state = prepare_database_file(path)?; let open_path = database_open_path(path)?; + ensure_local_database_file(&open_path)?; Ok((open_path, state)) } @@ -49,28 +49,14 @@ pub(crate) fn prepare(path: &Path) -> Result<(PathBuf, DatabaseFileState), Store target_os = "dragonfly" ))] fn ensure_local_filesystem(path: &Path) -> Result<(), StoreError> { - let existing_ancestor = path - .ancestors() - .map(|candidate| { - if candidate.as_os_str().is_empty() { - Path::new(".") - } else { - candidate - } - }) - .find(|candidate| match fs::symlink_metadata(candidate) { - Ok(_) => true, - Err(error) if error.kind() == ErrorKind::NotFound => false, - Err(_) => true, - }) - .ok_or(StoreError::InvalidDatabasePath)?; - let statistics = rustix::fs::statfs(existing_ancestor) + let existing_ancestor = existing_ancestor(path)?; + let statistics = rustix::fs::statfs(&existing_ancestor) .map_err(|error| StoreError::directory_operation(error.into()))?; - if filesystem_is_network(&statistics) { - Err(StoreError::InvalidDatabasePath) - } else { + if filesystem_is_local(&statistics) { Ok(()) + } else { + Err(StoreError::InvalidDatabasePath) } } @@ -90,33 +76,69 @@ fn ensure_local_filesystem(_path: &Path) -> Result<(), StoreError> { Err(StoreError::InvalidDatabasePath) } -#[cfg(not(unix))] +#[cfg(windows)] fn ensure_local_filesystem(path: &Path) -> Result<(), StoreError> { - if path_is_network_share(path) { - Err(StoreError::InvalidDatabasePath) - } else { + let canonical = + fs::canonicalize(existing_ancestor(path)?).map_err(StoreError::directory_operation)?; + let canonical = canonical.to_str().ok_or(StoreError::InvalidDatabasePath)?; + let volume = + winsafe::GetVolumePathName(canonical).map_err(|_| StoreError::InvalidDatabasePath)?; + if windows_drive_type_is_local(winsafe::GetDriveType(Some(&volume))) { Ok(()) + } else { + Err(StoreError::InvalidDatabasePath) } } +fn existing_ancestor(path: &Path) -> Result { + path.ancestors() + .map(|candidate| { + if candidate.as_os_str().is_empty() { + Path::new(".") + } else { + candidate + } + }) + .find(|candidate| match fs::symlink_metadata(candidate) { + Ok(_) => true, + Err(error) if error.kind() == ErrorKind::NotFound => false, + Err(_) => true, + }) + .map(Path::to_path_buf) + .ok_or(StoreError::InvalidDatabasePath) +} + +#[cfg(all(not(unix), not(windows)))] +fn ensure_local_filesystem(_path: &Path) -> Result<(), StoreError> { + Err(StoreError::InvalidDatabasePath) +} + #[cfg(any(target_os = "linux", target_os = "android"))] -fn filesystem_is_network(statistics: &rustix::fs::StatFs) -> bool { - filesystem_magic_is_network(statistics.f_type as u64) +fn filesystem_is_local(statistics: &rustix::fs::StatFs) -> bool { + filesystem_magic_is_supported_local(statistics.f_type as u64) } #[cfg(any(target_os = "linux", target_os = "android"))] -fn filesystem_magic_is_network(magic: u64) -> bool { +fn filesystem_magic_is_supported_local(magic: u64) -> bool { matches!( magic, - 0x0000_6969 // NFS - | 0x0000_517b // SMB - | 0xff53_4d42 // CIFS - | 0x7375_7245 // Coda - | 0x5346_414f // AFS - | 0x0000_564c // NCP - | 0x00c3_6400 // Ceph - | 0x0102_1997 // 9P - | 0x6573_5546 // FUSE, including sshfs and other remote transports + 0x0000_ef53 // ext2/ext3/ext4 + | 0x5846_5342 // XFS + | 0x9123_683e // Btrfs + | 0x0102_1994 // tmpfs + | 0x8584_58f6 // ramfs + | 0x794c_7630 // overlayfs + | 0x2fc1_2fc1 // ZFS + | 0x3153_464a // JFS + | 0x5265_4973 // ReiserFS + | 0xf2f5_2010 // F2FS + | 0x2405_1905 // UBIFS + | 0x0000_4d44 // FAT + | 0x2011_bab0 // exFAT + | 0x5346_544e // NTFS + | 0x0000_4244 // HFS + | 0x0000_482b // HFS+ + | 0x0000_f15f // eCryptfs ) } @@ -127,48 +149,67 @@ fn filesystem_magic_is_network(magic: u64) -> bool { target_os = "openbsd", target_os = "dragonfly" ))] -fn filesystem_is_network(statistics: &rustix::fs::StatFs) -> bool { - let name = statistics - .f_fstypename - .iter() - .take_while(|character| **character != 0) - .map(|character| *character as u8) - .collect::>(); - match std::str::from_utf8(&name) { - Ok(name) => filesystem_name_is_network(name), - Err(_) => true, +fn filesystem_is_local(statistics: &rustix::fs::StatFs) -> bool { + mount_flags_are_local(statistics.f_flags as u64) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn mount_flags_are_local(flags: u64) -> bool { + flags & libc::MNT_LOCAL as u64 != 0 +} + +#[cfg(windows)] +fn windows_drive_type_is_local(drive_type: winsafe::co::DRIVE) -> bool { + drive_type == winsafe::co::DRIVE::FIXED + || drive_type == winsafe::co::DRIVE::REMOVABLE + || drive_type == winsafe::co::DRIVE::RAMDISK +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn ensure_local_database_file(path: &Path) -> Result<(), StoreError> { + let file = File::open(path).map_err(StoreError::file_operation)?; + let statistics = + rustix::fs::fstatfs(&file).map_err(|error| StoreError::file_operation(error.into()))?; + if filesystem_is_local(&statistics) { + Ok(()) + } else { + Err(StoreError::InvalidDatabasePath) } } -fn filesystem_name_is_network(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "nfs" - | "nfs4" - | "smb" - | "smbfs" - | "cifs" - | "afp" - | "afpfs" - | "webdav" - | "9p" - | "ceph" - | "sshfs" - | "fusefs" - | "osxfuse" - | "macfuse" - ) +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" + )) +))] +fn ensure_local_database_file(_path: &Path) -> Result<(), StoreError> { + Err(StoreError::InvalidDatabasePath) } #[cfg(not(unix))] -fn path_is_network_share(path: &Path) -> bool { - use std::path::{Component, Prefix}; - - matches!( - path.components().next(), - Some(Component::Prefix(prefix)) - if matches!(prefix.kind(), Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _)) - ) +fn ensure_local_database_file(path: &Path) -> Result<(), StoreError> { + ensure_local_filesystem(path) } pub(crate) fn open_read_only(path: &Path) -> Result { @@ -176,6 +217,7 @@ pub(crate) fn open_read_only(path: &Path) -> Result { | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; let connection = Connection::open_with_flags(path, flags)?; + ensure_local_database_file(path)?; connection.busy_timeout(BUSY_TIMEOUT)?; Ok(connection) } @@ -185,6 +227,7 @@ pub(crate) fn open_read_write(path: &Path) -> Result { | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW; let connection = Connection::open_with_flags(path, flags)?; + ensure_local_database_file(path)?; connection.busy_timeout(BUSY_TIMEOUT)?; Ok(connection) } @@ -531,6 +574,7 @@ fn validate_path(path: &Path) -> Result<(), StoreError> { } pub(crate) fn prepare_data_dir(path: &Path) -> Result { + ensure_local_filesystem(path)?; let existed = match fs::symlink_metadata(path) { Ok(metadata) => { validate_parent_metadata(&metadata)?; @@ -545,6 +589,8 @@ pub(crate) fn prepare_data_dir(path: &Path) -> Result { let metadata = fs::symlink_metadata(path).map_err(StoreError::directory_operation)?; validate_parent_metadata(&metadata)?; + let canonical = fs::canonicalize(path).map_err(StoreError::directory_operation)?; + ensure_local_filesystem(&canonical)?; Ok(existed) } @@ -640,49 +686,59 @@ fn create_database_file(path: &Path) -> std::io::Result<()> { mod tests { use rusqlite::Connection; - use super::{ - configure, ensure_local_filesystem, filesystem_name_is_network, open_read_write, prepare, - }; - - #[test] - fn network_filesystem_name_classifier_is_fail_closed_for_known_remote_types() { - for name in [ - "nfs", "NFS4", "smbfs", "cifs", "afpfs", "webdav", "9p", "ceph", "sshfs", "fusefs", - "osxfuse", "macfuse", - ] { - assert!( - filesystem_name_is_network(name), - "expected {name} to be remote" - ); - } - for name in ["apfs", "ext4", "tmpfs", "xfs", "btrfs", "zfs"] { - assert!( - !filesystem_name_is_network(name), - "expected {name} to be local" - ); - } - } + use super::{configure, ensure_local_filesystem, open_read_write, prepare}; #[cfg(any(target_os = "linux", target_os = "android"))] #[test] - fn network_filesystem_magic_classifier_rejects_remote_and_fuse_types() { - use super::filesystem_magic_is_network; + fn filesystem_magic_classifier_allows_known_local_and_rejects_unknown_remote_and_fuse() { + use super::filesystem_magic_is_supported_local; for magic in [ + 0x0000_ef53, + 0x5846_5342, + 0x9123_683e, + 0x0102_1994, + 0x794c_7630, + ] { + assert!(filesystem_magic_is_supported_local(magic)); + } + for magic in [ + 0, + 0xdead_beef, 0x0000_6969, - 0x0000_517b, 0xff53_4d42, - 0x7375_7245, - 0x5346_414f, - 0x0000_564c, - 0x00c3_6400, 0x0102_1997, 0x6573_5546, ] { - assert!(filesystem_magic_is_network(magic)); + assert!(!filesystem_magic_is_supported_local(magic)); } - assert!(!filesystem_magic_is_network(0xef53)); - assert!(!filesystem_magic_is_network(0x0102_1994)); + } + + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + #[test] + fn bsd_mount_flags_require_mnt_local() { + use super::mount_flags_are_local; + + assert!(!mount_flags_are_local(0)); + assert!(!mount_flags_are_local((libc::MNT_LOCAL as u64) << 1)); + assert!(mount_flags_are_local(libc::MNT_LOCAL as u64)); + } + + #[cfg(windows)] + #[test] + fn windows_drive_classifier_rejects_mapped_and_unknown_drives() { + use super::windows_drive_type_is_local; + + assert!(windows_drive_type_is_local(winsafe::co::DRIVE::FIXED)); + assert!(windows_drive_type_is_local(winsafe::co::DRIVE::REMOVABLE)); + assert!(!windows_drive_type_is_local(winsafe::co::DRIVE::REMOTE)); + assert!(!windows_drive_type_is_local(winsafe::co::DRIVE::UNKNOWN)); } #[test] diff --git a/crates/psyche-store/src/migrations.rs b/crates/psyche-store/src/migrations.rs index 1446384..1e483ce 100644 --- a/crates/psyche-store/src/migrations.rs +++ b/crates/psyche-store/src/migrations.rs @@ -50,6 +50,10 @@ fn current_schema_matches(transaction: &Transaction<'_>) -> rusqlite::Result) -> rusqlite::Result) -> rusqlite::Result { + let mut statement = transaction + .prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema ORDER BY type, name")?; + let objects = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + + for object in objects { + let (kind, name, table, sql) = object?; + match kind.as_str() { + "table" => { + let Some(sql) = sql else { + return Ok(false); + }; + if name != table || sql_is_virtual_table(&sql) { + return Ok(false); + } + } + "index" => { + if name == table { + return Ok(false); + } + } + "trigger" | "view" => return Ok(false), + _ => return Ok(false), + } + } + Ok(true) +} + +fn sql_is_virtual_table(sql: &str) -> bool { + let mut tokens = sql.split_whitespace(); + tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("CREATE")) + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("VIRTUAL")) + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("TABLE")) +} + fn foundation_indexes(table: &str) -> &'static [&'static str] { match table { "canonical_records" => &[ diff --git a/crates/psyche-store/tests/migrations.rs b/crates/psyche-store/tests/migrations.rs index f931629..bcff131 100644 --- a/crates/psyche-store/tests/migrations.rs +++ b/crates/psyche-store/tests/migrations.rs @@ -186,6 +186,69 @@ fn user_version_and_migration_ledger_disagreement_is_rejected_as_corruption() { assert_database_corruption(Store::open(&path)); } +#[test] +fn trigger_on_foundation_table_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + execute_batch( + &path, + " + CREATE TRIGGER injected_foundation_trigger + AFTER INSERT ON canonical_records + BEGIN + SELECT 1; + END; + ", + ); + + assert_database_corruption(Store::open(&path)); +} + +#[test] +fn trigger_on_unrelated_table_that_writes_foundation_state_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + execute_batch( + &path, + " + CREATE TABLE unrelated_input (value TEXT NOT NULL) STRICT; + CREATE TRIGGER injected_unrelated_trigger + AFTER INSERT ON unrelated_input + BEGIN + INSERT INTO audit_events ( + event_code, correlation_id, public_details_json, created_at + ) VALUES ('injected', NEW.value, X'7b7d', '2026-08-08T00:00:00Z'); + END; + ", + ); + + assert_database_corruption(Store::open(&path)); +} + +#[test] +fn persisted_view_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + execute_batch( + &path, + "CREATE VIEW injected_view AS SELECT * FROM canonical_records", + ); + + assert_database_corruption(Store::open(&path)); +} + +#[test] +fn virtual_table_is_rejected_as_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_db(dir.path(), Fixture::Version1); + execute_batch( + &path, + "CREATE VIRTUAL TABLE injected_virtual_table USING fts5(content)", + ); + + assert_database_corruption(Store::open(&path)); +} + #[test] fn v1_quarantine_schema_contains_durable_integrity_metadata() { let dir = tempfile::tempdir().unwrap(); From 94fe926d7a75f8d89a8897748f18c9408953f3cb Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:58:11 -0500 Subject: [PATCH 60/66] test: escape runtime config paths portably --- Cargo.lock | 1 + crates/psyche-runtime/Cargo.toml | 1 + crates/psyche-runtime/tests/lifecycle.rs | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6249121..d57ae30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -684,6 +684,7 @@ dependencies = [ "psyche-config", "psyche-store", "rusqlite", + "serde_json", "tempfile", "thiserror", "tokio", diff --git a/crates/psyche-runtime/Cargo.toml b/crates/psyche-runtime/Cargo.toml index a550ea6..e238746 100644 --- a/crates/psyche-runtime/Cargo.toml +++ b/crates/psyche-runtime/Cargo.toml @@ -24,6 +24,7 @@ tracing = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } tempfile = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/psyche-runtime/tests/lifecycle.rs b/crates/psyche-runtime/tests/lifecycle.rs index 58a2a3d..8eb7926 100644 --- a/crates/psyche-runtime/tests/lifecycle.rs +++ b/crates/psyche-runtime/tests/lifecycle.rs @@ -6,20 +6,29 @@ use psyche_runtime::{LifecycleState, Runtime, RuntimeError}; use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; fn test_config(data_dir: &std::path::Path) -> Config { + let data_dir = serde_json::to_string(&data_dir.to_string_lossy()).unwrap(); psyche_config::load_str(&format!( r#" schema_version = "psyche.config.v1" -data_dir = "{}" +data_dir = {data_dir} [coven] socket = "/run/coven.sock" required_api_version = "coven.daemon.v1" -"#, - data_dir.display() +"# )) .unwrap() } +#[test] +fn test_config_preserves_a_windows_style_data_directory() { + let data_dir = std::path::Path::new(r"C:\Users\Val\AppData\Local\Psyche"); + + let config = test_config(data_dir); + + assert_eq!(config.data_dir, data_dir); +} + #[tokio::test] async fn start_opens_the_configured_store_and_shutdown_leaves_schema_v1_reopenable() { let directory = tempfile::tempdir().unwrap(); From 22824e43f6e57220e950df4738255687e4ef075a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:02:54 -0500 Subject: [PATCH 61/66] test: serialize runtime paths as TOML --- Cargo.lock | 2 +- crates/psyche-runtime/Cargo.toml | 2 +- crates/psyche-runtime/tests/lifecycle.rs | 27 +++++++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d57ae30..4e2fc31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -684,10 +684,10 @@ dependencies = [ "psyche-config", "psyche-store", "rusqlite", - "serde_json", "tempfile", "thiserror", "tokio", + "toml", "tracing", ] diff --git a/crates/psyche-runtime/Cargo.toml b/crates/psyche-runtime/Cargo.toml index e238746..1f23b14 100644 --- a/crates/psyche-runtime/Cargo.toml +++ b/crates/psyche-runtime/Cargo.toml @@ -24,7 +24,7 @@ tracing = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } tempfile = { workspace = true } -serde_json = { workspace = true } +toml = { workspace = true } [lints] workspace = true diff --git a/crates/psyche-runtime/tests/lifecycle.rs b/crates/psyche-runtime/tests/lifecycle.rs index 8eb7926..b701070 100644 --- a/crates/psyche-runtime/tests/lifecycle.rs +++ b/crates/psyche-runtime/tests/lifecycle.rs @@ -6,7 +6,10 @@ use psyche_runtime::{LifecycleState, Runtime, RuntimeError}; use psyche_store::{CURRENT_DATABASE_VERSION, Store, StoreError}; fn test_config(data_dir: &std::path::Path) -> Config { - let data_dir = serde_json::to_string(&data_dir.to_string_lossy()).unwrap(); + let Some(data_dir) = data_dir.to_str() else { + panic!("test data directory must be valid UTF-8"); + }; + let data_dir = toml::Value::String(data_dir.to_owned()).to_string(); psyche_config::load_str(&format!( r#" schema_version = "psyche.config.v1" @@ -29,6 +32,28 @@ fn test_config_preserves_a_windows_style_data_directory() { assert_eq!(config.data_dir, data_dir); } +#[test] +fn test_config_preserves_a_del_containing_utf8_data_directory() { + let data_dir = std::path::Path::new("/tmp/psyche-\u{007f}-store"); + + let config = test_config(data_dir); + + assert_eq!(config.data_dir, data_dir); +} + +#[cfg(unix)] +#[test] +#[should_panic(expected = "test data directory must be valid UTF-8")] +fn test_config_explicitly_rejects_a_non_utf8_data_directory() { + use std::os::unix::ffi::OsStringExt; + + let data_dir = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![ + b'/', b't', b'm', b'p', b'/', 0xff, + ])); + + let _ = test_config(&data_dir); +} + #[tokio::test] async fn start_opens_the_configured_store_and_shutdown_leaves_schema_v1_reopenable() { let directory = tempfile::tempdir().unwrap(); From 72b9223a74eb32be3fd3302278b89103f38613f6 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:19:37 -0500 Subject: [PATCH 62/66] test: isolate runtime store fixtures --- crates/psyche-runtime/src/lib.rs | 78 ++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/crates/psyche-runtime/src/lib.rs b/crates/psyche-runtime/src/lib.rs index f78fc0f..a006fc4 100644 --- a/crates/psyche-runtime/src/lib.rs +++ b/crates/psyche-runtime/src/lib.rs @@ -435,33 +435,52 @@ const _: fn() = || { mod tests { use super::*; - fn test_config() -> Config { - psyche_config::load_str( + fn toml_path(path: &std::path::Path) -> String { + let Some(path) = path.to_str() else { + panic!("test data directory must be valid UTF-8"); + }; + toml::Value::String(path.to_owned()).to_string() + } + + fn test_config() -> (Config, tempfile::TempDir) { + let owner = tempfile::tempdir().unwrap(); + let data_dir = owner.path().join("data"); + let data_dir = toml_path(&data_dir); + let config = psyche_config::load_str(&format!( r#" schema_version = "psyche.config.v1" -data_dir = "/tmp/psyche-test" +data_dir = {data_dir} [coven] socket = "/run/coven.sock" required_api_version = "coven.daemon.v1" -"#, - ) - .unwrap() +"# + )) + .unwrap(); + (config, owner) + } + + #[test] + fn test_configs_use_distinct_data_directories() { + let (first, _first_owner) = test_config(); + let (second, _second_owner) = test_config(); + + assert_ne!(first.data_dir, second.data_dir); } #[tokio::test] async fn starts_running() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); assert_eq!(rt.state(), LifecycleState::Running); } #[tokio::test] async fn the_configuration_is_readable_after_start() { - let rt = Runtime::start(test_config()).await.unwrap(); - assert_eq!( - rt.config().data_dir, - std::path::Path::new("/tmp/psyche-test") - ); + let (config, data_dir) = test_config(); + let expected = data_dir.path().join("data"); + let rt = Runtime::start(config).await.unwrap(); + assert_eq!(rt.config().data_dir, expected); } // The wire spellings are what `psyche status --json` emits and what log @@ -476,7 +495,8 @@ required_api_version = "coven.daemon.v1" #[tokio::test] async fn shutdown_drains_then_stops_in_order() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); rt.shutdown().await.unwrap(); assert_eq!(rt.state(), LifecycleState::Stopped); assert_eq!( @@ -504,7 +524,8 @@ required_api_version = "coven.daemon.v1" async fn a_later_shutdown_succeeds_without_redriving_the_drain() { use std::time::Duration; - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); for attempt in 0..2 { tokio::time::timeout(Duration::from_secs(5), rt.shutdown()) .await @@ -519,7 +540,8 @@ required_api_version = "coven.daemon.v1" // the only place the election is visible. #[tokio::test] async fn only_the_first_caller_drives_the_drain() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); assert_eq!(rt.shutdown_inner().await.unwrap(), ShutdownRole::Driver); assert_eq!(rt.shutdown_inner().await.unwrap(), ShutdownRole::Observer); } @@ -536,7 +558,8 @@ required_api_version = "coven.daemon.v1" async fn a_losing_caller_does_not_return_before_stopped_is_published() { use std::time::Duration; - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); // Enter `Draining` with no winner running, so nothing will publish // `Stopped` unless this test does. assert!(rt.transition_to(LifecycleState::Draining)); @@ -562,7 +585,8 @@ required_api_version = "coven.daemon.v1" // which in a daemon that is signalled repeatedly is an unbounded allocation. #[tokio::test] async fn the_transition_log_is_bounded_by_the_state_count() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); for _ in 0..1_000 { let _ = rt.shutdown().await; } @@ -578,7 +602,8 @@ required_api_version = "coven.daemon.v1" // the part that is actually a guarantee. #[tokio::test] async fn a_subscriber_that_keeps_up_observes_every_state_in_order() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); let mut receiver = rt.subscribe(); let mut seen = vec![*receiver.borrow_and_update()]; @@ -602,7 +627,8 @@ required_api_version = "coven.daemon.v1" // is what makes `await_stopped` safe for a caller that arrives late. #[tokio::test] async fn a_late_subscriber_sees_the_state_the_runtime_is_actually_in() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); rt.shutdown().await.unwrap(); assert_eq!(*rt.subscribe().borrow(), LifecycleState::Stopped); } @@ -650,7 +676,8 @@ required_api_version = "coven.daemon.v1" let executor = tokio::runtime::Builder::new_multi_thread().build().unwrap(); for round in 0..ROUNDS { - let rt = Arc::new(executor.block_on(Runtime::start(test_config())).unwrap()); + let (config, _data_dir) = test_config(); + let rt = Arc::new(executor.block_on(Runtime::start(config)).unwrap()); // +1 for the reader. let barrier = Arc::new(Barrier::new(THREADS + 1)); let drivers = Arc::new(AtomicUsize::new(0)); @@ -744,8 +771,9 @@ required_api_version = "coven.daemon.v1" const CALLERS: usize = 64; let calls = Arc::new(AtomicUsize::new(0)); + let (config, _data_dir) = test_config(); let runtime = Arc::new(Runtime::start_with_checkpoint_backend( - test_config(), + config, Box::new(FailingCheckpoint { calls: Arc::clone(&calls), }), @@ -796,10 +824,13 @@ required_api_version = "coven.daemon.v1" #[tokio::test] async fn debug_does_not_print_an_extension_secret() { let secretish = "A".repeat(30); + let data_dir = tempfile::tempdir().unwrap(); + let store_dir = data_dir.path().join("data"); + let store_dir = toml_path(&store_dir); let raw = format!( r#" schema_version = "psyche.config.v1" -data_dir = "/tmp/psyche-test" +data_dir = {store_dir} [coven] socket = "/run/coven.sock" @@ -840,7 +871,8 @@ looks_like_a_secret = "{secretish}" // A poisoned lock must not take the daemon's shutdown path down with it. #[tokio::test] async fn a_poisoned_lock_does_not_panic_the_shutdown_path() { - let rt = Runtime::start(test_config()).await.unwrap(); + let (config, _data_dir) = test_config(); + let rt = Runtime::start(config).await.unwrap(); let lock = Arc::clone(&rt.lifecycle); std::thread::spawn(move || { let _guard = lock.lock().unwrap(); From b4e0a1c6e2f70fc318d82080d1cf980d0ce86c38 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:00:06 -0500 Subject: [PATCH 63/66] test: normalize G2 evidence lifecycle fixtures --- scripts/check-g2-evidence-test.py | 50 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/scripts/check-g2-evidence-test.py b/scripts/check-g2-evidence-test.py index dce6a94..2be2b92 100644 --- a/scripts/check-g2-evidence-test.py +++ b/scripts/check-g2-evidence-test.py @@ -7,6 +7,7 @@ import importlib.util import json import pathlib +import re import subprocess import sys import unittest @@ -66,9 +67,38 @@ def assert_structure_rejected(self, workflow: str) -> None: with self.assertRaises(self.checker.EvidenceError): self.checker.validate_ci_structure(workflow) - def passed_evidence(self) -> str: + def candidate_evidence(self, evidence=None) -> str: + candidate = evidence if evidence is not None else self.evidence + fields = { + "Status": "candidate", + "Tested source commit": "not recorded before remote review", + "CI attestation": "not recorded before remote review", + "Coven plan source commit": "not recorded before plan approval", + "Coven plan URL": "not recorded before plan approval", + "Coven plan SHA-256": "not recorded before plan approval", + } + for label, value in fields.items(): + candidate, count = re.subn( + rf"^\*\*{re.escape(label)}:\*\* .*$", + f"**{label}:** {value}", + candidate, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise AssertionError(f"evidence must contain exactly one {label} field") + return re.sub( + r" \| passed \| https://github\.com/OpenCoven/psyche/actions/runs/[0-9]+ \|$", + " | not run remotely | none |", + candidate, + flags=re.MULTILINE, + ) + + def passed_evidence(self, evidence=None) -> str: run_url = "https://github.com/OpenCoven/psyche/actions/runs/123456" - passed = self.evidence.replace("**Status:** candidate", "**Status:** passed") + passed = self.candidate_evidence(evidence).replace( + "**Status:** candidate", "**Status:** passed" + ) passed = passed.replace( "**Tested source commit:** not recorded before remote review", "**Tested source commit:** 0123456789abcdef0123456789abcdef01234567", @@ -94,7 +124,21 @@ def passed_evidence(self) -> str: return passed.replace("not run remotely | none", f"passed | {run_url}") def test_valid_exact_manifest_and_candidate_evidence(self) -> None: - self.assert_valid() + self.assert_valid(evidence=self.candidate_evidence()) + + def test_passed_evidence_is_idempotent_valid_and_still_mutation_sensitive(self) -> None: + passed = self.passed_evidence() + rebuilt = self.passed_evidence(passed) + + self.assertEqual(rebuilt, passed) + self.assert_valid(evidence=rebuilt) + self.assert_rejected( + evidence=rebuilt.replace( + "passed | https://github.com/OpenCoven/psyche/actions/runs/123456", + "ExpectedUnsupported | https://github.com/OpenCoven/psyche/actions/runs/123456", + 1, + ) + ) def test_delivery_v1_documentation_rejects_field_order_mutation(self) -> None: path = "docs/SCHEMAS.md" From f0bd56d288ebb93382912798f3983cd8cc941369 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:37:38 -0500 Subject: [PATCH 64/66] docs: attest Psyche G2 evidence --- docs/G2-EVIDENCE.md | 76 ++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/G2-EVIDENCE.md b/docs/G2-EVIDENCE.md index 81bf23a..f44c9ca 100644 --- a/docs/G2-EVIDENCE.md +++ b/docs/G2-EVIDENCE.md @@ -1,11 +1,11 @@ # G2 Contract Foundation Evidence -**Status:** candidate -**Tested source commit:** not recorded before remote review -**CI attestation:** not recorded before remote review -**Coven plan source commit:** not recorded before plan approval -**Coven plan URL:** not recorded before plan approval -**Coven plan SHA-256:** not recorded before plan approval +**Status:** passed +**Tested source commit:** b4e0a1c6e2f70fc318d82080d1cf980d0ce86c38 +**CI attestation:** https://github.com/OpenCoven/psyche/actions/runs/31287668813 +**Coven plan source commit:** 5f22ebef1e23d045a10f2ec0a3c87be029446cf6 +**Coven plan URL:** https://github.com/OpenCoven/coven/blob/5f22ebef1e23d045a10f2ec0a3c87be029446cf6/docs/superpowers/plans/2026-08-05-psyche-w2-g2-foundation.md +**Coven plan SHA-256:** sha256:4fba002ad9f969cd01866ea08f270654f82b53c7d90b73d28643a9abb12cba68 **Coven specification source commit:** `42dcbc43-34cb48ec-af63efb5-50345e3e-ea2fb7ad` | Coven source | Immutable URL | SHA-256 | @@ -18,35 +18,35 @@ | Criterion | Command | Result | Artifact | |---|---|---|---| -| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | not run remotely | none | -| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | not run remotely | none | -| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | not run remotely | none | -| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | not run remotely | none | -| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | not run remotely | none | -| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | not run remotely | none | -| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | not run remotely | none | -| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | not run remotely | none | -| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | not run remotely | none | -| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | not run remotely | none | -| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | not run remotely | none | -| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | not run remotely | none | -| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | not run remotely | none | -| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | not run remotely | none | -| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | not run remotely | none | -| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | not run remotely | none | -| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | not run remotely | none | -| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | not run remotely | none | -| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | not run remotely | none | -| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | not run remotely | none | -| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | not run remotely | none | -| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | not run remotely | none | -| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | not run remotely | none | -| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | not run remotely | none | -| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | not run remotely | none | -| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | not run remotely | none | -| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | not run remotely | none | -| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | not run remotely | none | -| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | not run remotely | none | -| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | not run remotely | none | -| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | not run remotely | none | -| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | not run remotely | none | +| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | From 75877d78e00d36030d105db0b04b132081814f67 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:19:58 -0500 Subject: [PATCH 65/66] fix: verify G2 evidence in shallow CI --- docs/G2-EVIDENCE.md | 76 ++++++------- scripts/check-g2-evidence-test.py | 172 +++++++++++++++++++++++++++++- scripts/check-g2-evidence.py | 77 ++++++++++++- 3 files changed, 280 insertions(+), 45 deletions(-) diff --git a/docs/G2-EVIDENCE.md b/docs/G2-EVIDENCE.md index f44c9ca..81bf23a 100644 --- a/docs/G2-EVIDENCE.md +++ b/docs/G2-EVIDENCE.md @@ -1,11 +1,11 @@ # G2 Contract Foundation Evidence -**Status:** passed -**Tested source commit:** b4e0a1c6e2f70fc318d82080d1cf980d0ce86c38 -**CI attestation:** https://github.com/OpenCoven/psyche/actions/runs/31287668813 -**Coven plan source commit:** 5f22ebef1e23d045a10f2ec0a3c87be029446cf6 -**Coven plan URL:** https://github.com/OpenCoven/coven/blob/5f22ebef1e23d045a10f2ec0a3c87be029446cf6/docs/superpowers/plans/2026-08-05-psyche-w2-g2-foundation.md -**Coven plan SHA-256:** sha256:4fba002ad9f969cd01866ea08f270654f82b53c7d90b73d28643a9abb12cba68 +**Status:** candidate +**Tested source commit:** not recorded before remote review +**CI attestation:** not recorded before remote review +**Coven plan source commit:** not recorded before plan approval +**Coven plan URL:** not recorded before plan approval +**Coven plan SHA-256:** not recorded before plan approval **Coven specification source commit:** `42dcbc43-34cb48ec-af63efb5-50345e3e-ea2fb7ad` | Coven source | Immutable URL | SHA-256 | @@ -18,35 +18,35 @@ | Criterion | Command | Result | Artifact | |---|---|---|---| -| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | -| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | passed | https://github.com/OpenCoven/psyche/actions/runs/31287668813 | +| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | not run remotely | none | +| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | not run remotely | none | +| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | not run remotely | none | +| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | not run remotely | none | +| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | not run remotely | none | +| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | not run remotely | none | +| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | not run remotely | none | +| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | not run remotely | none | +| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | not run remotely | none | +| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | not run remotely | none | +| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | not run remotely | none | +| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | not run remotely | none | +| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | not run remotely | none | +| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | not run remotely | none | +| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | not run remotely | none | +| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | not run remotely | none | +| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | not run remotely | none | +| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | not run remotely | none | +| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | not run remotely | none | +| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | not run remotely | none | +| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | not run remotely | none | +| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | not run remotely | none | +| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | not run remotely | none | +| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | not run remotely | none | +| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | not run remotely | none | +| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | not run remotely | none | +| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | not run remotely | none | +| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | not run remotely | none | +| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | not run remotely | none | +| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | not run remotely | none | +| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | not run remotely | none | +| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | not run remotely | none | diff --git a/scripts/check-g2-evidence-test.py b/scripts/check-g2-evidence-test.py index 2be2b92..a793d18 100644 --- a/scripts/check-g2-evidence-test.py +++ b/scripts/check-g2-evidence-test.py @@ -6,6 +6,7 @@ import copy import importlib.util import json +import os import pathlib import re import subprocess @@ -123,6 +124,32 @@ def passed_evidence(self, evidence=None) -> str: ) return passed.replace("not run remotely | none", f"passed | {run_url}") + def pull_request_event(self, *, repository="OpenCoven/psyche", head=None): + return { + "repository": {"full_name": "OpenCoven/psyche"}, + "pull_request": { + "head": { + "sha": head or "fedcba9876543210fedcba9876543210fedcba98", + "repo": {"full_name": repository}, + } + }, + } + + def compare_response(self, *, merge_base=None, files=None): + tested = "0123456789abcdef0123456789abcdef01234567" + head = "fedcba9876543210fedcba9876543210fedcba98" + return { + "status": "ahead", + "ahead_by": 1, + "total_commits": 1, + "base_commit": {"sha": tested}, + "merge_base_commit": {"sha": merge_base or tested}, + "commits": [{"sha": head}], + "files": files if files is not None else [ + {"filename": "docs/G2-EVIDENCE.md", "status": "modified"} + ], + } + def test_valid_exact_manifest_and_candidate_evidence(self) -> None: self.assert_valid(evidence=self.candidate_evidence()) @@ -478,13 +505,144 @@ def test_passed_remote_verifier_accepts_exact_ci_attestation(self) -> None: "path": ".github/workflows/ci.yml", "state": "active", } - with mock.patch.object(self.checker.subprocess, "run", side_effect=completed), mock.patch.object( + with mock.patch.dict(os.environ, {"GITHUB_ACTIONS": ""}, clear=False), mock.patch.object( + self.checker.subprocess, "run", side_effect=completed + ), mock.patch.object( self.checker, "run_json", side_effect=(run, rest, workflow) ) as run_json, mock.patch.object(self.checker, "verify_coven_blob") as verify_blob: self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) self.assertEqual(run_json.call_count, 3) self.assertEqual(verify_blob.call_count, 6) + def test_shallow_actions_verifier_uses_event_and_compare_without_local_git(self) -> None: + passed = self.passed_evidence() + tested = "0123456789abcdef0123456789abcdef01234567" + run_url = "https://github.com/OpenCoven/psyche/actions/runs/123456" + run = { + "conclusion": "success", "event": "pull_request", "headSha": tested, + "url": run_url, "workflowName": "CI", + } + rest = { + "conclusion": "success", "event": "pull_request", + "head_repository": {"full_name": "OpenCoven/psyche"}, "head_sha": tested, + "html_url": run_url, "id": 123456, "path": ".github/workflows/ci.yml", + "repository": {"full_name": "OpenCoven/psyche"}, "status": "completed", + "workflow_id": 326408880, + } + workflow = { + "id": 326408880, "name": "CI", "path": ".github/workflows/ci.yml", "state": "active", + } + event = json.dumps(self.pull_request_event()) + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object( + self.checker.subprocess, "run", side_effect=AssertionError("local git must not run in Actions") + ), mock.patch.object( + self.checker, "run_json", side_effect=(self.compare_response(), run, rest, workflow) + ) as run_json, mock.patch.object(self.checker, "verify_coven_blob"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + self.assertEqual( + run_json.call_args_list[0].args[0], + [ + "gh", "api", + "repos/OpenCoven/psyche/compare/" + "0123456789abcdef0123456789abcdef01234567..." + "fedcba9876543210fedcba9876543210fedcba98", + ], + ) + + def test_shallow_actions_verifier_rejects_malformed_event_payload(self) -> None: + passed = self.passed_evidence() + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value="{not-json" + ), mock.patch.object(self.checker, "run_json"): + with self.assertRaisesRegex(self.checker.EvidenceError, "event payload"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_wrong_head_repository(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event(repository="fork/psyche")) + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json"): + with self.assertRaisesRegex(self.checker.EvidenceError, "repository"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_wrong_compare_files(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event()) + compare = self.compare_response(files=[{"filename": "src/main.rs", "status": "modified"}]) + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json", return_value=compare): + with self.assertRaisesRegex(self.checker.EvidenceError, "modified evidence"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_duplicate_missing_or_renamed_file_status(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event()) + evidence = {"filename": "docs/G2-EVIDENCE.md", "status": "modified"} + mutations = ( + [evidence, evidence], + [{"filename": "docs/G2-EVIDENCE.md"}], + [{"filename": "docs/G2-EVIDENCE.md", "status": "renamed"}], + ) + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + for files in mutations: + compare = self.compare_response(files=files) + with self.subTest(files=files), mock.patch.dict( + os.environ, environment, clear=False + ), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json", return_value=compare): + with self.assertRaisesRegex(self.checker.EvidenceError, "modified evidence"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_compare_commit_count_mismatch(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event()) + compare = self.compare_response() + compare["ahead_by"] = 2 + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json", return_value=compare): + with self.assertRaisesRegex(self.checker.EvidenceError, "commit counts"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_missing_empty_or_wrong_terminal_commit(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event()) + wrong = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + mutations = ({}, {"commits": []}, {"commits": [{"sha": wrong}]}) + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + for mutation in mutations: + compare = self.compare_response() + compare.pop("commits", None) + compare.update(mutation) + with self.subTest(mutation=mutation), mock.patch.dict( + os.environ, environment, clear=False + ), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json", return_value=compare): + with self.assertRaisesRegex(self.checker.EvidenceError, "terminal commit"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + + def test_shallow_actions_verifier_rejects_non_ancestor_compare(self) -> None: + passed = self.passed_evidence() + event = json.dumps(self.pull_request_event()) + compare = self.compare_response(merge_base="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + environment = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_PATH": "/tmp/event.json"} + with mock.patch.dict(os.environ, environment, clear=False), mock.patch.object( + self.checker.pathlib.Path, "read_text", return_value=event + ), mock.patch.object(self.checker, "run_json", return_value=compare): + with self.assertRaisesRegex(self.checker.EvidenceError, "ancestor"): + self.checker.verify_passed(ROOT, passed, self.checker.validate_evidence(passed)[1]) + def test_remote_verifier_rejects_wrong_workflow_or_event(self) -> None: passed = self.passed_evidence() baseline = { @@ -499,7 +657,9 @@ def test_remote_verifier_rejects_wrong_workflow_or_event(self) -> None: subprocess.CompletedProcess([], 0, "", ""), subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), ) - with self.subTest(field=field), mock.patch.object( + with self.subTest(field=field), mock.patch.dict( + os.environ, {"GITHUB_ACTIONS": ""}, clear=False + ), mock.patch.object( self.checker.subprocess, "run", side_effect=completed ), mock.patch.object(self.checker, "run_json", return_value={**baseline, field: value}), mock.patch.object( self.checker, "verify_coven_blob" @@ -538,7 +698,9 @@ def test_remote_verifier_rejects_wrong_rest_workflow_path_or_repository(self) -> subprocess.CompletedProcess([], 0, "", ""), subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), ) - with self.subTest(index=index), mock.patch.object( + with self.subTest(index=index), mock.patch.dict( + os.environ, {"GITHUB_ACTIONS": ""}, clear=False + ), mock.patch.object( self.checker.subprocess, "run", side_effect=completed ), mock.patch.object(self.checker, "run_json", side_effect=(view, mutated)), mock.patch.object( self.checker, "verify_coven_blob" @@ -566,7 +728,9 @@ def test_remote_verifier_rejects_inactive_workflow_metadata(self) -> None: subprocess.CompletedProcess([], 0, "", ""), subprocess.CompletedProcess([], 0, "docs/G2-EVIDENCE.md\n", ""), ) - with mock.patch.object(self.checker.subprocess, "run", side_effect=completed), mock.patch.object( + with mock.patch.dict(os.environ, {"GITHUB_ACTIONS": ""}, clear=False), mock.patch.object( + self.checker.subprocess, "run", side_effect=completed + ), mock.patch.object( self.checker, "run_json", side_effect=(view, rest, workflow) ), mock.patch.object(self.checker, "verify_coven_blob"): with self.assertRaisesRegex(self.checker.EvidenceError, "workflow metadata"): diff --git a/scripts/check-g2-evidence.py b/scripts/check-g2-evidence.py index fa46cfc..4cff009 100644 --- a/scripts/check-g2-evidence.py +++ b/scripts/check-g2-evidence.py @@ -6,6 +6,7 @@ import base64 import hashlib import json +import os import pathlib import re import subprocess @@ -790,15 +791,85 @@ def verify_coven_blob(root: pathlib.Path, url: str, expected_digest: str) -> Non fail(f"Coven content SHA-256 disagrees with evidence: {path}") -def verify_passed(root: pathlib.Path, markdown: str, source_rows: list[list[str]]) -> None: - tested = field(markdown, "Tested source commit") - run_url = field(markdown, "CI attestation") +def verify_local_source_relationship(root: pathlib.Path, tested: str) -> None: subprocess.run(["git", "merge-base", "--is-ancestor", tested, "HEAD"], cwd=root, check=True) changed = subprocess.run( ["git", "diff", "--name-only", f"{tested}..HEAD"], cwd=root, text=True, capture_output=True, check=True ).stdout.splitlines() if changed != ["docs/G2-EVIDENCE.md"]: fail(f"passed source-to-HEAD diff is not evidence-only: {changed}") + + +def verify_actions_source_relationship(root: pathlib.Path, tested: str) -> None: + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + fail("GitHub Actions event payload path is absent") + try: + event = json.loads(pathlib.Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"GitHub Actions event payload is invalid: {error}") + if not isinstance(event, dict): + fail("GitHub Actions event payload is not an object") + + repository = event.get("repository") + pull_request = event.get("pull_request") + head = pull_request.get("head") if isinstance(pull_request, dict) else None + head_repository = head.get("repo") if isinstance(head, dict) else None + pr_head = head.get("sha") if isinstance(head, dict) else None + if ( + not isinstance(repository, dict) + or repository.get("full_name") != "OpenCoven/psyche" + or not isinstance(head_repository, dict) + or head_repository.get("full_name") != "OpenCoven/psyche" + ): + fail("GitHub Actions pull-request repository provenance is invalid") + if not isinstance(pr_head, str) or not re.fullmatch(r"[0-9a-f]{40}", pr_head): + fail("GitHub Actions pull-request head provenance is invalid") + + compare = run_json( + ["gh", "api", f"repos/OpenCoven/psyche/compare/{tested}...{pr_head}"], + root, + ) + if not isinstance(compare, dict): + fail("GitHub compare response is invalid") + base = compare.get("base_commit") + merge_base = compare.get("merge_base_commit") + commits = compare.get("commits") + if not isinstance(base, dict) or base.get("sha") != tested: + fail("GitHub compare response does not match the tested source") + if ( + not isinstance(commits, list) + or not commits + or not isinstance(commits[-1], dict) + or commits[-1].get("sha") != pr_head + ): + fail("GitHub compare response does not end at the pull-request terminal commit") + if compare.get("ahead_by") != len(commits) or compare.get("total_commits") != len(commits): + fail("GitHub compare response commit counts are inconsistent") + if ( + compare.get("status") != "ahead" + or not isinstance(merge_base, dict) + or merge_base.get("sha") != tested + ): + fail("tested source is not the pull-request head's merge-base ancestor") + files = compare.get("files") + if ( + not isinstance(files, list) + or len(files) != 1 + or not isinstance(files[0], dict) + or files[0].get("filename") != "docs/G2-EVIDENCE.md" + or files[0].get("status") != "modified" + ): + fail(f"passed source-to-pull-request diff is not one modified evidence file: {files}") + + +def verify_passed(root: pathlib.Path, markdown: str, source_rows: list[list[str]]) -> None: + tested = field(markdown, "Tested source commit") + run_url = field(markdown, "CI attestation") + if os.environ.get("GITHUB_ACTIONS") == "true": + verify_actions_source_relationship(root, tested) + else: + verify_local_source_relationship(root, tested) match = re.fullmatch(r"https://github\.com/(OpenCoven)/(psyche)/actions/runs/([0-9]+)", run_url) if not match: fail("CI attestation URL is malformed") From 29842faa9b1cc205d061170b361627d3b41b9d84 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:51:17 -0500 Subject: [PATCH 66/66] docs: attest Psyche G2 evidence --- docs/G2-EVIDENCE.md | 76 ++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/G2-EVIDENCE.md b/docs/G2-EVIDENCE.md index 81bf23a..0b93e91 100644 --- a/docs/G2-EVIDENCE.md +++ b/docs/G2-EVIDENCE.md @@ -1,11 +1,11 @@ # G2 Contract Foundation Evidence -**Status:** candidate -**Tested source commit:** not recorded before remote review -**CI attestation:** not recorded before remote review -**Coven plan source commit:** not recorded before plan approval -**Coven plan URL:** not recorded before plan approval -**Coven plan SHA-256:** not recorded before plan approval +**Status:** passed +**Tested source commit:** 75877d78e00d36030d105db0b04b132081814f67 +**CI attestation:** https://github.com/OpenCoven/psyche/actions/runs/31290123379 +**Coven plan source commit:** 5f22ebef1e23d045a10f2ec0a3c87be029446cf6 +**Coven plan URL:** https://github.com/OpenCoven/coven/blob/5f22ebef1e23d045a10f2ec0a3c87be029446cf6/docs/superpowers/plans/2026-08-05-psyche-w2-g2-foundation.md +**Coven plan SHA-256:** sha256:4fba002ad9f969cd01866ea08f270654f82b53c7d90b73d28643a9abb12cba68 **Coven specification source commit:** `42dcbc43-34cb48ec-af63efb5-50345e3e-ea2fb7ad` | Coven source | Immutable URL | SHA-256 | @@ -18,35 +18,35 @@ | Criterion | Command | Result | Artifact | |---|---|---|---| -| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | not run remotely | none | -| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | not run remotely | none | -| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | not run remotely | none | -| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | not run remotely | none | -| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | not run remotely | none | -| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | not run remotely | none | -| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | not run remotely | none | -| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | not run remotely | none | -| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | not run remotely | none | -| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | not run remotely | none | -| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | not run remotely | none | -| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | not run remotely | none | -| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | not run remotely | none | -| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | not run remotely | none | -| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | not run remotely | none | -| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | not run remotely | none | -| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | not run remotely | none | -| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | not run remotely | none | -| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | not run remotely | none | -| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | not run remotely | none | -| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | not run remotely | none | -| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | not run remotely | none | -| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | not run remotely | none | -| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | not run remotely | none | -| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | not run remotely | none | -| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | not run remotely | none | -| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | not run remotely | none | -| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | not run remotely | none | -| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | not run remotely | none | -| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | not run remotely | none | -| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | not run remotely | none | -| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | not run remotely | none | +| Canonical ID prefixes and execution-binding identity | `cargo test -p psyche-core --test contracts -- --exact delivery_keeps_the_canonical_del_prefix && cargo test -p psyche-core --test contracts -- --exact delegation_uses_the_distinct_dlg_prefix && cargo test -p psyche-core --test contracts -- --exact execution_binding_uses_attempt_as_its_only_record_kind` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Complete canonical error enum | `cargo test -p psyche-core --test contracts -- --exact all_canonical_error_codes_decode` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Canonical delivery v1 shape | `cargo test -p psyche-core --test contracts -- --exact delivery_v1_fixture_round_trips_canonically && cargo test -p psyche-store --test records -- --exact delivery_direct_insert_round_trips_canonically` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Surface and quarantine owned types | `cargo test -p psyche-core --test contracts -- --exact surface_event_and_effect_fixtures_round_trip && cargo test -p psyche-store --test retention -- --exact quarantine_id_constructor_parser_and_serde_round_trip` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Package-local nullable-binding fixtures | `cargo test -p psyche-core --test contracts -- --exact graph_and_node_accept_only_the_two_frozen_nullable_bindings` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Exhaustive registered decode | `cargo test -p psyche-core --test decode -- --exact recognized_error_envelope_decodes_exhaustively` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Unknown kind/version/enum denial and quarantine | `cargo test -p psyche-core --test decode -- --exact unknown_typed_enum_is_a_quarantinable_decode_failure && cargo test -p psyche-store --test retention -- --exact unknown_enum_is_quarantined_without_dispatchable_record` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Quarantine resolution | `cargo test -p psyche-store --test retention -- --exact quarantine_resolution_is_durable_and_idempotent && cargo test -p psyche-store --test retention -- --exact concurrent_quarantine_resolution_has_one_durable_winner` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Direct typed insert validation | `cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_field_id_kind_without_writing && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_cancellation_without_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledged_state_without_termination_correlation && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_mismatched_cancellation_evidence && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_wrong_termination_request_id && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_termination_before_execution_request && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_acknowledgement_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_outside_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_rejects_unresolved_before_termination_window && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_acknowledgement_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_unresolved_at_termination_window_boundaries && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_window_after_execution_deadline && cargo test -p psyche-store --test records -- --exact direct_insert_accepts_termination_at_execution_creation_boundary` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Append-only execution-binding revisions | `cargo test -p psyche-store --test records -- --exact execution_binding_revision_appends_termination_outcomes_without_record_conflict && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_forks_gaps_and_changed_correlation && cargo test -p psyche-store --test records -- --exact execution_binding_revision_replay_is_idempotent && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_same_revision_changed_bytes && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_changed_reason_replay && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_every_frozen_execution_field_change && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_session_and_termination_rebinding && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_termination_correlation_removal && cargo test -p psyche-store --test records -- --exact execution_binding_revision_rejects_timestamp_regression && cargo test -p psyche-store --test retention -- --exact pruning_preserves_unresolved_quarantine_binding_revisions_and_transitions` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Transition contract and append-only rules | `cargo test -p psyche-store --test records -- --exact transition_versions_are_monotonic_and_append_only` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Checkpoint-failure shutdown | `cargo test -p psyche-runtime --lib -- --exact tests::checkpoint_failure_stops_and_releases_every_shutdown_waiter` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Migrations | `cargo test -p psyche-store --test migrations -- --exact fresh_store_applies_v1_once_and_reopens` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| State-machine/property | `cargo test -p psyche-test-support --test state_machine -- --exact model_and_store_agree_after_any_foundation_operation_sequence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Crash/restart | `cargo test -p psyche-store --features test-fault-injection --test crash -- --exact killed_writer_exposes_only_committed_state_after_reopen` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Fake boundaries and durable termination ordering | `cargo test -p psyche-test-support --test fakes -- --exact advertised_adoption_requires_a_scripted_adoption_step && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_requires_durable_session_bound_revision && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_acknowledged_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_persists_unresolved_outcome_before_success && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_exact_replay_is_idempotent && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_crash_after_response_leaves_recoverable_request && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_restart_recovers_missing_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_conflicting_replay_response && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_invalid_outcome_evidence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_unresolved_outside_termination_window && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_reports_indeterminate_outcome_persistence && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_accepts_concurrent_exact_outcome_replay && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_concurrent_divergent_outcome && cargo test -p psyche-test-support --test fakes -- --exact termination_dispatch_rejects_outcome_byte_attestation_mismatch` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Execution request RFC3339 golden bytes | `cargo test -p psyche-coven --test request_digest -- --exact execution_request_launch_matches_golden_bytes_and_digest && cargo test -p psyche-coven --test request_digest -- --exact execution_request_input_matches_golden_bytes_and_digest` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Validated termination dispatch | `cargo test -p psyche-coven --test bindings -- --exact termination_dispatch_rejects_invalid_request_before_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| G2 cancellation-state vocabulary | `cargo test -p psyche-core --test contracts -- --exact cancellation_state_vocabulary_requires_matching_o5_evidence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| Full execution-request digest binding | `cargo test -p psyche-test-support --test state_machine -- --exact request_digest_binds_every_typed_field` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S1 scripted contract negotiation | `cargo test -p psyche-test-support --test conformance -- --exact c_s1_contract_negotiation` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S2 scripted session lifecycle | `cargo test -p psyche-test-support --test conformance -- --exact c_s2_session_lifecycle` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S3 scripted snapshot/attempt binding | `cargo test -p psyche-test-support --test conformance -- --exact c_s3_snapshot_attempt_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S4 scripted stable adoption | `cargo test -p psyche-test-support --test conformance -- --exact c_s4_stable_adoption` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S5 scripted non-adoption proof | `cargo test -p psyche-test-support --test conformance -- --exact c_s5_non_adoption_proof` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S6 scripted ambiguity reconciliation/fence | `cargo test -p psyche-test-support --test state_machine -- --exact c_s6_model_never_redispatches_without_fence && cargo test -p psyche-test-support --test conformance -- --exact c_s6_ambiguity_fence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S7 scripted ordered cursor | `cargo test -p psyche-test-support --test conformance -- --exact c_s7_ordered_cursor` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S8 scripted terminal authority | `cargo test -p psyche-test-support --test conformance -- --exact c_s8_terminal_authority` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S9 scripted O5 cancellation acknowledgement | `cargo test -p psyche-test-support --test conformance -- --exact c_s9_cancellation_acknowledgement` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S10 scripted result/artifact binding | `cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_round_trips_complete_content_references && cargo test -p psyche-coven --test bindings -- --exact result_bundle_fixture_uses_launch_request_correlation && cargo test -p psyche-coven --test bindings -- --exact content_reference_rejects_digest_size_media_type_and_lifetime_mismatch && cargo test -p psyche-test-support --test conformance -- --exact c_s10_result_artifact_binding` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S11 scripted restart persistence | `cargo test -p psyche-test-support --test conformance -- --exact c_s11_restart_persistence` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 | +| C-S12 scripted structured denial | `cargo test -p psyche-test-support --test conformance -- --exact c_s12_structured_denial` | passed | https://github.com/OpenCoven/psyche/actions/runs/31290123379 |