crtx-store 0.1.0

SQLite persistence: migrations, repositories, transactions.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! Context pack repository operations.
//!
//! ADR 0026 §2 requires every durable mutation to compose through the policy
//! lattice. ContextPack persistence is the durable side of the
//! ADR 0016 / ADR 0019 consumer advisory contract: a pack that downstream
//! consumers will interpret as `OperatorExecutionTrusted` must not enter the
//! store on an implicit allow.
//!
//! Both entry points require the caller to pass a composed [`PolicyDecision`]:
//!
//! - [`ContextPackRepo::insert`] is the v1 default path. Required contributors:
//!   [`INSERT_DEFAULT_USE_ALLOWED_RULE_ID`] (mirrors the upstream
//!   `ContextPack::policy_decision` outcome that the CLI already enforces with
//!   `require_default_use_allowed`) and [`INSERT_BUILDER_PROVENANCE_RULE_ID`].
//! - [`ContextPackRepo::insert_with_consumer_advisory`] is the schema-v2 path.
//!   In addition to #8's contributors it requires
//!   [`CONSUMER_ADVISORY_RENDER_VS_EXEC_RULE_ID`] and
//!   [`CONSUMER_ADVISORY_TIER_GATE_RULE_ID`] from ADR 0016 / ADR 0019. The repo
//!   independently refuses an advisory whose render-vs-exec split is
//!   structurally unsafe by default (see [`ConsumerAdvisorySafety`]).
//!
//! The two methods are kept separate. ADR 0018 cutover has landed and the
//! `consumer_advisory_json` column exists, but it remains nullable for
//! pre-backfill rows and the v1 insertion contract is still a legitimate write
//! shape for tests and historical rows. Folding the two paths would force a
//! `ConsumerAdvisory` parameter onto every v1 caller and conflate the v1 and v2
//! policy preflight scopes.

use chrono::{DateTime, Utc};
use cortex_core::{
    compose_policy_outcomes, AdvisoryFlag, ConsumerAdvisory, ContextPackId, ExecutionTrustClass,
    PolicyContribution, PolicyDecision, PolicyOutcome, RenderTrustClass,
};
use rusqlite::{params, OptionalExtension, Row};
use serde_json::Value;

use crate::{Pool, StoreError, StoreResult};

macro_rules! context_pack_select_sql {
    ($where_clause:literal) => {
        concat!(
            "SELECT id, task, pack_json, selection_audit, created_at FROM context_packs ",
            $where_clause,
            ";"
        )
    };
}

/// Required contributor rule id documenting that the upstream
/// [`cortex_context::ContextPack::policy_decision`] outcome permits default use
/// of this pack (ADR 0026 §2, ADR 0016 / ADR 0019 consumer-advisory invariants).
/// The CLI already enforces the upstream contract through
/// `require_default_use_allowed`; this contributor reaches that same gate at
/// the store boundary so non-CLI callers cannot persist a `Reject` /
/// `Quarantine` pack on an implicit allow.
pub const INSERT_DEFAULT_USE_ALLOWED_RULE_ID: &str = "contextpack.insert.default_use_allowed";
/// Required contributor rule id documenting that the caller validated the
/// builder-side provenance for this row (non-empty task, non-empty selection
/// audit, deterministic `pack_json` shape).
pub const INSERT_BUILDER_PROVENANCE_RULE_ID: &str = "contextpack.insert.builder_provenance";
/// Required contributor rule id documenting that the
/// [`ConsumerAdvisory::render_trust`] vs
/// [`ConsumerAdvisory::execution_trust`] split was composed by the caller
/// (ADR 0016 §"Split trust classes"). The advisory MUST NOT collapse render
/// trust and execution trust into a single `OperatorTrusted` label.
pub const CONSUMER_ADVISORY_RENDER_VS_EXEC_RULE_ID: &str =
    "contextpack.consumer_advisory.render_vs_exec";
/// Required contributor rule id documenting that the trust-tier gate
/// authorising any non-default elevation was composed by the caller
/// (ADR 0019 §3). The repo independently refuses unsafe-default advisories
/// regardless of how this contributor voted.
pub const CONSUMER_ADVISORY_TIER_GATE_RULE_ID: &str = "contextpack.consumer_advisory.tier_gate";

/// Context pack data accepted by [`ContextPackRepo`].
#[derive(Debug, Clone, PartialEq)]
pub struct ContextPackRecord {
    /// Stable context pack identifier.
    pub id: ContextPackId,
    /// Task the pack was selected for.
    pub task: String,
    /// Serialized context pack body.
    pub pack_json: Value,
    /// Selection audit trail.
    pub selection_audit: String,
    /// Creation timestamp.
    pub created_at: DateTime<Utc>,
}

/// Structural safety classification for a [`ConsumerAdvisory`] under the ADR
/// 0016 split trust contract.
///
/// ADR 0016 §"Decision" defines `UntrustedExecution` as the default. An
/// advisory is structurally safe when execution trust is `UntrustedExecution`,
/// or when execution trust is elevated *and* no advisory flag contradicts the
/// elevation. The classification is independent of the composed
/// [`PolicyDecision`]: the repo refuses durable persistence of structurally
/// unsafe advisories even if a caller hand-composed `Allow` contributors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsumerAdvisorySafety {
    /// Advisory matches the ADR 0016 default safe posture.
    SafeDefault,
    /// Advisory elevates execution trust while flags name an unresolved hazard
    /// (`ContainsExecShaped`, `ContainsUnattestedSources`, or
    /// `ContainsCrossSessionUnvalidated`). Persistence is refused.
    UnsafeDefault,
}

impl ConsumerAdvisorySafety {
    /// Classify a [`ConsumerAdvisory`].
    ///
    /// `SafeDefault` is returned when execution trust stays untrusted, or when
    /// both render-trust and execution-trust elevations are consistent with the
    /// advisory flags. `UnsafeDefault` is returned when execution trust is
    /// elevated alongside an exec-shape / unattested-sources /
    /// cross-session-unvalidated flag, or when render trust is elevated while
    /// the advisory still flags unattested or exec-shaped content.
    #[must_use]
    pub fn classify(advisory: &ConsumerAdvisory) -> Self {
        let has_exec_shaped = advisory.flags.contains(&AdvisoryFlag::ContainsExecShaped);
        let has_unattested = advisory
            .flags
            .contains(&AdvisoryFlag::ContainsUnattestedSources);
        let has_cross_session_unvalidated = advisory
            .flags
            .contains(&AdvisoryFlag::ContainsCrossSessionUnvalidated);

        let execution_elevated = matches!(
            advisory.execution_trust,
            ExecutionTrustClass::OperatorExecutionTrusted
        );
        let render_elevated = matches!(
            advisory.render_trust,
            RenderTrustClass::OperatorRenderingTrusted
        );

        if execution_elevated
            && (has_exec_shaped || has_unattested || has_cross_session_unvalidated)
        {
            return Self::UnsafeDefault;
        }
        if render_elevated && (has_exec_shaped || has_unattested) {
            return Self::UnsafeDefault;
        }
        Self::SafeDefault
    }
}

/// Repository for context pack rows.
#[derive(Debug)]
pub struct ContextPackRepo<'a> {
    pool: &'a Pool,
}

impl<'a> ContextPackRepo<'a> {
    /// Creates a context pack repository over an open SQLite connection.
    #[must_use]
    pub const fn new(pool: &'a Pool) -> Self {
        Self { pool }
    }

    /// Inserts one context pack row using the current v1 shape through the
    /// ADR 0026 enforcement lattice.
    ///
    /// `policy` is the composed [`PolicyDecision`] for this insertion and MUST
    /// satisfy:
    ///
    /// 1. The final outcome is one of [`PolicyOutcome::Allow`],
    ///    [`PolicyOutcome::Warn`], or [`PolicyOutcome::BreakGlass`]. A
    ///    `Quarantine` or `Reject` decision fails closed and writes nothing.
    /// 2. The composition includes contributors for
    ///    [`INSERT_DEFAULT_USE_ALLOWED_RULE_ID`] and
    ///    [`INSERT_BUILDER_PROVENANCE_RULE_ID`]. The repo refuses callers that
    ///    skipped composition.
    pub fn insert(&self, pack: &ContextPackRecord, policy: &PolicyDecision) -> StoreResult<()> {
        require_policy_final_outcome(policy, "contextpack.insert")?;
        require_contributor_rule(policy, INSERT_DEFAULT_USE_ALLOWED_RULE_ID)?;
        require_contributor_rule(policy, INSERT_BUILDER_PROVENANCE_RULE_ID)?;

        validate_context_pack(pack)?;

        self.pool.execute(
            "INSERT INTO context_packs (
                id, task, pack_json, selection_audit, created_at
             ) VALUES (?1, ?2, ?3, ?4, ?5);",
            params![
                pack.id.to_string(),
                pack.task,
                serde_json::to_string(&pack.pack_json)?,
                pack.selection_audit,
                pack.created_at.to_rfc3339(),
            ],
        )?;

        Ok(())
    }

    /// Inserts one context pack row with explicit schema-v2 consumer advisory
    /// posture through the ADR 0026 enforcement lattice.
    ///
    /// This is the default v2 write path now that the schema-v2 atomic cutover
    /// has landed (see `docs/design/DESIGN_schema_v2_cutover.md`). It requires
    /// the same contributors as [`Self::insert`] plus
    /// [`CONSUMER_ADVISORY_RENDER_VS_EXEC_RULE_ID`] and
    /// [`CONSUMER_ADVISORY_TIER_GATE_RULE_ID`] from ADR 0016 / ADR 0019.
    ///
    /// Independent of the composed policy, this method refuses persistence
    /// when the advisory is [`ConsumerAdvisorySafety::UnsafeDefault`] —
    /// elevating execution trust while flagging an unresolved hazard, or
    /// elevating render trust while flagging unattested or exec-shaped
    /// content. ADR 0016 makes the split-trust contract a hard wall:
    /// `BreakGlass` MUST NOT durably persist an advisory whose elevation
    /// contradicts its own flags.
    pub fn insert_with_consumer_advisory(
        &self,
        pack: &ContextPackRecord,
        advisory: &ConsumerAdvisory,
        policy: &PolicyDecision,
    ) -> StoreResult<()> {
        require_policy_final_outcome(policy, "contextpack.insert_with_consumer_advisory")?;
        require_contributor_rule(policy, INSERT_DEFAULT_USE_ALLOWED_RULE_ID)?;
        require_contributor_rule(policy, INSERT_BUILDER_PROVENANCE_RULE_ID)?;
        require_contributor_rule(policy, CONSUMER_ADVISORY_RENDER_VS_EXEC_RULE_ID)?;
        require_contributor_rule(policy, CONSUMER_ADVISORY_TIER_GATE_RULE_ID)?;
        require_advisory_safe_default(advisory)?;

        validate_context_pack(pack)?;

        self.pool.execute(
            "INSERT INTO context_packs (
                id, task, pack_json, selection_audit, created_at, consumer_advisory_json
             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
            params![
                pack.id.to_string(),
                pack.task,
                serde_json::to_string(&pack.pack_json)?,
                pack.selection_audit,
                pack.created_at.to_rfc3339(),
                serde_json::to_string(advisory)?,
            ],
        )?;

        Ok(())
    }

    /// Fetches a context pack by id.
    pub fn get_by_id(&self, id: &ContextPackId) -> StoreResult<Option<ContextPackRecord>> {
        let row = self
            .pool
            .query_row(
                context_pack_select_sql!("WHERE id = ?1"),
                params![id.to_string()],
                context_pack_row,
            )
            .optional()?;

        row.map(TryInto::try_into).transpose()
    }
}

#[derive(Debug)]
struct ContextPackRow {
    id: String,
    task: String,
    pack_json: String,
    selection_audit: String,
    created_at: String,
}

fn context_pack_row(row: &Row<'_>) -> rusqlite::Result<ContextPackRow> {
    Ok(ContextPackRow {
        id: row.get(0)?,
        task: row.get(1)?,
        pack_json: row.get(2)?,
        selection_audit: row.get(3)?,
        created_at: row.get(4)?,
    })
}

impl TryFrom<ContextPackRow> for ContextPackRecord {
    type Error = StoreError;

    fn try_from(row: ContextPackRow) -> StoreResult<Self> {
        Ok(Self {
            id: row.id.parse()?,
            task: row.task,
            pack_json: serde_json::from_str(&row.pack_json)?,
            selection_audit: row.selection_audit,
            created_at: DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
        })
    }
}

fn validate_context_pack(pack: &ContextPackRecord) -> StoreResult<()> {
    if pack.task.trim().is_empty() {
        return Err(StoreError::Validation(
            "context pack requires non-empty task".into(),
        ));
    }
    if pack.selection_audit.trim().is_empty() {
        return Err(StoreError::Validation(
            "context pack requires non-empty selection audit".into(),
        ));
    }
    Ok(())
}

fn require_policy_final_outcome(policy: &PolicyDecision, surface: &str) -> StoreResult<()> {
    match policy.final_outcome {
        PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
        PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
            "{surface} preflight: composed policy outcome {:?} blocks context-pack persistence",
            policy.final_outcome,
        ))),
    }
}

fn require_contributor_rule(policy: &PolicyDecision, rule_id: &str) -> StoreResult<()> {
    let contains_rule = policy
        .contributing
        .iter()
        .chain(policy.discarded.iter())
        .any(|contribution| contribution.rule_id.as_str() == rule_id);
    if contains_rule {
        Ok(())
    } else {
        Err(StoreError::Validation(format!(
            "policy decision missing required contributor `{rule_id}`; caller skipped ADR 0026 composition",
        )))
    }
}

fn require_advisory_safe_default(advisory: &ConsumerAdvisory) -> StoreResult<()> {
    match ConsumerAdvisorySafety::classify(advisory) {
        ConsumerAdvisorySafety::SafeDefault => Ok(()),
        ConsumerAdvisorySafety::UnsafeDefault => Err(StoreError::Validation(format!(
            "contextpack.insert_with_consumer_advisory preflight: ConsumerAdvisory is unsafe by default \
             (render_trust={:?}, execution_trust={:?}, flags={:?}); ADR 0016 forbids durable persistence of \
             an advisory whose elevation contradicts its own flags",
            advisory.render_trust, advisory.execution_trust, advisory.flags,
        ))),
    }
}

/// Build a [`PolicyDecision`] that satisfies [`ContextPackRepo::insert`] for
/// the happy path. Intended for tests and fixtures only.
///
/// Production callers MUST compose [`INSERT_DEFAULT_USE_ALLOWED_RULE_ID`] from
/// the upstream [`cortex_context::ContextPack::policy_decision`] outcome and
/// [`INSERT_BUILDER_PROVENANCE_RULE_ID`] from real builder provenance. This
/// helper is exposed unconditionally because integration test crates outside
/// `cortex-store` need the same fixture shape; the `_test_allow` suffix is the
/// contract that documents intent.
#[must_use]
pub fn insert_policy_decision_test_allow() -> PolicyDecision {
    compose_policy_outcomes(
        vec![
            PolicyContribution::new(
                INSERT_DEFAULT_USE_ALLOWED_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: upstream ContextPack::policy_decision allows default use",
            )
            .expect("static test contribution is valid"),
            PolicyContribution::new(
                INSERT_BUILDER_PROVENANCE_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: builder provenance validated",
            )
            .expect("static test contribution is valid"),
        ],
        None,
    )
}

/// Build a [`PolicyDecision`] that satisfies
/// [`ContextPackRepo::insert_with_consumer_advisory`] for the happy path.
/// Intended for tests and fixtures only; see
/// [`insert_policy_decision_test_allow`] for the production-caller contract.
#[must_use]
pub fn insert_with_consumer_advisory_policy_decision_test_allow() -> PolicyDecision {
    compose_policy_outcomes(
        vec![
            PolicyContribution::new(
                INSERT_DEFAULT_USE_ALLOWED_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: upstream ContextPack::policy_decision allows default use",
            )
            .expect("static test contribution is valid"),
            PolicyContribution::new(
                INSERT_BUILDER_PROVENANCE_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: builder provenance validated",
            )
            .expect("static test contribution is valid"),
            PolicyContribution::new(
                CONSUMER_ADVISORY_RENDER_VS_EXEC_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: render-vs-exec split composed",
            )
            .expect("static test contribution is valid"),
            PolicyContribution::new(
                CONSUMER_ADVISORY_TIER_GATE_RULE_ID,
                PolicyOutcome::Allow,
                "test fixture: trust tier gate satisfied",
            )
            .expect("static test contribution is valid"),
        ],
        None,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn safe_default_advisory() -> ConsumerAdvisory {
        ConsumerAdvisory::untrusted_default()
    }

    #[test]
    fn safe_default_advisory_classifies_as_safe() {
        assert_eq!(
            ConsumerAdvisorySafety::classify(&safe_default_advisory()),
            ConsumerAdvisorySafety::SafeDefault
        );
    }

    #[test]
    fn execution_elevated_with_exec_shaped_is_unsafe_default() {
        let advisory = ConsumerAdvisory {
            render_trust: RenderTrustClass::UntrustedRendering,
            execution_trust: ExecutionTrustClass::OperatorExecutionTrusted,
            flags: vec![AdvisoryFlag::ContainsExecShaped],
            advisory_text: "operator elevated execution but exec-shaped strings present".into(),
        };
        assert_eq!(
            ConsumerAdvisorySafety::classify(&advisory),
            ConsumerAdvisorySafety::UnsafeDefault
        );
    }

    #[test]
    fn execution_elevated_with_unattested_sources_is_unsafe_default() {
        let advisory = ConsumerAdvisory {
            render_trust: RenderTrustClass::UntrustedRendering,
            execution_trust: ExecutionTrustClass::OperatorExecutionTrusted,
            flags: vec![AdvisoryFlag::ContainsUnattestedSources],
            advisory_text: "operator elevated execution despite unattested sources".into(),
        };
        assert_eq!(
            ConsumerAdvisorySafety::classify(&advisory),
            ConsumerAdvisorySafety::UnsafeDefault
        );
    }

    #[test]
    fn render_elevated_with_exec_shaped_is_unsafe_default() {
        let advisory = ConsumerAdvisory {
            render_trust: RenderTrustClass::OperatorRenderingTrusted,
            execution_trust: ExecutionTrustClass::UntrustedExecution,
            flags: vec![AdvisoryFlag::ContainsExecShaped],
            advisory_text: "render elevated but exec-shaped strings present".into(),
        };
        assert_eq!(
            ConsumerAdvisorySafety::classify(&advisory),
            ConsumerAdvisorySafety::UnsafeDefault
        );
    }

    #[test]
    fn execution_elevated_without_hazardous_flags_is_safe_default() {
        let advisory = ConsumerAdvisory {
            render_trust: RenderTrustClass::UntrustedRendering,
            execution_trust: ExecutionTrustClass::OperatorExecutionTrusted,
            flags: vec![AdvisoryFlag::RedactedDefaultPolicy],
            advisory_text: "operator elevated execution after vetting".into(),
        };
        assert_eq!(
            ConsumerAdvisorySafety::classify(&advisory),
            ConsumerAdvisorySafety::SafeDefault
        );
    }
}