use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::canonical_hash::{sha256_bytes_hex, sha256_json_prefixed};
use crate::error::{Result, SchemaBreakRecord, ShoreError};
use crate::model::id_prefix;
use crate::session::event::{AssertionMode, EventType, ShoreEvent, event_type_from_code};
use crate::session::store::backend::{Journal, JournalEntry, LocalJournal, StoreBackend};
use crate::storage::{CreateOutcome, LocalStorage};
#[derive(Debug)]
pub struct EventStore {
journal: Box<dyn Journal>,
files: Option<LocalEventFiles>,
}
#[derive(Debug)]
struct LocalEventFiles {
store_dir: PathBuf,
storage: LocalStorage,
}
impl LocalEventFiles {
fn new(store_dir: impl AsRef<Path>) -> Self {
let store_dir = store_dir.as_ref().to_path_buf();
Self {
storage: LocalStorage::new(&store_dir),
store_dir,
}
}
}
impl EventStore {
pub fn open(store_dir: impl AsRef<Path>) -> Self {
let store_dir = store_dir.as_ref().to_path_buf();
Self {
journal: Box::new(LocalJournal::new(&store_dir)),
files: Some(LocalEventFiles::new(store_dir)),
}
}
pub(crate) fn from_backend(backend: &StoreBackend) -> Self {
let journal = backend.journal();
let files = match backend {
StoreBackend::Local(store_dir) => Some(LocalEventFiles::new(store_dir)),
#[cfg(test)]
StoreBackend::Memory(_) => None,
};
Self { journal, files }
}
fn files(&self) -> &LocalEventFiles {
self.files
.as_ref()
.expect("event file-path helpers require a file-backed store")
}
pub(crate) fn events_dir(&self) -> PathBuf {
self.files().store_dir.join("events")
}
pub(crate) fn event_path_for_idempotency_key(&self, idempotency_key: &str) -> PathBuf {
self.events_dir()
.join(format!("{}.json", event_filename_stem(idempotency_key)))
}
pub fn record_event_once(&self, event: &ShoreEvent) -> Result<EventWriteOutcome> {
let span = tracing::debug_span!(
"event_store.record_event_once",
event_id = event.event_id.as_str(),
event_type = ?event.event_type,
idempotency_key = event.idempotency_key.as_str(),
);
let _entered = span.enter();
validate_event(event, None)?;
let bytes = serde_json::to_vec(event)?;
match self
.journal
.create_event_once(&event.idempotency_key, &bytes)?
{
CreateOutcome::Created => {
tracing::debug!(
idempotency_key = %event.idempotency_key,
"event_store_write_created"
);
Ok(EventWriteOutcome::Created)
}
CreateOutcome::AlreadyExists => {
let existing = self.read_stored_event(&event.idempotency_key)?;
if existing.payload_hash == event.payload_hash {
if event_signature_binding_matches(&existing, event) {
tracing::debug!(
idempotency_key = %event.idempotency_key,
"event_store_write_existing"
);
Ok(EventWriteOutcome::Existing)
} else if existing.event_record_hash()? == event.event_record_hash()? {
tracing::debug!(
idempotency_key = %event.idempotency_key,
"event_store_write_existing_divergent_signature"
);
Ok(EventWriteOutcome::ExistingDivergentSignature)
} else {
tracing::debug!(
idempotency_key = %event.idempotency_key,
"event_store_write_existing"
);
Ok(EventWriteOutcome::Existing)
}
} else {
Err(ShoreError::Message(format!(
"event conflict for idempotency key {}",
event.idempotency_key
)))
}
}
}
}
pub fn read_event(&self, path: &Path) -> Result<ShoreEvent> {
let bytes = self.files().storage.read_bytes(path)?;
Self::decode_validated_event(&bytes, Some(path))
}
fn read_stored_event(&self, idempotency_key: &str) -> Result<ShoreEvent> {
let bytes = self
.journal
.read_event_bytes(idempotency_key)?
.ok_or_else(|| {
ShoreError::Message(format!(
"stored event for idempotency key {idempotency_key} disappeared during write"
))
})?;
let event = Self::decode_validated_event(&bytes, None)?;
if event.idempotency_key != idempotency_key {
return Err(filename_idempotency_mismatch(idempotency_key));
}
Ok(event)
}
fn decode_validated_entry(entry: &JournalEntry) -> Result<ShoreEvent> {
let event = Self::decode_validated_event(&entry.bytes, None)?;
if event_filename_stem(&event.idempotency_key) != entry.key_digest {
return Err(filename_idempotency_mismatch(&event.idempotency_key));
}
Ok(event)
}
fn decode_validated_event(bytes: &[u8], path: Option<&Path>) -> Result<ShoreEvent> {
#[derive(serde::Deserialize)]
struct EventProbe<'a> {
#[serde(rename = "eventType", borrow)]
event_type: &'a str,
#[serde(default)]
writer: WriterProbe,
#[serde(default)]
target: TargetProbe,
}
#[derive(Default, serde::Deserialize)]
struct WriterProbe {
role: Option<serde::de::IgnoredAny>,
tool: Option<serde::de::IgnoredAny>,
}
#[derive(Default, serde::Deserialize)]
struct TargetProbe {
subject: Option<serde::de::IgnoredAny>,
}
let probe: EventProbe<'_> = serde_json::from_slice(bytes)?;
if let Some(record) = schema_break_for(probe.event_type) {
return Err(ShoreError::UnsupportedEventType(record));
}
if event_type_from_code(probe.event_type).is_none() {
return Err(ShoreError::UnsupportedEventType(
schema_break_for("eventType.snake_case")
.expect("the pre-opaque-code event-type encoding has a break record"),
));
}
if probe.target.subject.is_some() {
return Err(ShoreError::UnsupportedEventEnvelope(
schema_break_for("target.subject")
.expect("the pre-opaque-code structural subject has a break record"),
));
}
if probe.writer.role.is_some() {
return Err(ShoreError::UnsupportedEventEnvelope(
schema_break_for("writer.role").expect("writer.role has a break record"),
));
}
if probe.writer.tool.is_some() {
return Err(ShoreError::UnsupportedEventEnvelope(
schema_break_for("writer.tool").expect("writer.tool has a break record"),
));
}
let event: ShoreEvent = serde_json::from_slice(bytes)?;
validate_event(&event, path)?;
Ok(event)
}
pub fn list_events(&self) -> Result<Vec<ShoreEvent>> {
self.journal
.list_event_entries()?
.iter()
.map(Self::decode_validated_entry)
.collect()
}
pub fn list_events_lenient(&self) -> Result<(Vec<ShoreEvent>, Vec<SkippedEvent>)> {
let mut events = Vec::new();
let mut skipped = Vec::new();
for entry in self.journal.list_event_entries()? {
match Self::decode_validated_entry(&entry) {
Ok(event) => events.push(event),
Err(ShoreError::UnsupportedEventType(record)) => skipped.push(SkippedEvent {
code: "unsupported_event_type",
record,
}),
Err(ShoreError::UnsupportedEventEnvelope(record)) => skipped.push(SkippedEvent {
code: "unsupported_event_envelope",
record,
}),
Err(other) => return Err(other),
}
}
Ok((events, skipped))
}
#[cfg(test)]
pub(crate) fn list_event_file_names(&self) -> Result<Vec<String>> {
Ok(self
.files()
.storage
.list_dir(&self.events_dir())?
.into_iter()
.filter(|path| is_event_file(path))
.filter_map(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
})
.collect())
}
pub(crate) fn event_exists(&self, idempotency_key: &str) -> Result<bool> {
self.journal.event_exists(idempotency_key)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventWriteOutcome {
Created,
Existing,
ExistingDivergentSignature,
}
impl EventWriteOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Created => "created",
Self::Existing => "existing",
Self::ExistingDivergentSignature => "existing_divergent_signature",
}
}
}
impl std::fmt::Display for EventWriteOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug)]
pub struct SkippedEvent {
pub code: &'static str,
pub record: SchemaBreakRecord,
}
fn event_signature_binding_matches(existing: &ShoreEvent, candidate: &ShoreEvent) -> bool {
existing.signer == candidate.signer && existing.signature == candidate.signature
}
fn validate_event(event: &ShoreEvent, path: Option<&Path>) -> Result<()> {
event.validate_schema_version()?;
if event.event_type == EventType::ReviewAssessmentRecorded
&& event.assertion_mode != AssertionMode::Operative
{
return Err(ShoreError::InvalidEvent {
message: "review_assessment_recorded events must use assertionMode Operative"
.to_owned(),
});
}
let expected_event_id = format!(
"{}:sha256:{}",
id_prefix::EVENT,
sha256_bytes_hex(event.idempotency_key.as_bytes())
);
if event.event_id.as_str() != expected_event_id {
return Err(ShoreError::Message(format!(
"eventId mismatch for idempotency key {}",
event.idempotency_key
)));
}
let expected_payload_hash = sha256_json_prefixed(&event.payload)?;
if event.payload_hash != expected_payload_hash {
return Err(ShoreError::Message(format!(
"payloadHash mismatch for event {}",
event.event_id.as_str()
)));
}
if let Some(path) = path
&& let Some(stem) = path.file_stem().and_then(|stem| stem.to_str())
{
let expected_stem = event_filename_stem(&event.idempotency_key);
if stem != expected_stem {
return Err(filename_idempotency_mismatch(&event.idempotency_key));
}
}
Ok(())
}
fn filename_idempotency_mismatch(idempotency_key: &str) -> ShoreError {
ShoreError::Message(format!(
"event filename does not match idempotencyKey for {idempotency_key}"
))
}
pub(in crate::session::store) fn event_filename_stem(idempotency_key: &str) -> String {
sha256_bytes_hex(idempotency_key.as_bytes())
}
pub(in crate::session::store) fn is_event_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.len() == 69 && name.ends_with(".json"))
}
fn schema_break_for(retired: &str) -> Option<SchemaBreakRecord> {
let (broken_at, anchor) = match retired {
"review_disposition_recorded" => {
("0.1", "docs/assessment-model.md#legacy-disposition-events")
}
"intervention_requested" | "intervention_resolved" => (
"0.1",
"docs/input-request-model.md#legacy-intervention-events",
),
"writer.role" => ("0.1", "docs/storage-model.md#legacy-writer-role-events"),
"writer.tool" => ("0.1", "docs/storage-model.md#legacy-writer-tool-events"),
"eventType.snake_case" | "target.subject" => (
"0.1",
"docs/store-migration.md#1-a-fail-loud-strict-reader-not-a-dual-read",
),
_ => return None,
};
Some(SchemaBreakRecord {
retired: retired.to_owned(),
broken_at: broken_at.to_owned(),
anchor: anchor.to_owned(),
})
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::crypto::SignerId;
use crate::model::{AssessmentId, JournalId, ReviewTargetRef, RevisionId, TargetRef, TrackId};
use crate::session::event::{
AssertionMode, EventSignature, EventTarget, EventType, IngestProvenance, IngestVia,
ReviewAssessment, ReviewAssessmentRecordedPayload, ReviewInitializedPayload, ShoreEvent,
Writer,
};
use crate::session::state::SessionState;
#[test]
fn schema_break_for_covers_every_retired_identifier() {
for (retired, anchor_suffix) in [
(
"review_disposition_recorded",
"assessment-model.md#legacy-disposition-events",
),
(
"intervention_requested",
"input-request-model.md#legacy-intervention-events",
),
(
"intervention_resolved",
"input-request-model.md#legacy-intervention-events",
),
("writer.role", "storage-model.md#legacy-writer-role-events"),
("writer.tool", "storage-model.md#legacy-writer-tool-events"),
(
"eventType.snake_case",
"store-migration.md#1-a-fail-loud-strict-reader-not-a-dual-read",
),
(
"target.subject",
"store-migration.md#1-a-fail-loud-strict-reader-not-a-dual-read",
),
] {
let record =
schema_break_for(retired).expect("a retired identifier has a break record");
assert_eq!(record.retired, retired);
assert!(!record.broken_at.is_empty());
assert!(
record.anchor.ends_with(anchor_suffix),
"anchor was {}",
record.anchor
);
}
assert!(schema_break_for("review_observation_recorded").is_none());
}
#[test]
fn pre_opaque_code_snake_case_event_type_is_rejected_with_a_break_record() {
let bytes = br#"{"eventType":"review_observation_recorded"}"#;
let err = EventStore::decode_validated_event(bytes, None).unwrap_err();
match err {
ShoreError::UnsupportedEventType(record) => {
assert_eq!(record.retired, "eventType.snake_case");
assert!(!record.broken_at.is_empty());
assert!(
record.anchor.contains("store-migration"),
"{}",
record.anchor
);
}
other => panic!("expected UnsupportedEventType, got {other:?}"),
}
}
#[test]
fn pre_opaque_code_structural_subject_envelope_is_rejected_with_a_break_record() {
let bytes = br#"{"eventType":"t:03","target":{"journalId":"journal:x","subject":{"review":{"kind":"revision","revisionId":"rev:sha256:a"}}}}"#;
let err = EventStore::decode_validated_event(bytes, None).unwrap_err();
match err {
ShoreError::UnsupportedEventEnvelope(record) => {
assert_eq!(record.retired, "target.subject");
assert!(
record.anchor.contains("store-migration"),
"{}",
record.anchor
);
}
other => panic!("expected UnsupportedEventEnvelope, got {other:?}"),
}
}
#[test]
fn event_path_is_sha256_of_idempotency_key() {
let root = tempfile::tempdir().unwrap();
let store = EventStore::open(root.path().join(".shore/data"));
let path =
store.event_path_for_idempotency_key("review_initialized:review:default:work:default");
assert_eq!(
path.parent().unwrap(),
root.path().join(".shore/data/events")
);
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
"922a9f73c057fa93d31156c391cb0ca441dfa8c1f3cd9cf94a497e8f309675be.json"
);
}
#[test]
fn recording_same_event_twice_returns_existing_without_rewriting() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
assert_eq!(
store.record_event_once(&event).unwrap(),
EventWriteOutcome::Created
);
assert!(store.event_exists(&event.idempotency_key).unwrap());
assert_eq!(
store.record_event_once(&event).unwrap(),
EventWriteOutcome::Existing
);
assert_eq!(store.list_events().unwrap(), vec![event]);
}
#[test]
fn replay_with_new_occurred_at_returns_existing_when_payload_matches() {
let (_root, store) = temp_event_store();
let first = review_initialized_event_at("2026-05-10T00:00:00Z");
let retry = review_initialized_event_at("2026-05-10T00:01:00Z");
store.record_event_once(&first).unwrap();
assert_eq!(
store.record_event_once(&retry).unwrap(),
EventWriteOutcome::Existing
);
assert_eq!(store.list_events().unwrap(), vec![first]);
}
#[test]
fn same_payload_hash_but_different_signature_returns_existing_divergent_signature() {
let (_root, store) = temp_event_store();
let first = signed_review_initialized_event(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
);
let second = signed_review_initialized_event(
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",
);
assert_eq!(first.idempotency_key, second.idempotency_key);
assert_eq!(first.payload_hash, second.payload_hash);
assert_eq!(
store.record_event_once(&first).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store.record_event_once(&second).unwrap(),
EventWriteOutcome::ExistingDivergentSignature
);
assert_eq!(store.list_events().unwrap(), vec![first]);
}
#[test]
fn divergent_signature_with_differing_event_record_hash_is_plain_existing() {
let (_root, store) = temp_event_store();
let first = signed_review_initialized_event(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
);
let mut second = signed_review_initialized_event(
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",
);
second.occurred_at = "2026-06-04T00:00:00Z".to_owned();
assert_eq!(first.idempotency_key, second.idempotency_key);
assert_eq!(first.payload_hash, second.payload_hash);
assert_ne!(
first.event_record_hash().unwrap(),
second.event_record_hash().unwrap()
);
store.record_event_once(&first).unwrap();
assert_eq!(
store.record_event_once(&second).unwrap(),
EventWriteOutcome::Existing
);
}
#[test]
fn same_idempotency_key_with_conflicting_payload_is_an_error() {
let (_root, store) = temp_event_store();
let first = review_initialized_event();
let conflicting = conflicting_event_with_same_idempotency_key(&first);
store.record_event_once(&first).unwrap();
let error = store
.record_event_once(&conflicting)
.expect_err("conflict is rejected");
assert!(error.to_string().contains("conflict"));
}
#[test]
fn record_event_rejects_advisory_review_assessment_recorded() {
let (_root, store) = temp_event_store();
let mut event = review_assessment_recorded_event();
event.assertion_mode = AssertionMode::Advisory;
let error = store
.record_event_once(&event)
.expect_err("advisory assessment event is invalid");
assert!(error.to_string().contains("assertionMode Operative"));
}
#[test]
fn role_free_stored_events_read_cleanly() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
let path = store.event_path_for_idempotency_key(&event.idempotency_key);
store.record_event_once(&event).unwrap();
let read = store.read_event(&path).expect("current-shape event reads");
assert_eq!(read, event);
}
#[test]
fn read_event_accepts_producer_keyed_envelope() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
let path = store.event_path_for_idempotency_key(&event.idempotency_key);
store.record_event_once(&event).unwrap();
let read = store
.read_event(&path)
.expect("producer-keyed event reads cleanly");
assert_eq!(read, event);
}
#[test]
fn read_event_rejects_filename_idempotency_mismatch() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
let wrong_path = store.events_dir().join(format!("{}.json", "0".repeat(64)));
std::fs::create_dir_all(store.events_dir()).unwrap();
fs::write(&wrong_path, serde_json::to_vec(&event).unwrap()).unwrap();
let error = store
.read_event(&wrong_path)
.expect_err("filename mismatch is rejected");
assert!(error.to_string().contains("idempotencyKey"));
}
#[test]
fn list_events_ignores_temp_files_and_unknown_suffixes() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
store.record_event_once(&event).unwrap();
fs::write(
store.events_dir().join(".shore-write.partial.tmp"),
b"partial",
)
.unwrap();
fs::write(store.events_dir().join("README.txt"), b"ignore me").unwrap();
assert_eq!(store.list_events().unwrap(), vec![event]);
}
#[test]
fn list_event_file_names_ignores_temp_files_and_unknown_suffixes() {
let (_root, store) = temp_event_store();
let event = review_initialized_event();
store.record_event_once(&event).unwrap();
fs::write(
store.events_dir().join(".shore-write.partial.tmp"),
b"partial",
)
.unwrap();
fs::write(store.events_dir().join("README.txt"), b"ignore me").unwrap();
let expected = format!("{}.json", event_filename_stem(&event.idempotency_key));
assert_eq!(store.list_event_file_names().unwrap(), vec![expected]);
}
#[test]
fn list_event_file_names_with_missing_events_dir_is_empty() {
let (_root, store) = temp_event_store();
assert_eq!(store.list_event_file_names().unwrap(), Vec::<String>::new());
}
#[test]
fn record_event_once_is_existing_across_ingest_stamp_differences_first_stored_wins() {
let (_root, store) = temp_event_store();
let unstamped = review_initialized_event();
let mut stamped = unstamped.clone();
stamped.ingest = Some(IngestProvenance {
via: IngestVia::IngestEvents,
received_at: "unix-ms:1760000000000".to_owned(),
});
assert_eq!(
store.record_event_once(&unstamped).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store.record_event_once(&stamped).unwrap(),
EventWriteOutcome::Existing
);
let path = store.event_path_for_idempotency_key(&unstamped.idempotency_key);
assert!(store.read_event(&path).unwrap().ingest.is_none());
let (_root_two, store_two) = temp_event_store();
let mut later_stamp = unstamped.clone();
later_stamp.ingest = Some(IngestProvenance {
via: IngestVia::BundleApply,
received_at: "unix-ms:1760000000001".to_owned(),
});
assert_eq!(
store_two.record_event_once(&stamped).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store_two.record_event_once(&later_stamp).unwrap(),
EventWriteOutcome::Existing
);
let path_two = store_two.event_path_for_idempotency_key(&stamped.idempotency_key);
assert_eq!(
store_two.read_event(&path_two).unwrap().ingest,
stamped.ingest
);
}
#[test]
fn payload_hash_is_stable_across_a_journal_round_trip() {
let (_root, store) = temp_event_store();
assert_payload_hash_is_stable_across_a_round_trip(&store);
}
#[test]
fn payload_hash_is_stable_across_a_round_trip_on_the_in_memory_backend() {
assert_payload_hash_is_stable_across_a_round_trip(&in_memory_event_store());
}
#[test]
fn co_signature_decision_holds_over_the_in_memory_backend() {
let store = in_memory_event_store();
let first = review_initialized_event();
assert_eq!(
store.record_event_once(&first).unwrap(),
EventWriteOutcome::Created
);
assert!(store.event_exists(&first.idempotency_key).unwrap());
let retry = review_initialized_event_at("2026-05-10T00:01:00Z");
assert_eq!(
store.record_event_once(&retry).unwrap(),
EventWriteOutcome::Existing
);
assert_eq!(store.list_events().unwrap(), vec![first]);
let signed_store = in_memory_event_store();
let signed_a = signed_review_initialized_event(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
);
let signed_b = signed_review_initialized_event(
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",
);
assert_eq!(
signed_store.record_event_once(&signed_a).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
signed_store.record_event_once(&signed_b).unwrap(),
EventWriteOutcome::ExistingDivergentSignature
);
let conflict_store = in_memory_event_store();
let base = review_initialized_event();
let conflicting = conflicting_event_with_same_idempotency_key(&base);
conflict_store.record_event_once(&base).unwrap();
assert!(
conflict_store
.record_event_once(&conflicting)
.unwrap_err()
.to_string()
.contains("conflict")
);
}
#[test]
fn list_events_rejects_payload_hash_mismatch_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
json["payloadHash"] = serde_json::json!("sha256:wrong");
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("a payloadHash mismatch is rejected on replay");
assert!(error.to_string().contains("payloadHash"));
});
}
#[test]
fn list_events_rejects_event_id_mismatch_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
json["eventId"] = serde_json::json!("evt:sha256:wrong");
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("an eventId mismatch is rejected on replay");
assert!(error.to_string().contains("eventId"));
});
}
#[test]
fn list_events_rejects_retired_event_type_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
json["eventType"] = serde_json::json!("review_disposition_recorded");
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("a retired event type is rejected on replay");
assert!(matches!(
error,
ShoreError::UnsupportedEventType(ref record)
if record.retired == "review_disposition_recorded"
));
});
}
#[test]
fn list_events_rejects_legacy_writer_role_envelope_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
json["writer"]["role"] = serde_json::json!("reviewer");
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("a role-bearing envelope is rejected on replay");
assert!(matches!(error, ShoreError::UnsupportedEventEnvelope(_)));
assert!(
error
.to_string()
.contains("docs/storage-model.md#legacy-writer-role-events")
);
});
}
#[test]
fn list_events_rejects_legacy_writer_tool_envelope_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
let writer = json["writer"].as_object_mut().unwrap();
writer.remove("producer");
writer.insert(
"tool".to_owned(),
serde_json::json!({ "name": "shore", "version": "0.1.0" }),
);
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("a tool-bearing envelope is rejected on replay");
assert!(matches!(error, ShoreError::UnsupportedEventEnvelope(_)));
assert!(
error
.to_string()
.contains("docs/storage-model.md#legacy-writer-tool-events")
);
assert!(error.to_string().contains("writer.tool"));
});
}
#[test]
fn list_events_rejects_pre_reshape_target_envelope_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
let mut json = serde_json::to_value(&event).unwrap();
json["target"] = serde_json::json!({
"sessionId": "session:default",
"reviewUnitId": "review-unit:sha256:legacy",
"revisionId": "rev:git:sha256:legacy",
"snapshotId": "snap:git:sha256:legacy",
});
journal
.insert_raw(&event.idempotency_key, &serde_json::to_vec(&json).unwrap())
.unwrap();
let error = store
.list_events()
.expect_err("a pre-reshape target envelope is rejected on replay");
let message = error.to_string();
assert!(
message.contains("journalId") || message.contains("subject"),
"rejection names a missing reshaped-target field; got: {error}"
);
});
}
#[test]
fn list_events_rejects_a_mislocated_entry_over_every_backend() {
for_each_event_backend(|store, journal| {
let event = review_initialized_event();
journal
.insert_raw("not:this:events:home", &serde_json::to_vec(&event).unwrap())
.unwrap();
assert!(
store
.list_events()
.expect_err("a mislocated entry is rejected on replay")
.to_string()
.contains("idempotencyKey")
);
assert!(store.list_events_lenient().is_err());
});
}
#[test]
fn list_events_lenient_skips_retired_schemas_over_every_backend() {
for_each_event_backend(|store, journal| {
let valid = review_initialized_event();
store.record_event_once(&valid).unwrap();
journal
.insert_raw(
"retired:type:key",
br#"{"eventType":"review_disposition_recorded"}"#,
)
.unwrap();
journal
.insert_raw(
"retired:opaque-code:key",
br#"{"eventType":"review_observation_recorded"}"#,
)
.unwrap();
journal
.insert_raw(
"retired:envelope:key",
br#"{"eventType":"t:01","writer":{"role":"x"}}"#,
)
.unwrap();
let (events, skipped) = store.list_events_lenient().unwrap();
assert_eq!(events.len(), 1, "the one valid event survives");
assert_eq!(skipped.len(), 3);
let codes: std::collections::BTreeSet<_> = skipped.iter().map(|s| s.code).collect();
assert!(codes.contains(&"unsupported_event_type"));
assert!(codes.contains(&"unsupported_event_envelope"));
});
}
#[test]
fn list_events_lenient_hard_errors_on_genuine_corruption_over_every_backend() {
for_each_event_backend(|store, journal| {
journal
.insert_raw("corrupt:key", br#"{"eventType":"t:01"}"#)
.unwrap();
assert!(store.list_events_lenient().is_err());
});
}
fn assert_payload_hash_is_stable_across_a_round_trip(store: &EventStore) {
let mut event = review_initialized_event();
event.payload = serde_json::json!({
"zeta": 1,
"alpha": -9_007_199_254_740_993_i64,
"huge": 9_007_199_254_740_993_i64,
"text": "café ☕ 日本語 — déjà vu",
"nested": { "b": 2, "a": 1 },
});
event.payload_hash = sha256_json_prefixed(&event.payload).unwrap();
let original_hash = event.payload_hash.clone();
store.record_event_once(&event).unwrap();
let read_back = store.list_events().unwrap();
assert_eq!(read_back.len(), 1);
assert_eq!(read_back[0].payload_hash, original_hash);
assert_eq!(
sha256_json_prefixed(&read_back[0].payload).unwrap(),
original_hash,
"the payload digest re-derives identically after a storage round trip"
);
}
#[test]
fn listing_order_is_hash_sorted_and_renders_a_stable_projection() {
let (_root, store) = temp_event_store();
for session in ["session:c", "session:a", "session:b", "session:d"] {
store
.record_event_once(&review_initialized_event_for(session))
.unwrap();
}
let by_listing = store.list_events().unwrap();
let listed_file_names: Vec<String> = by_listing
.iter()
.map(|event| format!("{}.json", event_filename_stem(&event.idempotency_key)))
.collect();
assert_eq!(
listed_file_names,
store.list_event_file_names().unwrap(),
"the byte listing is in the same hash-sorted order as the file names"
);
let by_file_name: Vec<ShoreEvent> = store
.list_event_file_names()
.unwrap()
.into_iter()
.map(|name| store.read_event(&store.events_dir().join(name)).unwrap())
.collect();
let from_listing =
serde_json::to_vec(&SessionState::from_events(&by_listing).unwrap()).unwrap();
let from_file_name =
serde_json::to_vec(&SessionState::from_events(&by_file_name).unwrap()).unwrap();
assert_eq!(from_listing, from_file_name);
}
fn review_initialized_event_for(session: &str) -> ShoreEvent {
ShoreEvent::new(
EventType::ReviewInitialized,
format!("review_initialized:{session}:work:default"),
EventTarget::for_journal(JournalId::new(session)),
Writer::shore_local("0.1.0"),
ReviewInitializedPayload {},
"2026-05-10T00:00:00Z",
)
.expect("event builds")
}
fn temp_event_store() -> (tempfile::TempDir, EventStore) {
let root = tempfile::tempdir().unwrap();
let store = EventStore::open(root.path().join(".shore/data"));
(root, store)
}
fn in_memory_event_store() -> EventStore {
EventStore::from_backend(&StoreBackend::memory())
}
fn for_each_event_backend(mut assertion: impl FnMut(&EventStore, &dyn Journal)) {
let root = tempfile::tempdir().unwrap();
let local = StoreBackend::Local(root.path().join(".shore/data"));
let local_journal = local.journal();
assertion(&EventStore::from_backend(&local), local_journal.as_ref());
let memory = StoreBackend::memory();
let memory_journal = memory.journal();
assertion(&EventStore::from_backend(&memory), memory_journal.as_ref());
}
fn review_initialized_event() -> ShoreEvent {
review_initialized_event_at("2026-05-10T00:00:00Z")
}
fn review_initialized_event_at(occurred_at: &str) -> ShoreEvent {
ShoreEvent::new(
EventType::ReviewInitialized,
"review_initialized:session:default:work:default",
EventTarget::for_journal(JournalId::new("journal:default")),
Writer::shore_local("0.1.0"),
ReviewInitializedPayload {},
occurred_at,
)
.expect("event builds")
}
fn signed_review_initialized_event(signature: &str) -> ShoreEvent {
let mut event = review_initialized_event();
event.signer = Some(
SignerId::parse("did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd").unwrap(),
);
event.signature = Some(EventSignature::new_ed25519_v1(signature).unwrap());
event
}
fn review_assessment_recorded_event() -> ShoreEvent {
let revision_id = RevisionId::new("review-unit:sha256:one");
let track_id = TrackId::new("human:kevin");
let assessment_id = AssessmentId::new("assess:sha256:one");
let target_ref = ReviewTargetRef::Revision {
revision_id: revision_id.clone(),
};
ShoreEvent::new(
EventType::ReviewAssessmentRecorded,
ReviewAssessmentRecordedPayload::idempotency_key(
&revision_id,
&track_id,
assessment_id.as_str(),
),
EventTarget::for_subject(
JournalId::new("journal:default"),
TargetRef::Review(target_ref.clone()),
Some(track_id),
)
.unwrap(),
Writer::shore_local("test"),
ReviewAssessmentRecordedPayload {
assessment_id,
target: target_ref,
assessment: ReviewAssessment::Accepted,
summary: Some("Ship it".to_owned()),
summary_content_type: Default::default(),
summary_artifact_path: None,
summary_byte_size: Some(7),
summary_content_hash: Some("sha256:summary".to_owned()),
replaces_assessment_ids: Vec::new(),
related_observation_ids: Vec::new(),
related_input_request_ids: Vec::new(),
},
"2026-05-10T00:00:00Z",
)
.expect("event builds")
}
fn conflicting_event_with_same_idempotency_key(event: &ShoreEvent) -> ShoreEvent {
ShoreEvent::new(
EventType::ReviewAssessmentRecorded,
event.idempotency_key.clone(),
event.target.clone(),
event.writer.clone(),
ReviewAssessmentRecordedPayload {
assessment_id: AssessmentId::new("assess:sha256:conflict"),
target: ReviewTargetRef::Revision {
revision_id: RevisionId::new("review-unit:sha256:one"),
},
assessment: ReviewAssessment::NeedsChanges,
summary: Some("Conflicting payload".to_owned()),
summary_content_type: Default::default(),
summary_artifact_path: None,
summary_byte_size: Some(19),
summary_content_hash: Some("sha256:conflict".to_owned()),
replaces_assessment_ids: Vec::new(),
related_observation_ids: Vec::new(),
related_input_request_ids: Vec::new(),
},
event.occurred_at.clone(),
)
.expect("conflicting event builds")
}
}