From 011ee2af845ec127da82f2f021d80bd9aed872ed Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 15:21:26 +0530 Subject: [PATCH 1/4] feat: add transactional SQLite event store --- Cargo.lock | 210 +++++++ Cargo.toml | 3 +- README.md | 19 +- crates/autophagy-events/src/event.rs | 46 ++ crates/autophagy-store/Cargo.toml | 23 + .../migrations/0001_initial.sql | 187 ++++++ crates/autophagy-store/src/error.rs | 74 +++ crates/autophagy-store/src/lib.rs | 18 + crates/autophagy-store/src/migration.rs | 122 ++++ crates/autophagy-store/src/model.rs | 123 ++++ crates/autophagy-store/src/store.rs | 574 ++++++++++++++++++ crates/autophagy-store/src/util.rs | 14 + crates/autophagy-store/tests/store.rs | 448 ++++++++++++++ docs/architecture/database-schema.md | 67 +- docs/architecture/repository-structure.md | 5 +- 15 files changed, 1911 insertions(+), 22 deletions(-) create mode 100644 crates/autophagy-store/Cargo.toml create mode 100644 crates/autophagy-store/migrations/0001_initial.sql create mode 100644 crates/autophagy-store/src/error.rs create mode 100644 crates/autophagy-store/src/lib.rs create mode 100644 crates/autophagy-store/src/migration.rs create mode 100644 crates/autophagy-store/src/model.rs create mode 100644 crates/autophagy-store/src/store.rs create mode 100644 crates/autophagy-store/src/util.rs create mode 100644 crates/autophagy-store/tests/store.rs diff --git a/Cargo.lock b/Cargo.lock index 24ef5ef..9470b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,19 @@ dependencies = [ "time", ] +[[package]] +name = "autophagy-store" +version = "0.1.0-alpha.1" +dependencies = [ + "autophagy-events", + "rusqlite", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "time", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -69,6 +82,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -87,12 +109,46 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -109,6 +165,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -135,6 +202,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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 = "fancy-regex" version = "0.18.0" @@ -146,6 +235,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fluent-uri" version = "0.4.1" @@ -198,6 +299,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -364,6 +474,23 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -516,6 +643,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -630,6 +763,32 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -685,6 +844,23 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "smallvec" version = "1.15.2" @@ -719,6 +895,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -780,6 +969,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -808,6 +1003,12 @@ dependencies = [ "vsimd", ] +[[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" @@ -880,6 +1081,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index fbce8f8..f98d5be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/autophagy-events"] +members = ["crates/autophagy-events", "crates/autophagy-store"] [workspace.package] version = "0.1.0-alpha.1" @@ -12,6 +12,7 @@ repository = "https://github.com/karnstack/autophagy" [workspace.dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.11" thiserror = "2.0" time = { version = "0.3", features = ["formatting", "parsing", "serde"] } diff --git a/README.md b/README.md index 8887efe..1066769 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ change because of what happened?” ## Status -Autophagy is in foundation development. The first pull request defines Agent -Event Protocol (AEP) v0.1 and the Rust types that all collectors, storage, and -detectors will share. No daemon, session importer, or background capture ships -yet. +Autophagy is in foundation development. Agent Event Protocol (AEP) v0.1 and the +transactional local SQLite event store are implemented. No daemon, session +importer, or background capture ships yet. ## Principles @@ -28,6 +27,7 @@ yet. ```text crates/autophagy-events/ AEP Rust types, parsing, and validation +crates/autophagy-store/ SQLite migrations, idempotency, FTS, and deletion docs/architecture/ Planned component and storage boundaries docs/blueprint/ Complete normalized product and implementation brief docs/decisions/ Architecture decision records @@ -71,6 +71,17 @@ An AEP event looks like this: } ``` +## Storage guarantees + +- AEP validation runs before a transaction writes any rows. +- Reimporting an identical event is a no-op. +- Reusing an event ID with different content creates an auditable quarantine + record and never overwrites canonical evidence. +- Raw JSON is not copied into FTS5; tool input and free text require an explicit + redaction-approved search projection. +- Session deletion cascades through events, conflicts, and search rows, then + removes only artifacts that no remaining event references. + ## Security and privacy Autophagy processes private developer activity. Cloud processing and telemetry diff --git a/crates/autophagy-events/src/event.rs b/crates/autophagy-events/src/event.rs index e8939b3..966bc63 100644 --- a/crates/autophagy-events/src/event.rs +++ b/crates/autophagy-events/src/event.rs @@ -14,6 +14,16 @@ pub enum SpecVersion { V0_1, } +impl SpecVersion { + /// Return the stable wire-format value. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::V0_1 => "aep/0.1", + } + } +} + /// Normalized kind of agent activity. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum EventKind { @@ -62,6 +72,27 @@ pub enum EventKind { } impl EventKind { + /// Return the stable wire-format value. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::SessionStarted => "session.started", + Self::SessionEnded => "session.ended", + Self::PromptSubmitted => "prompt.submitted", + Self::DecisionRecorded => "decision.recorded", + Self::ToolCalled => "tool.called", + Self::ToolCompleted => "tool.completed", + Self::ToolFailed => "tool.failed", + Self::FileRead => "file.read", + Self::FileChanged => "file.changed", + Self::TestFailed => "test.failed", + Self::TestPassed => "test.passed", + Self::UserCorrectedAgent => "user.corrected_agent", + Self::UserRejectedAction => "user.rejected_action", + Self::ContextCompacted => "context.compacted", + } + } + const fn requires_tool(self) -> bool { matches!( self, @@ -113,6 +144,21 @@ pub enum ArtifactKind { Other, } +impl ArtifactKind { + /// Return the stable wire-format value. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::File => "file", + Self::GitCommit => "git.commit", + Self::GitDiff => "git.diff", + Self::Test => "test", + Self::CommandOutput => "command.output", + Self::Other => "other", + } + } +} + /// A file, commit, test, or other durable object referenced by an event. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/autophagy-store/Cargo.toml b/crates/autophagy-store/Cargo.toml new file mode 100644 index 0000000..4fdf4f6 --- /dev/null +++ b/crates/autophagy-store/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "autophagy-store" +description = "Transactional local SQLite event store for Autophagy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +autophagy-events = { path = "../autophagy-events" } +rusqlite = { version = "0.40.1", default-features = false, features = ["bundled"] } +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true + +[dev-dependencies] +tempfile = "3.27" + +[lints] +workspace = true diff --git a/crates/autophagy-store/migrations/0001_initial.sql b/crates/autophagy-store/migrations/0001_initial.sql new file mode 100644 index 0000000..0e8db44 --- /dev/null +++ b/crates/autophagy-store/migrations/0001_initial.sql @@ -0,0 +1,187 @@ +CREATE TABLE sources ( + source_id INTEGER PRIMARY KEY, + adapter TEXT NOT NULL CHECK (length(trim(adapter)) BETWEEN 1 AND 128), + instance_key TEXT NOT NULL CHECK (length(trim(instance_key)) > 0), + display_name TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + UNIQUE (adapter, instance_key) +) STRICT; + +CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY CHECK (session_id GLOB 'ses_?*'), + source_id INTEGER NOT NULL REFERENCES sources(source_id), + project_path TEXT, + started_at TEXT, + ended_at TEXT, + first_event_at TEXT NOT NULL, + last_event_at TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0 CHECK (event_count >= 0), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)) +) STRICT; + +CREATE TABLE events ( + row_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE CHECK (event_id GLOB 'evt_?*'), + spec_version TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, + occurred_at TEXT NOT NULL, + sequence INTEGER CHECK (sequence IS NULL OR sequence >= 0), + event_type TEXT NOT NULL, + project_path TEXT, + parent_event_id TEXT CHECK (parent_event_id IS NULL OR parent_event_id GLOB 'evt_?*'), + tool_name TEXT, + tool_input_text TEXT, + exit_code INTEGER, + event_json TEXT NOT NULL CHECK (json_valid(event_json)), + content_hash BLOB NOT NULL CHECK (length(content_hash) = 32), + imported_at TEXT NOT NULL, + UNIQUE (session_id, sequence) +) STRICT; + +CREATE INDEX events_session_time + ON events(session_id, occurred_at, sequence); +CREATE INDEX events_type_time + ON events(event_type, occurred_at); +CREATE INDEX events_tool_failure + ON events(tool_name, exit_code, occurred_at) + WHERE event_type = 'tool.failed'; + +CREATE TABLE artifacts ( + artifact_id INTEGER PRIMARY KEY, + artifact_type TEXT NOT NULL, + path TEXT, + uri TEXT, + digest TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + CHECK (path IS NOT NULL OR uri IS NOT NULL OR digest IS NOT NULL) +) STRICT; + +CREATE UNIQUE INDEX artifacts_identity + ON artifacts( + artifact_type, + coalesce(path, ''), + coalesce(uri, ''), + coalesce(digest, '') + ); + +CREATE TABLE event_artifacts ( + event_row_id INTEGER NOT NULL REFERENCES events(row_id) ON DELETE CASCADE, + artifact_id INTEGER NOT NULL REFERENCES artifacts(artifact_id), + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + PRIMARY KEY (event_row_id, ordinal), + UNIQUE (event_row_id, artifact_id) +) STRICT; + +CREATE TABLE events_search ( + event_row_id INTEGER PRIMARY KEY REFERENCES events(row_id) ON DELETE CASCADE, + project_path TEXT, + tool_name TEXT, + tool_input_text TEXT, + searchable_text TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE VIRTUAL TABLE events_fts USING fts5( + project_path, + tool_name, + tool_input_text, + searchable_text, + content='events_search', + content_rowid='event_row_id', + tokenize='unicode61 remove_diacritics 2' +); + +CREATE TRIGGER events_search_after_insert AFTER INSERT ON events_search BEGIN + INSERT INTO events_fts( + rowid, + project_path, + tool_name, + tool_input_text, + searchable_text + ) VALUES ( + new.event_row_id, + new.project_path, + new.tool_name, + new.tool_input_text, + new.searchable_text + ); +END; + +CREATE TRIGGER events_search_after_delete AFTER DELETE ON events_search BEGIN + INSERT INTO events_fts( + events_fts, + rowid, + project_path, + tool_name, + tool_input_text, + searchable_text + ) VALUES ( + 'delete', + old.event_row_id, + old.project_path, + old.tool_name, + old.tool_input_text, + old.searchable_text + ); +END; + +CREATE TRIGGER events_search_after_update AFTER UPDATE ON events_search BEGIN + INSERT INTO events_fts( + events_fts, + rowid, + project_path, + tool_name, + tool_input_text, + searchable_text + ) VALUES ( + 'delete', + old.event_row_id, + old.project_path, + old.tool_name, + old.tool_input_text, + old.searchable_text + ); + INSERT INTO events_fts( + rowid, + project_path, + tool_name, + tool_input_text, + searchable_text + ) VALUES ( + new.event_row_id, + new.project_path, + new.tool_name, + new.tool_input_text, + new.searchable_text + ); +END; + +CREATE TABLE event_conflicts ( + conflict_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES events(event_id) ON DELETE CASCADE, + existing_content_hash BLOB NOT NULL CHECK (length(existing_content_hash) = 32), + conflicting_content_hash BLOB NOT NULL CHECK (length(conflicting_content_hash) = 32), + conflicting_event_json TEXT NOT NULL CHECK (json_valid(conflicting_event_json)), + source_adapter TEXT NOT NULL, + source_instance_key TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + observation_count INTEGER NOT NULL DEFAULT 1 CHECK (observation_count > 0), + UNIQUE (event_id, conflicting_content_hash) +) STRICT; + +CREATE TABLE imports ( + import_id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL REFERENCES sources(source_id), + origin TEXT NOT NULL, + fingerprint BLOB NOT NULL, + cursor_json TEXT CHECK (cursor_json IS NULL OR json_valid(cursor_json)), + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL CHECK (status IN ('running', 'complete', 'failed')), + seen_count INTEGER NOT NULL DEFAULT 0 CHECK (seen_count >= 0), + inserted_count INTEGER NOT NULL DEFAULT 0 CHECK (inserted_count >= 0), + rejected_count INTEGER NOT NULL DEFAULT 0 CHECK (rejected_count >= 0), + error TEXT, + UNIQUE (source_id, origin, fingerprint) +) STRICT; diff --git a/crates/autophagy-store/src/error.rs b/crates/autophagy-store/src/error.rs new file mode 100644 index 0000000..587dc27 --- /dev/null +++ b/crates/autophagy-store/src/error.rs @@ -0,0 +1,74 @@ +use autophagy_events::{EventParseError, ValidationErrors}; + +/// Error produced by the local event store. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + /// `SQLite` rejected an operation. + #[error("SQLite operation failed: {0}")] + Database(#[from] rusqlite::Error), + /// An event or metadata value could not be serialized. + #[error("JSON serialization failed: {0}")] + Serialization(#[from] serde_json::Error), + /// An event timestamp could not be formatted canonically. + #[error("timestamp formatting failed: {0}")] + TimeFormat(#[from] time::error::Format), + /// The event violated the AEP contract. + #[error("event validation failed: {0}")] + InvalidEvent(#[from] ValidationErrors), + /// An event read from `SQLite` no longer satisfies the AEP contract. + #[error("stored event is invalid: {0}")] + StoredEventInvalid(#[from] EventParseError), + /// A required source identity component was blank. + #[error("source {field} must not be empty or whitespace")] + InvalidSource { + /// Invalid source field. + field: &'static str, + }, + /// The AEP source and storage provenance adapter disagreed. + #[error("event source '{event_source}' does not match adapter '{adapter}'")] + SourceMismatch { + /// Value in the AEP event. + event_source: String, + /// Value in storage provenance. + adapter: String, + }, + /// An existing session was observed under a different source instance. + #[error("session '{session_id}' already belongs to a different source instance")] + SessionSourceConflict { + /// Conflicting session identifier. + session_id: String, + }, + /// An unsigned sequence cannot fit `SQLite`'s signed integer representation. + #[error("event sequence {sequence} exceeds SQLite's integer range")] + SequenceOutOfRange { + /// Rejected sequence. + sequence: u64, + }, + /// An artifact position cannot fit `SQLite`'s signed integer representation. + #[error("artifact ordinal {ordinal} exceeds SQLite's integer range")] + ArtifactOrdinalOutOfRange { + /// Rejected zero-based artifact position. + ordinal: usize, + }, + /// An applied migration's SQL no longer matches the compiled migration. + #[error("migration {version} checksum does not match the compiled migration")] + MigrationDrift { + /// Migration version whose content changed. + version: i64, + }, + /// The database was created by a newer store version. + #[error("database migration {version} is newer than this binary supports")] + DatabaseTooNew { + /// Unsupported migration version. + version: i64, + }, + /// An earlier migration record is absent while a later one exists. + #[error("database is missing migration {version}")] + MissingMigration { + /// Missing migration version. + version: i64, + }, + /// Full-text search queries cannot be blank. + #[error("search query must not be empty or whitespace")] + EmptySearchQuery, +} diff --git a/crates/autophagy-store/src/lib.rs b/crates/autophagy-store/src/lib.rs new file mode 100644 index 0000000..6f237fa --- /dev/null +++ b/crates/autophagy-store/src/lib.rs @@ -0,0 +1,18 @@ +//! Transactional, local-only `SQLite` storage for normalized Autophagy events. +//! +//! The store validates every event before persistence, derives a stable content +//! hash for idempotency, quarantines conflicting identities, and indexes only +//! path-policy-processed structural fields plus caller-approved free text. + +mod error; +mod migration; +mod model; +mod store; +mod util; + +pub use error::StoreError; +pub use model::{ + DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceIdentity, + StoreStats, +}; +pub use store::EventStore; diff --git a/crates/autophagy-store/src/migration.rs b/crates/autophagy-store/src/migration.rs new file mode 100644 index 0000000..5b0b444 --- /dev/null +++ b/crates/autophagy-store/src/migration.rs @@ -0,0 +1,122 @@ +use std::collections::BTreeMap; + +use rusqlite::{Connection, TransactionBehavior, params}; + +use crate::{StoreError, util}; + +const BOOTSTRAP_SQL: &str = " +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + description TEXT NOT NULL, + checksum BLOB NOT NULL CHECK (length(checksum) = 32), + applied_at TEXT NOT NULL +) STRICT; +"; + +struct Migration { + version: i64, + description: &'static str, + sql: &'static str, +} + +const MIGRATIONS: &[Migration] = &[Migration { + version: 1, + description: "initial event store", + sql: include_str!("../migrations/0001_initial.sql"), +}]; + +pub(crate) fn apply(connection: &mut Connection) -> Result<(), StoreError> { + connection.execute_batch(BOOTSTRAP_SQL)?; + + let applied = load_applied(connection)?; + let latest = MIGRATIONS.last().map_or(0, |migration| migration.version); + if let Some(version) = applied.keys().copied().find(|version| *version > latest) { + return Err(StoreError::DatabaseTooNew { version }); + } + + let highest_applied = applied.keys().next_back().copied().unwrap_or(0); + for migration in MIGRATIONS { + let checksum = util::sha256(migration.sql.as_bytes()); + if let Some(stored_checksum) = applied.get(&migration.version) { + if stored_checksum.as_slice() != checksum { + return Err(StoreError::MigrationDrift { + version: migration.version, + }); + } + continue; + } + if migration.version < highest_applied { + return Err(StoreError::MissingMigration { + version: migration.version, + }); + } + + let applied_at = util::now_timestamp()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(migration.sql)?; + transaction.execute( + "INSERT INTO schema_migrations(version, description, checksum, applied_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + migration.version, + migration.description, + checksum.as_slice(), + applied_at + ], + )?; + transaction.pragma_update(None, "user_version", migration.version)?; + transaction.commit()?; + } + + Ok(()) +} + +fn load_applied(connection: &Connection) -> Result>, rusqlite::Error> { + let mut statement = + connection.prepare("SELECT version, checksum FROM schema_migrations ORDER BY version")?; + let rows = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect() +} + +#[cfg(test)] +mod tests { + use rusqlite::{Connection, params}; + + use super::apply; + use crate::StoreError; + + #[test] + fn changed_applied_migration_is_rejected() { + let mut connection = Connection::open_in_memory().expect("database"); + apply(&mut connection).expect("initial migration"); + connection + .execute( + "UPDATE schema_migrations SET checksum = zeroblob(32) WHERE version = 1", + [], + ) + .expect("tamper checksum"); + + assert!(matches!( + apply(&mut connection), + Err(StoreError::MigrationDrift { version: 1 }) + )); + } + + #[test] + fn newer_database_is_rejected() { + let mut connection = Connection::open_in_memory().expect("database"); + apply(&mut connection).expect("initial migration"); + connection + .execute( + "INSERT INTO schema_migrations(version, description, checksum, applied_at) + VALUES (2, 'future', ?1, '2026-07-16T00:00:00Z')", + params![[7_u8; 32].as_slice()], + ) + .expect("future migration"); + + assert!(matches!( + apply(&mut connection), + Err(StoreError::DatabaseTooNew { version: 2 }) + )); + } +} diff --git a/crates/autophagy-store/src/model.rs b/crates/autophagy-store/src/model.rs new file mode 100644 index 0000000..46d4fdc --- /dev/null +++ b/crates/autophagy-store/src/model.rs @@ -0,0 +1,123 @@ +/// Stable provenance for one adapter installation or history directory. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceIdentity { + /// Adapter identifier; must match the AEP event's `source` value. + pub adapter: String, + /// Stable identity for this adapter installation or source directory. + pub instance_key: String, + /// Optional user-facing label. + pub display_name: Option, +} + +impl SourceIdentity { + /// Create a source identity without a display label. + #[must_use] + pub fn new(adapter: impl Into, instance_key: impl Into) -> Self { + Self { + adapter: adapter.into(), + instance_key: instance_key.into(), + display_name: None, + } + } + + /// Attach a user-facing label. + #[must_use] + pub fn with_display_name(mut self, display_name: impl Into) -> Self { + self.display_name = Some(display_name.into()); + self + } +} + +/// Redaction-approved free text that may enter the FTS5 index. +/// +/// Project paths and tool names come from the already policy-processed AEP +/// envelope. Tool input and event payload text require this explicit projection. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SearchProjection { + /// Sanitized tool input. Raw event input is not indexed automatically. + pub tool_input_text: Option, + /// Sanitized free text extracted by an adapter or digester. + pub searchable_text: Option, +} + +/// Result of attempting to persist one event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InsertOutcome { + /// A new immutable event row was created. + Inserted { + /// Internal `SQLite` row identifier. + row_id: i64, + }, + /// The same event ID and content hash already existed; nothing changed. + Duplicate { + /// Existing internal `SQLite` row identifier. + row_id: i64, + }, + /// The ID existed with different content, which was retained separately. + ConflictQuarantined { + /// Stable quarantine record identifier. + conflict_id: i64, + /// Number of times this exact conflicting content has been observed. + observation_count: i64, + }, +} + +/// Compact session record returned by storage queries. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionSummary { + /// AEP session identifier. + pub session_id: String, + /// Adapter identifier. + pub adapter: String, + /// Stable source-instance identifier. + pub instance_key: String, + /// Project path after path-policy processing. + pub project_path: Option, + /// Earliest explicit session-start event. + pub started_at: Option, + /// Latest explicit session-end event. + pub ended_at: Option, + /// Earliest observed event timestamp. + pub first_event_at: String, + /// Latest observed event timestamp. + pub last_event_at: String, + /// Number of immutable events in the session. + pub event_count: i64, +} + +/// One full-text search result. +#[derive(Clone, Debug, PartialEq)] +pub struct SearchHit { + /// Exact AEP evidence identifier. + pub event_id: String, + /// FTS5 BM25 rank; lower values are better matches. + pub rank: f64, + /// Context with matching terms surrounded by brackets. + pub snippet: String, +} + +/// Row counts useful for diagnostics and idempotency assertions. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct StoreStats { + /// Number of source instances. + pub sources: i64, + /// Number of sessions. + pub sessions: i64, + /// Number of canonical events. + pub events: i64, + /// Number of distinct artifacts. + pub artifacts: i64, + /// Number of quarantined conflicting payloads. + pub conflicts: i64, +} + +/// Effect of deleting one session. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DeleteSummary { + /// Whether a session row existed and was removed. + pub session_deleted: bool, + /// Number of canonical events removed by the cascade. + pub events_deleted: i64, + /// Number of artifacts that became unreferenced and were removed. + pub artifacts_deleted: i64, +} diff --git a/crates/autophagy-store/src/store.rs b/crates/autophagy-store/src/store.rs new file mode 100644 index 0000000..0a95b7f --- /dev/null +++ b/crates/autophagy-store/src/store.rs @@ -0,0 +1,574 @@ +use std::{path::Path, time::Duration}; + +use autophagy_events::{Event, EventKind}; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; + +use crate::{ + DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceIdentity, + StoreError, StoreStats, migration, util, +}; + +/// Transactional owner of one local Autophagy `SQLite` database. +pub struct EventStore { + connection: Connection, +} + +impl EventStore { + /// Open or create a store at a filesystem path and apply pending migrations. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` cannot be configured or a migration + /// cannot be verified and applied. + pub fn open(path: impl AsRef) -> Result { + let connection = Connection::open(path)?; + Self::from_connection(connection) + } + + /// Open a temporary in-memory store, primarily for tests and previews. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` cannot be configured or migrated. + pub fn open_in_memory() -> Result { + Self::from_connection(Connection::open_in_memory()?) + } + + /// Return the highest immutable migration applied to this database. + /// + /// # Errors + /// + /// Returns [`StoreError`] if the migration table cannot be queried. + pub fn schema_version(&self) -> Result { + Ok(self.connection.query_row( + "SELECT coalesce(max(version), 0) FROM schema_migrations", + [], + |row| row.get(0), + )?) + } + + /// Atomically validate and persist one normalized event. + /// + /// Identical event IDs and content hashes are no-ops. Reusing an event ID + /// with different content commits an audit quarantine record without + /// changing the canonical event. + /// + /// # Errors + /// + /// Returns [`StoreError`] when validation, serialization, provenance, or a + /// transactional `SQLite` operation fails. + pub fn insert_event( + &mut self, + source: &SourceIdentity, + event: &Event, + search: &SearchProjection, + ) -> Result { + event.validate()?; + validate_source(source)?; + if event.source != source.adapter { + return Err(StoreError::SourceMismatch { + event_source: event.source.clone(), + adapter: source.adapter.clone(), + }); + } + + let sequence = event + .sequence + .map(|value| { + i64::try_from(value).map_err(|_| StoreError::SequenceOutOfRange { sequence: value }) + }) + .transpose()?; + let event_json = serde_json::to_string(event)?; + let content_hash = util::sha256(event_json.as_bytes()); + let occurred_at = util::canonical_timestamp(event.timestamp)?; + let imported_at = util::now_timestamp()?; + let tool_input_text = persisted_tool_input(event)?; + + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + + if let Some((row_id, existing_hash)) = transaction + .query_row( + "SELECT row_id, content_hash FROM events WHERE event_id = ?1", + [event.event_id.as_str()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)), + ) + .optional()? + { + if existing_hash.as_slice() == content_hash { + return Ok(InsertOutcome::Duplicate { row_id }); + } + return quarantine_conflict( + transaction, + source, + event, + &event_json, + &existing_hash, + &content_hash, + &imported_at, + ); + } + + let source_id = upsert_source(&transaction, source, &occurred_at)?; + ensure_session(&transaction, source_id, event, &occurred_at)?; + + let tool_name = event.tool.as_ref().map(|tool| tool.name.as_str()); + let exit_code = event.tool.as_ref().and_then(|tool| tool.exit_code); + transaction.execute( + "INSERT INTO events( + event_id, spec_version, session_id, occurred_at, sequence, + event_type, project_path, parent_event_id, tool_name, + tool_input_text, exit_code, event_json, content_hash, imported_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14 + )", + params![ + event.event_id.as_str(), + event.spec_version.as_str(), + event.session_id.as_str(), + occurred_at, + sequence, + event.kind.as_str(), + event.project.as_deref(), + event + .parent_event_id + .as_ref() + .map(autophagy_events::EventId::as_str), + tool_name, + tool_input_text, + exit_code, + event_json, + content_hash.as_slice(), + imported_at, + ], + )?; + let row_id = transaction.last_insert_rowid(); + + transaction.execute( + "INSERT INTO events_search( + event_row_id, project_path, tool_name, tool_input_text, searchable_text + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + row_id, + event.project.as_deref(), + tool_name, + search.tool_input_text.as_deref(), + search.searchable_text.as_deref().unwrap_or_default(), + ], + )?; + + insert_artifacts(&transaction, row_id, event)?; + update_session_rollup(&transaction, event, &occurred_at)?; + transaction.commit()?; + + Ok(InsertOutcome::Inserted { row_id }) + } + + /// Load and revalidate one canonical event by its evidence ID. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` fails or persisted JSON is invalid. + pub fn get_event(&self, event_id: &str) -> Result, StoreError> { + let event_json = self + .connection + .query_row( + "SELECT event_json FROM events WHERE event_id = ?1", + [event_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + event_json + .map(|json| Event::from_json_str(&json).map_err(StoreError::from)) + .transpose() + } + + /// Return one session and its source provenance. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` cannot execute the query. + pub fn get_session(&self, session_id: &str) -> Result, StoreError> { + Ok(self + .connection + .query_row( + "SELECT + sessions.session_id, + sources.adapter, + sources.instance_key, + sessions.project_path, + sessions.started_at, + sessions.ended_at, + sessions.first_event_at, + sessions.last_event_at, + sessions.event_count + FROM sessions + JOIN sources USING (source_id) + WHERE sessions.session_id = ?1", + [session_id], + |row| { + Ok(SessionSummary { + session_id: row.get(0)?, + adapter: row.get(1)?, + instance_key: row.get(2)?, + project_path: row.get(3)?, + started_at: row.get(4)?, + ended_at: row.get(5)?, + first_event_at: row.get(6)?, + last_event_at: row.get(7)?, + event_count: row.get(8)?, + }) + }, + ) + .optional()?) + } + + /// Search the explicit redaction-approved FTS5 projection. + /// + /// # Errors + /// + /// Returns [`StoreError`] for blank or invalid FTS5 queries and `SQLite` + /// failures. + pub fn search(&self, query: &str, limit: u32) -> Result, StoreError> { + if query.trim().is_empty() { + return Err(StoreError::EmptySearchQuery); + } + if limit == 0 { + return Ok(Vec::new()); + } + + let mut statement = self.connection.prepare( + "SELECT + events.event_id, + bm25(events_fts), + snippet(events_fts, -1, '[', ']', ' … ', 16) + FROM events_fts + JOIN events ON events.row_id = events_fts.rowid + WHERE events_fts MATCH ?1 + ORDER BY bm25(events_fts), events.occurred_at, events.row_id + LIMIT ?2", + )?; + let rows = statement.query_map(params![query, i64::from(limit)], |row| { + Ok(SearchHit { + event_id: row.get(0)?, + rank: row.get(1)?, + snippet: row.get(2)?, + }) + })?; + Ok(rows.collect::>()?) + } + + /// Return diagnostic row counts without exposing the `SQLite` connection. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` cannot execute the query. + pub fn stats(&self) -> Result { + Ok(self.connection.query_row( + "SELECT + (SELECT count(*) FROM sources), + (SELECT count(*) FROM sessions), + (SELECT count(*) FROM events), + (SELECT count(*) FROM artifacts), + (SELECT count(*) FROM event_conflicts)", + [], + |row| { + Ok(StoreStats { + sources: row.get(0)?, + sessions: row.get(1)?, + events: row.get(2)?, + artifacts: row.get(3)?, + conflicts: row.get(4)?, + }) + }, + )?) + } + + /// Delete a session, its events, search rows, conflicts, and orphaned artifacts. + /// + /// # Errors + /// + /// Returns [`StoreError`] when the deletion transaction fails. + pub fn delete_session(&mut self, session_id: &str) -> Result { + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let session_exists = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM sessions WHERE session_id = ?1)", + [session_id], + |row| row.get::<_, bool>(0), + )?; + if !session_exists { + return Ok(DeleteSummary::default()); + } + + let events_deleted = transaction.query_row( + "SELECT count(*) FROM events WHERE session_id = ?1", + [session_id], + |row| row.get::<_, i64>(0), + )?; + let artifacts_before = + transaction.query_row("SELECT count(*) FROM artifacts", [], |row| { + row.get::<_, i64>(0) + })?; + + transaction.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?; + transaction.execute( + "DELETE FROM artifacts + WHERE NOT EXISTS ( + SELECT 1 FROM event_artifacts + WHERE event_artifacts.artifact_id = artifacts.artifact_id + )", + [], + )?; + let artifacts_after = + transaction.query_row("SELECT count(*) FROM artifacts", [], |row| { + row.get::<_, i64>(0) + })?; + transaction.commit()?; + + Ok(DeleteSummary { + session_deleted: true, + events_deleted, + artifacts_deleted: artifacts_before - artifacts_after, + }) + } + + fn from_connection(mut connection: Connection) -> Result { + configure(&connection)?; + migration::apply(&mut connection)?; + Ok(Self { connection }) + } +} + +fn configure(connection: &Connection) -> Result<(), rusqlite::Error> { + connection.busy_timeout(Duration::from_secs(5))?; + connection.pragma_update(None, "foreign_keys", true)?; + connection.pragma_update(None, "secure_delete", true)?; + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "synchronous", "NORMAL")?; + Ok(()) +} + +fn validate_source(source: &SourceIdentity) -> Result<(), StoreError> { + for (field, value) in [ + ("adapter", source.adapter.as_str()), + ("instance_key", source.instance_key.as_str()), + ] { + if value.trim().is_empty() { + return Err(StoreError::InvalidSource { field }); + } + } + if source + .display_name + .as_ref() + .is_some_and(|name| name.trim().is_empty()) + { + return Err(StoreError::InvalidSource { + field: "display_name", + }); + } + Ok(()) +} + +fn persisted_tool_input(event: &Event) -> Result, serde_json::Error> { + event + .tool + .as_ref() + .and_then(|tool| tool.input.as_ref()) + .map(|input| match input { + serde_json::Value::String(value) => Ok(value.clone()), + value => serde_json::to_string(value), + }) + .transpose() +} + +fn quarantine_conflict( + transaction: Transaction<'_>, + source: &SourceIdentity, + event: &Event, + event_json: &str, + existing_hash: &[u8], + conflicting_hash: &[u8; 32], + observed_at: &str, +) -> Result { + transaction.execute( + "INSERT INTO event_conflicts( + event_id, existing_content_hash, conflicting_content_hash, + conflicting_event_json, source_adapter, source_instance_key, + first_seen_at, last_seen_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(event_id, conflicting_content_hash) DO UPDATE SET + last_seen_at = excluded.last_seen_at, + observation_count = event_conflicts.observation_count + 1", + params![ + event.event_id.as_str(), + existing_hash, + conflicting_hash.as_slice(), + event_json, + source.adapter, + source.instance_key, + observed_at, + ], + )?; + let (conflict_id, observation_count) = transaction.query_row( + "SELECT conflict_id, observation_count + FROM event_conflicts + WHERE event_id = ?1 AND conflicting_content_hash = ?2", + params![event.event_id.as_str(), conflicting_hash.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + transaction.commit()?; + Ok(InsertOutcome::ConflictQuarantined { + conflict_id, + observation_count, + }) +} + +fn upsert_source( + transaction: &Transaction<'_>, + source: &SourceIdentity, + occurred_at: &str, +) -> Result { + transaction.execute( + "INSERT INTO sources( + adapter, instance_key, display_name, first_seen_at, last_seen_at + ) VALUES (?1, ?2, ?3, ?4, ?4) + ON CONFLICT(adapter, instance_key) DO UPDATE SET + display_name = coalesce(excluded.display_name, sources.display_name), + first_seen_at = min(sources.first_seen_at, excluded.first_seen_at), + last_seen_at = max(sources.last_seen_at, excluded.last_seen_at)", + params![ + source.adapter, + source.instance_key, + source.display_name, + occurred_at + ], + )?; + transaction.query_row( + "SELECT source_id FROM sources WHERE adapter = ?1 AND instance_key = ?2", + params![source.adapter, source.instance_key], + |row| row.get(0), + ) +} + +fn ensure_session( + transaction: &Transaction<'_>, + source_id: i64, + event: &Event, + occurred_at: &str, +) -> Result<(), StoreError> { + if let Some(existing_source_id) = transaction + .query_row( + "SELECT source_id FROM sessions WHERE session_id = ?1", + [event.session_id.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional()? + { + if existing_source_id != source_id { + return Err(StoreError::SessionSourceConflict { + session_id: event.session_id.to_string(), + }); + } + return Ok(()); + } + + let started_at = (event.kind == EventKind::SessionStarted).then_some(occurred_at); + let ended_at = (event.kind == EventKind::SessionEnded).then_some(occurred_at); + transaction.execute( + "INSERT INTO sessions( + session_id, source_id, project_path, started_at, ended_at, + first_event_at, last_event_at, event_count + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, 0)", + params![ + event.session_id.as_str(), + source_id, + event.project.as_deref(), + started_at, + ended_at, + occurred_at, + ], + )?; + Ok(()) +} + +fn insert_artifacts( + transaction: &Transaction<'_>, + event_row_id: i64, + event: &Event, +) -> Result<(), StoreError> { + for (ordinal, artifact) in event.artifacts.iter().enumerate() { + let ordinal = i64::try_from(ordinal) + .map_err(|_| StoreError::ArtifactOrdinalOutOfRange { ordinal })?; + let metadata_json = serde_json::to_string(&artifact.metadata)?; + transaction.execute( + "INSERT OR IGNORE INTO artifacts( + artifact_type, path, uri, digest, metadata_json + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + artifact.kind.as_str(), + artifact.path.as_deref(), + artifact.uri.as_deref(), + artifact.digest.as_deref(), + metadata_json, + ], + )?; + let artifact_id = transaction.query_row( + "SELECT artifact_id FROM artifacts + WHERE artifact_type = ?1 + AND path IS ?2 + AND uri IS ?3 + AND digest IS ?4", + params![ + artifact.kind.as_str(), + artifact.path.as_deref(), + artifact.uri.as_deref(), + artifact.digest.as_deref(), + ], + |row| row.get::<_, i64>(0), + )?; + transaction.execute( + "INSERT OR IGNORE INTO event_artifacts(event_row_id, artifact_id, ordinal) + VALUES (?1, ?2, ?3)", + params![event_row_id, artifact_id, ordinal], + )?; + } + Ok(()) +} + +fn update_session_rollup( + transaction: &Transaction<'_>, + event: &Event, + occurred_at: &str, +) -> Result<(), rusqlite::Error> { + let started_at = (event.kind == EventKind::SessionStarted).then_some(occurred_at); + let ended_at = (event.kind == EventKind::SessionEnded).then_some(occurred_at); + transaction.execute( + "UPDATE sessions SET + project_path = coalesce(project_path, ?2), + started_at = CASE + WHEN ?3 IS NULL THEN started_at + WHEN started_at IS NULL THEN ?3 + ELSE min(started_at, ?3) + END, + ended_at = CASE + WHEN ?4 IS NULL THEN ended_at + WHEN ended_at IS NULL THEN ?4 + ELSE max(ended_at, ?4) + END, + first_event_at = min(first_event_at, ?5), + last_event_at = max(last_event_at, ?5), + event_count = event_count + 1 + WHERE session_id = ?1", + params![ + event.session_id.as_str(), + event.project.as_deref(), + started_at, + ended_at, + occurred_at, + ], + )?; + Ok(()) +} diff --git a/crates/autophagy-store/src/util.rs b/crates/autophagy-store/src/util.rs new file mode 100644 index 0000000..fdfd5a9 --- /dev/null +++ b/crates/autophagy-store/src/util.rs @@ -0,0 +1,14 @@ +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, UtcOffset, format_description::well_known::Rfc3339}; + +pub(crate) fn sha256(input: &[u8]) -> [u8; 32] { + Sha256::digest(input).into() +} + +pub(crate) fn canonical_timestamp(value: OffsetDateTime) -> Result { + value.to_offset(UtcOffset::UTC).format(&Rfc3339) +} + +pub(crate) fn now_timestamp() -> Result { + canonical_timestamp(OffsetDateTime::now_utc()) +} diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs new file mode 100644 index 0000000..8d6709f --- /dev/null +++ b/crates/autophagy-store/tests/store.rs @@ -0,0 +1,448 @@ +//! Integration tests for transactional event storage and retrieval. + +use std::collections::BTreeMap; + +use autophagy_events::{ + Artifact, ArtifactKind, Event, EventId, EventKind, SessionId, SpecVersion, ToolCall, +}; +use autophagy_store::{ + DeleteSummary, EventStore, InsertOutcome, SearchProjection, SourceIdentity, StoreError, + StoreStats, +}; +use serde_json::{Value, json}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[test] +fn migrations_persist_and_reopen_cleanly() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("autophagy.db"); + let source = source("instance-a"); + let event = session_event( + "evt_session-start", + "ses_reopen", + EventKind::SessionStarted, + "2026-07-16T01:00:00Z", + 0, + ); + + { + let mut store = EventStore::open(&database).expect("store should open"); + assert_eq!(store.schema_version().expect("schema version"), 1); + assert!(matches!( + store + .insert_event(&source, &event, &SearchProjection::default()) + .expect("insert should succeed"), + InsertOutcome::Inserted { .. } + )); + } + + let reopened = EventStore::open(&database).expect("store should reopen"); + assert_eq!(reopened.schema_version().expect("schema version"), 1); + assert_eq!( + reopened + .get_event(event.event_id.as_str()) + .expect("event query"), + Some(event) + ); +} + +#[test] +fn insertion_rolls_up_sessions_and_indexes_only_approved_text() { + let mut store = EventStore::open_in_memory().expect("store should open"); + let source = source("instance-a"); + let mut failure = tool_failure("evt_failure", "ses_rollup", "2026-07-16T01:10:00+00:00", 1); + failure + .metadata + .insert("private".to_owned(), json!("supersecretneedle")); + failure.artifacts.push(file_artifact("src/schema.graphql")); + let approved = SearchProjection { + tool_input_text: Some("pytest translation".to_owned()), + searchable_text: Some("generated client was stale".to_owned()), + }; + + store + .insert_event(&source, &failure, &approved) + .expect("failure insert"); + store + .insert_event( + &source, + &session_event( + "evt_start", + "ses_rollup", + EventKind::SessionStarted, + "2026-07-16T01:00:00-00:00", + 0, + ), + &SearchProjection::default(), + ) + .expect("start insert"); + store + .insert_event( + &source, + &session_event( + "evt_end", + "ses_rollup", + EventKind::SessionEnded, + "2026-07-16T01:20:00Z", + 2, + ), + &SearchProjection::default(), + ) + .expect("end insert"); + + let session = store + .get_session("ses_rollup") + .expect("session query") + .expect("session should exist"); + assert_eq!(session.event_count, 3); + assert_eq!(session.first_event_at, "2026-07-16T01:00:00Z"); + assert_eq!(session.last_event_at, "2026-07-16T01:20:00Z"); + assert_eq!(session.started_at.as_deref(), Some("2026-07-16T01:00:00Z")); + assert_eq!(session.ended_at.as_deref(), Some("2026-07-16T01:20:00Z")); + assert_eq!(session.project_path.as_deref(), Some("/workspace/project")); + + let hits = store.search("generated", 10).expect("approved search"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].event_id, "evt_failure"); + assert!(hits[0].snippet.contains("[generated]")); + assert_eq!(store.search("pytest", 10).expect("tool search").len(), 1); + assert!( + store + .search("supersecretneedle", 10) + .expect("private metadata search") + .is_empty() + ); + assert!( + store + .search("\"private-command-token\"", 10) + .expect("raw tool input search") + .is_empty() + ); + assert_eq!( + store.stats().expect("stats"), + StoreStats { + sources: 1, + sessions: 1, + events: 3, + artifacts: 1, + conflicts: 0, + } + ); +} + +#[test] +fn duplicate_is_a_noop_and_conflicting_content_is_quarantined() { + let mut store = EventStore::open_in_memory().expect("store should open"); + let source = source("instance-a"); + let original = tool_failure("evt_stable", "ses_idempotent", "2026-07-16T02:00:00Z", 0); + let inserted = store + .insert_event( + &source, + &original, + &SearchProjection { + tool_input_text: None, + searchable_text: Some("first projection".to_owned()), + }, + ) + .expect("first insert"); + let row_id = match inserted { + InsertOutcome::Inserted { row_id } => row_id, + outcome => panic!("unexpected first outcome: {outcome:?}"), + }; + + assert_eq!( + store + .insert_event( + &source, + &original, + &SearchProjection { + tool_input_text: None, + searchable_text: Some("must not replace projection".to_owned()), + }, + ) + .expect("duplicate insert"), + InsertOutcome::Duplicate { row_id } + ); + assert!( + store + .search("replace", 10) + .expect("duplicate projection search") + .is_empty() + ); + + let mut conflict = original.clone(); + conflict + .metadata + .insert("changed".to_owned(), Value::Bool(true)); + let first_conflict = store + .insert_event(&source, &conflict, &SearchProjection::default()) + .expect("conflict should be quarantined"); + let conflict_id = match first_conflict { + InsertOutcome::ConflictQuarantined { + conflict_id, + observation_count: 1, + } => conflict_id, + outcome => panic!("unexpected conflict outcome: {outcome:?}"), + }; + assert_eq!( + store + .insert_event(&source, &conflict, &SearchProjection::default()) + .expect("repeated conflict"), + InsertOutcome::ConflictQuarantined { + conflict_id, + observation_count: 2, + } + ); + assert_eq!( + store + .get_event(original.event_id.as_str()) + .expect("canonical query"), + Some(original) + ); + assert_eq!( + store.stats().expect("stats"), + StoreStats { + sources: 1, + sessions: 1, + events: 1, + artifacts: 0, + conflicts: 1, + } + ); +} + +#[test] +fn provenance_and_sequence_conflicts_roll_back_atomically() { + let mut store = EventStore::open_in_memory().expect("store should open"); + let first_source = source("instance-a"); + let first = session_event( + "evt_first", + "ses_provenance", + EventKind::SessionStarted, + "2026-07-16T03:00:00Z", + 0, + ); + store + .insert_event(&first_source, &first, &SearchProjection::default()) + .expect("first insert"); + + let second_source = source("instance-b"); + let source_conflict = session_event( + "evt_second", + "ses_provenance", + EventKind::SessionEnded, + "2026-07-16T03:10:00Z", + 1, + ); + assert!(matches!( + store.insert_event( + &second_source, + &source_conflict, + &SearchProjection::default() + ), + Err(StoreError::SessionSourceConflict { .. }) + )); + + let sequence_conflict = session_event( + "evt_third", + "ses_provenance", + EventKind::SessionEnded, + "2026-07-16T03:20:00Z", + 0, + ); + assert!(matches!( + store.insert_event( + &first_source, + &sequence_conflict, + &SearchProjection::default() + ), + Err(StoreError::Database(_)) + )); + assert_eq!( + store.stats().expect("stats"), + StoreStats { + sources: 1, + sessions: 1, + events: 1, + artifacts: 0, + conflicts: 0, + } + ); + assert_eq!( + store + .get_session("ses_provenance") + .expect("session query") + .expect("session") + .event_count, + 1 + ); + + let wrong_adapter = SourceIdentity::new("claude-code", "instance-c"); + assert!(matches!( + store.insert_event(&wrong_adapter, &first, &SearchProjection::default()), + Err(StoreError::SourceMismatch { .. }) + )); +} + +#[test] +fn deleting_sessions_cascades_search_and_only_orphaned_artifacts() { + let mut store = EventStore::open_in_memory().expect("store should open"); + let source = source("instance-a"); + let shared = file_artifact("src/shared.rs"); + let mut first = session_event( + "evt_delete-one", + "ses_delete-one", + EventKind::FileChanged, + "2026-07-16T04:00:00Z", + 0, + ); + first.artifacts = vec![shared.clone(), file_artifact("src/only-one.rs")]; + let mut second = session_event( + "evt_delete-two", + "ses_delete-two", + EventKind::FileChanged, + "2026-07-16T04:10:00Z", + 0, + ); + second.artifacts = vec![shared, file_artifact("src/only-two.rs")]; + + store + .insert_event( + &source, + &first, + &SearchProjection { + tool_input_text: None, + searchable_text: Some("delete marker one".to_owned()), + }, + ) + .expect("first insert"); + store + .insert_event( + &source, + &second, + &SearchProjection { + tool_input_text: None, + searchable_text: Some("delete marker two".to_owned()), + }, + ) + .expect("second insert"); + assert_eq!(store.stats().expect("stats").artifacts, 3); + + let deleted = store + .delete_session("ses_delete-one") + .expect("first deletion"); + assert!(deleted.session_deleted); + assert_eq!(deleted.events_deleted, 1); + assert_eq!(deleted.artifacts_deleted, 1); + assert!(store.search("one", 10).expect("deleted search").is_empty()); + assert_eq!(store.search("two", 10).expect("retained search").len(), 1); + assert_eq!(store.stats().expect("stats").artifacts, 2); + + assert_eq!( + store + .delete_session("ses_missing") + .expect("missing deletion"), + DeleteSummary::default() + ); + let deleted = store + .delete_session("ses_delete-two") + .expect("second deletion"); + assert_eq!(deleted.artifacts_deleted, 2); + assert_eq!( + store.stats().expect("stats"), + StoreStats { + sources: 1, + ..StoreStats::default() + } + ); +} + +#[test] +fn invalid_events_are_rejected_before_storage() { + let mut store = EventStore::open_in_memory().expect("store should open"); + let source = source("instance-a"); + let mut invalid = session_event( + "evt_valid", + "ses_invalid", + EventKind::SessionStarted, + "2026-07-16T05:00:00Z", + 0, + ); + invalid.event_id = EventId::new("not-an-event-id"); + assert!(matches!( + store.insert_event(&source, &invalid, &SearchProjection::default()), + Err(StoreError::InvalidEvent(_)) + )); + let mut oversized_sequence = session_event( + "evt_oversized-sequence", + "ses_invalid", + EventKind::SessionStarted, + "2026-07-16T05:00:00Z", + 0, + ); + oversized_sequence.sequence = Some(u64::MAX); + assert!(matches!( + store.insert_event(&source, &oversized_sequence, &SearchProjection::default()), + Err(StoreError::SequenceOutOfRange { .. }) + )); + assert_eq!(store.stats().expect("stats"), StoreStats::default()); + assert!(matches!( + store.search(" ", 10), + Err(StoreError::EmptySearchQuery) + )); +} + +fn source(instance_key: &str) -> SourceIdentity { + SourceIdentity::new("codex", instance_key).with_display_name("Codex") +} + +fn session_event( + event_id: &str, + session_id: &str, + kind: EventKind, + timestamp: &str, + sequence: u64, +) -> Event { + Event { + spec_version: SpecVersion::V0_1, + event_id: EventId::new(event_id), + session_id: SessionId::new(session_id), + timestamp: OffsetDateTime::parse(timestamp, &Rfc3339).expect("valid timestamp"), + sequence: Some(sequence), + source: "codex".to_owned(), + kind, + project: Some("/workspace/project".to_owned()), + parent_event_id: None, + tool: None, + artifacts: Vec::new(), + metadata: BTreeMap::new(), + } +} + +fn tool_failure(event_id: &str, session_id: &str, timestamp: &str, sequence: u64) -> Event { + let mut event = session_event( + event_id, + session_id, + EventKind::ToolFailed, + timestamp, + sequence, + ); + event.tool = Some(ToolCall { + name: "bash".to_owned(), + input: Some(Value::String("private-command-token".to_owned())), + exit_code: Some(1), + duration_ms: Some(100), + metadata: BTreeMap::new(), + }); + event +} + +fn file_artifact(path: &str) -> Artifact { + Artifact { + kind: ArtifactKind::File, + path: Some(path.to_owned()), + uri: None, + digest: None, + metadata: BTreeMap::new(), + } +} diff --git a/docs/architecture/database-schema.md b/docs/architecture/database-schema.md index c48d420..0ab3732 100644 --- a/docs/architecture/database-schema.md +++ b/docs/architecture/database-schema.md @@ -1,18 +1,20 @@ # Milestone 1 database schema -Status: proposed for PR 2 (2026-07-16) +Status: implemented in PR 2 (2026-07-16) SQLite is the single-user source of truth. Foreign keys and WAL mode are enabled per connection. Timestamps are canonical RFC 3339 UTC strings so exports remain readable; sortable integer sequence fields break same-timestamp ties. -PR 2 will implement this schema as an immutable migration, not by executing this -document directly. +The authoritative DDL is the immutable +[`0001_initial.sql`](../../crates/autophagy-store/migrations/0001_initial.sql) +migration. The logical schema and its trust boundaries are summarized here. ```sql CREATE TABLE schema_migrations ( version INTEGER PRIMARY KEY, description TEXT NOT NULL, + checksum BLOB NOT NULL CHECK (length(checksum) = 32), applied_at TEXT NOT NULL ) STRICT; @@ -43,7 +45,7 @@ CREATE TABLE events ( row_id INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE CHECK (event_id LIKE 'evt_%'), spec_version TEXT NOT NULL, - session_id TEXT NOT NULL REFERENCES sessions(session_id), + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, occurred_at TEXT NOT NULL, sequence INTEGER CHECK (sequence IS NULL OR sequence >= 0), event_type TEXT NOT NULL, @@ -52,8 +54,8 @@ CREATE TABLE events ( tool_name TEXT, tool_input_text TEXT, exit_code INTEGER, - event_json TEXT NOT NULL, - content_hash BLOB NOT NULL, + event_json TEXT NOT NULL CHECK (json_valid(event_json)), + content_hash BLOB NOT NULL CHECK (length(content_hash) = 32), imported_at TEXT NOT NULL, UNIQUE (session_id, sequence) ) STRICT; @@ -72,10 +74,18 @@ CREATE TABLE artifacts ( path TEXT, uri TEXT, digest TEXT, - metadata_json TEXT NOT NULL DEFAULT '{}', - UNIQUE (artifact_type, path, uri, digest) + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + CHECK (path IS NOT NULL OR uri IS NOT NULL OR digest IS NOT NULL) ) STRICT; +CREATE UNIQUE INDEX artifacts_identity + ON artifacts( + artifact_type, + coalesce(path, ''), + coalesce(uri, ''), + coalesce(digest, '') + ); + CREATE TABLE event_artifacts ( event_row_id INTEGER NOT NULL REFERENCES events(row_id) ON DELETE CASCADE, artifact_id INTEGER NOT NULL REFERENCES artifacts(artifact_id), @@ -84,6 +94,28 @@ CREATE TABLE event_artifacts ( UNIQUE (event_row_id, artifact_id) ) STRICT; +CREATE TABLE events_search ( + event_row_id INTEGER PRIMARY KEY REFERENCES events(row_id) ON DELETE CASCADE, + project_path TEXT, + tool_name TEXT, + tool_input_text TEXT, + searchable_text TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE TABLE event_conflicts ( + conflict_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES events(event_id) ON DELETE CASCADE, + existing_content_hash BLOB NOT NULL, + conflicting_content_hash BLOB NOT NULL, + conflicting_event_json TEXT NOT NULL CHECK (json_valid(conflicting_event_json)), + source_adapter TEXT NOT NULL, + source_instance_key TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + observation_count INTEGER NOT NULL DEFAULT 1, + UNIQUE (event_id, conflicting_content_hash) +) STRICT; + CREATE TABLE imports ( import_id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(source_id), @@ -111,9 +143,12 @@ CREATE VIRTUAL TABLE events_fts USING fts5( ); ``` -`events_search` will be a one-to-one projection populated in the same transaction -as `events`; it contains only text approved by the redaction policy. Raw JSON is -never indexed blindly. +Insert, update, and delete triggers keep the external-content FTS5 table aligned +with `events_search`, including foreign-key cascades. `events_search` is populated +in the same transaction as `events`. Project paths and tool names come from the +already policy-processed AEP envelope; tool input and free text require an +explicit redaction-approved search projection. Raw event JSON and raw tool input +are never indexed blindly. ## Idempotency @@ -122,13 +157,15 @@ never indexed blindly. and canonicalized content. 2. `events.event_id` is the primary deduplication boundary. 3. A repeated ID with the same `content_hash` is a no-op. -4. A repeated ID with a different hash is quarantined as a contract conflict; it - is never silently overwritten. +4. A repeated ID with a different hash is committed to `event_conflicts` with + its source provenance and observation count. The canonical event is never + silently overwritten. 5. Source-file fingerprints and cursors avoid rescanning unchanged inputs, but correctness does not depend on that optimization. ## Deletion -Deleting a session cascades through event-to-artifact links and FTS projections. -Unreferenced artifacts are removed in the same transaction. `VACUUM` is an +Deleting a session cascades through events, conflict records, event-to-artifact +links, and FTS projections. Unreferenced artifacts are removed in the same +transaction. Connections enable SQLite `secure_delete`; `VACUUM` remains an explicit user operation because it has performance and disk-space implications. diff --git a/docs/architecture/repository-structure.md b/docs/architecture/repository-structure.md index e06ac83..e2bfb90 100644 --- a/docs/architecture/repository-structure.md +++ b/docs/architecture/repository-structure.md @@ -43,8 +43,9 @@ autophagy/ └── website/ ``` -Only `autophagy-events` exists in PR 1. A crate or package is added when its PR -contains an executable vertical slice; empty placeholder crates are avoided. +`autophagy-events` and `autophagy-store` exist through PR 2. A crate or package +is added when its PR contains an executable vertical slice; empty placeholder +crates are avoided. ## Dependency direction From 9bcdd7650cbf4d37b5d2edc78f2b736683c5ea76 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 15:35:00 +0530 Subject: [PATCH 2/4] maint: run quality gate in GitHub Actions --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ .mise.toml | 7 ++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40e0d4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + name: mise run check + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install tools with mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + version: 2026.6.1 + cache: true + + - name: Run quality gate + run: mise run check diff --git a/.mise.toml b/.mise.toml index 7fd55a5..025c3f0 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,7 @@ min_version = "2026.6.1" [tools] +actionlint = "1.7.12" pandoc = "3.10" rust = "1.97.0" @@ -20,6 +21,10 @@ run = "cargo test --workspace --all-features" description = "Parse the normalized blueprint as GitHub-flavored Markdown" run = "pandoc docs/blueprint/*.md --from=gfm --to=html --fail-if-warnings --output=/dev/null" +[tasks.ci] +description = "Validate GitHub Actions workflows" +run = "actionlint" + [tasks.check] description = "Run the pull-request quality gate" -depends = ["docs", "fmt", "lint", "test"] +depends = ["ci", "docs", "fmt", "lint", "test"] From 4df3133ed3791fd0acf498e5559c19476e236284 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 15:38:06 +0530 Subject: [PATCH 3/4] fix: install Rust quality components in CI --- .mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index 025c3f0..a3266c6 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,7 +3,7 @@ min_version = "2026.6.1" [tools] actionlint = "1.7.12" pandoc = "3.10" -rust = "1.97.0" +rust = { version = "1.97.0", profile = "minimal", components = ["clippy", "rustfmt"] } [tasks.fmt] description = "Check Rust formatting" From 3c83018e8fe21bdb7f2ae03a18a1f6c8bd5701cd Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 15:41:04 +0530 Subject: [PATCH 4/4] fix: use complete Rust CI profile --- .mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index a3266c6..c449c3f 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,7 +3,7 @@ min_version = "2026.6.1" [tools] actionlint = "1.7.12" pandoc = "3.10" -rust = { version = "1.97.0", profile = "minimal", components = ["clippy", "rustfmt"] } +rust = { version = "1.97.0", profile = "default" } [tasks.fmt] description = "Check Rust formatting"