1pub const BOOTSTRAP_SQL: &str = "
9CREATE TABLE IF NOT EXISTS schema_meta (
10 key TEXT PRIMARY KEY,
11 value TEXT NOT NULL
12);
13";
14
15pub const MIGRATION_001_INITIAL_SCHEMA: &str = "
22PRAGMA journal_mode = WAL;
23PRAGMA busy_timeout = 5000;
24PRAGMA foreign_keys = ON;
25
26CREATE TABLE IF NOT EXISTS workspace_scans (
27 workspace_path TEXT PRIMARY KEY,
28 workspace_name TEXT NOT NULL,
29 last_scan_ts TEXT NOT NULL,
30 scan_duration_ms INTEGER NOT NULL,
31 asset_count INTEGER NOT NULL,
32 fs_mtime_at_scan INTEGER NOT NULL
33);
34
35CREATE TABLE IF NOT EXISTS installed_assets (
36 id INTEGER PRIMARY KEY AUTOINCREMENT,
37 workspace_path TEXT NOT NULL REFERENCES workspace_scans(workspace_path) ON DELETE CASCADE,
38 asset_id TEXT NOT NULL,
39 asset_type TEXT NOT NULL,
40 installed_path TEXT NOT NULL,
41 content_hash TEXT NOT NULL,
42 version TEXT,
43 detection_method TEXT NOT NULL,
44 harness TEXT NOT NULL,
45 scan_ts TEXT NOT NULL,
46 UNIQUE(workspace_path, asset_id, harness)
47);
48
49CREATE INDEX IF NOT EXISTS idx_installed_assets_workspace
50 ON installed_assets(workspace_path);
51CREATE INDEX IF NOT EXISTS idx_installed_assets_asset_id
52 ON installed_assets(asset_id);
53
54CREATE TABLE IF NOT EXISTS content_hashes (
55 path TEXT PRIMARY KEY,
56 hash TEXT NOT NULL,
57 last_checked TEXT NOT NULL
58);
59";
60
61pub const MIGRATION_002_AUDIT_LOG: &str = "
68CREATE TABLE IF NOT EXISTS audit_log (
69 id INTEGER PRIMARY KEY AUTOINCREMENT,
70 timestamp TEXT NOT NULL,
71 event_type TEXT NOT NULL,
72 subject TEXT NOT NULL,
73 details TEXT NOT NULL,
74 operator TEXT NOT NULL,
75 entry_hash TEXT NOT NULL,
76 prev_hash TEXT NOT NULL
77);
78
79CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp
80 ON audit_log(timestamp);
81CREATE INDEX IF NOT EXISTS idx_audit_log_event_type
82 ON audit_log(event_type);
83CREATE INDEX IF NOT EXISTS idx_audit_log_subject
84 ON audit_log(subject);
85
86CREATE TRIGGER IF NOT EXISTS audit_log_no_update
87 BEFORE UPDATE ON audit_log
88BEGIN
89 SELECT RAISE(ABORT, 'audit_log is append-only: UPDATE not permitted');
90END;
91
92CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
93 BEFORE DELETE ON audit_log
94BEGIN
95 SELECT RAISE(ABORT, 'audit_log is append-only: DELETE not permitted');
96END;
97";
98
99pub const MIGRATION_003_GATE_HISTORY: &str = "
105CREATE TABLE IF NOT EXISTS gate_history (
106 id INTEGER PRIMARY KEY AUTOINCREMENT,
107 gate_name TEXT NOT NULL,
108 status TEXT NOT NULL,
109 exit_code INTEGER,
110 duration_ms INTEGER NOT NULL,
111 timestamp TEXT NOT NULL,
112 catalog_hash TEXT NOT NULL,
113 output_excerpt TEXT
114);
115
116CREATE INDEX IF NOT EXISTS idx_gate_history_name
117 ON gate_history(gate_name);
118CREATE INDEX IF NOT EXISTS idx_gate_history_timestamp
119 ON gate_history(timestamp);
120
121CREATE TABLE IF NOT EXISTS drift_history (
122 id INTEGER PRIMARY KEY AUTOINCREMENT,
123 workspace_path TEXT NOT NULL,
124 asset_id TEXT NOT NULL,
125 drift_type TEXT NOT NULL,
126 first_detected TEXT NOT NULL,
127 resolved_at TEXT,
128 expected_hash TEXT NOT NULL,
129 actual_hash TEXT NOT NULL
130);
131
132CREATE INDEX IF NOT EXISTS idx_drift_workspace
133 ON drift_history(workspace_path);
134CREATE INDEX IF NOT EXISTS idx_drift_unresolved
135 ON drift_history(resolved_at)
136 WHERE resolved_at IS NULL;
137";
138
139pub const MIGRATION_004_COVERAGE_CACHE: &str = "
146CREATE TABLE IF NOT EXISTS coverage_cache (
147 workspace_path TEXT PRIMARY KEY,
148 workspace_name TEXT NOT NULL,
149 coverage_score REAL NOT NULL,
150 computed_at TEXT NOT NULL
151);
152
153CREATE INDEX IF NOT EXISTS idx_coverage_cache_name
154 ON coverage_cache(workspace_name);
155";
156
157pub const MIGRATIONS: &[(u32, &str)] = &[
162 (1, MIGRATION_001_INITIAL_SCHEMA),
163 (2, MIGRATION_002_AUDIT_LOG),
164 (3, MIGRATION_003_GATE_HISTORY),
165 (4, MIGRATION_004_COVERAGE_CACHE),
166];
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn migrations_are_in_ascending_order() {
174 let versions: Vec<u32> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
175 for window in versions.windows(2) {
176 assert!(
177 window[0] < window[1],
178 "migrations must be ordered: {versions:?}"
179 );
180 }
181 }
182
183 #[test]
184 fn bootstrap_creates_schema_meta() {
185 let conn = rusqlite::Connection::open_in_memory().unwrap();
186 conn.execute_batch(BOOTSTRAP_SQL).unwrap();
187 conn.execute(
189 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', '0')",
190 [],
191 )
192 .unwrap();
193 let v: String = conn
194 .query_row(
195 "SELECT value FROM schema_meta WHERE key = 'schema_version'",
196 [],
197 |row| row.get(0),
198 )
199 .unwrap();
200 assert_eq!(v, "0");
201 }
202
203 #[test]
204 fn migration_001_creates_expected_tables() {
205 let conn = rusqlite::Connection::open_in_memory().unwrap();
206 conn.execute_batch(BOOTSTRAP_SQL).unwrap();
207 conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
208 conn.execute(
210 "INSERT INTO workspace_scans \
211 (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
212 VALUES ('/tmp/ws', 'ws', '2025-01-01T00:00:00Z', 100, 5, 1234567890)",
213 [],
214 )
215 .unwrap();
216 conn.execute(
218 "INSERT INTO installed_assets \
219 (workspace_path, asset_id, asset_type, installed_path, content_hash, \
220 detection_method, harness, scan_ts) \
221 VALUES ('/tmp/ws', 'agent-1', 'agent', '/tmp/ws/.claude/agents/a.md', \
222 'hash', 'directory_scan', 'claude', '2025-01-01T00:00:00Z')",
223 [],
224 )
225 .unwrap();
226 }
227
228 #[test]
229 fn migration_002_append_only_triggers() {
230 let conn = rusqlite::Connection::open_in_memory().unwrap();
231 conn.execute_batch(BOOTSTRAP_SQL).unwrap();
232 conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
233 conn.execute_batch(MIGRATION_002_AUDIT_LOG).unwrap();
234
235 conn.execute(
237 "INSERT INTO audit_log \
238 (timestamp, event_type, subject, details, operator, entry_hash, prev_hash) \
239 VALUES ('2025-01-01T00:00:00.000Z', 'policy_evaluation', 'ws', '{}', \
240 'system', 'hash1', '')",
241 [],
242 )
243 .unwrap();
244
245 let update_result =
247 conn.execute("UPDATE audit_log SET subject = 'tampered' WHERE id = 1", []);
248 assert!(
249 update_result.is_err(),
250 "UPDATE should be rejected by trigger"
251 );
252
253 let delete_result = conn.execute("DELETE FROM audit_log WHERE id = 1", []);
255 assert!(
256 delete_result.is_err(),
257 "DELETE should be rejected by trigger"
258 );
259 }
260
261 #[test]
262 fn migration_003_creates_gate_history() {
263 let conn = rusqlite::Connection::open_in_memory().unwrap();
264 conn.execute_batch(BOOTSTRAP_SQL).unwrap();
265 conn.execute_batch(MIGRATION_001_INITIAL_SCHEMA).unwrap();
266 conn.execute_batch(MIGRATION_002_AUDIT_LOG).unwrap();
267 conn.execute_batch(MIGRATION_003_GATE_HISTORY).unwrap();
268
269 conn.execute(
270 "INSERT INTO gate_history \
271 (gate_name, status, exit_code, duration_ms, timestamp, catalog_hash) \
272 VALUES ('lint', 'pass', 0, 1200, '2025-01-01T00:00:00Z', 'chash')",
273 [],
274 )
275 .unwrap();
276 }
277}