use chrono::{DateTime, Utc};
use cortex_core::{
ContradictionId, PolicyContribution, PolicyDecision, PolicyOutcome, TemporalAuthorityReport,
};
use rusqlite::{params, OptionalExtension, Row};
use crate::{Pool, StoreError, StoreResult};
macro_rules! contradiction_select_sql {
($where_clause:literal) => {
concat!(
"SELECT id, left_ref, right_ref, contradiction_type, status, interpretation,
created_at, updated_at
FROM contradictions ",
$where_clause,
";"
)
};
}
pub const INSERT_SCOPE_VALIDITY_RULE_ID: &str = "contradictions.insert.scope_validity";
pub const INSERT_UNRESOLVED_STATE_RULE_ID: &str = "contradictions.insert.unresolved_state";
pub const TRANSITION_ACTOR_AUTHORITY_RULE_ID: &str = "contradictions.transition.actor_authority";
pub const TRANSITION_RESOLUTION_EVIDENCE_RULE_ID: &str =
"contradictions.transition.resolution_evidence";
pub const TRANSITION_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID: &str =
"contradictions.transition.operator_temporal_authority";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContradictionStatus {
Unresolved,
Interpreted,
Resolved,
}
impl ContradictionStatus {
#[must_use]
pub const fn wire(self) -> &'static str {
match self {
Self::Unresolved => "unresolved",
Self::Interpreted => "interpreted",
Self::Resolved => "resolved",
}
}
#[must_use]
pub const fn is_open(self) -> bool {
matches!(self, Self::Unresolved | Self::Interpreted)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContradictionRecord {
pub id: ContradictionId,
pub left_ref: String,
pub right_ref: String,
pub contradiction_type: String,
pub status: ContradictionStatus,
pub interpretation: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub struct ContradictionRepo<'a> {
pool: &'a Pool,
}
impl<'a> ContradictionRepo<'a> {
#[must_use]
pub const fn new(pool: &'a Pool) -> Self {
Self { pool }
}
pub fn insert(
&self,
contradiction: &ContradictionRecord,
policy: &PolicyDecision,
) -> StoreResult<()> {
require_policy_final_outcome(policy, "contradictions.insert")?;
require_contributor_rule(policy, INSERT_SCOPE_VALIDITY_RULE_ID)?;
require_contributor_rule(policy, INSERT_UNRESOLVED_STATE_RULE_ID)?;
validate_record(contradiction)?;
self.pool.execute(
"INSERT INTO contradictions (
id, left_ref, right_ref, contradiction_type, status, interpretation,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![
contradiction.id.to_string(),
contradiction.left_ref,
contradiction.right_ref,
contradiction.contradiction_type,
contradiction.status.wire(),
contradiction.interpretation,
contradiction.created_at.to_rfc3339(),
contradiction.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
pub fn get_by_id(&self, id: &ContradictionId) -> StoreResult<Option<ContradictionRecord>> {
let row = self
.pool
.query_row(
contradiction_select_sql!("WHERE id = ?1"),
params![id.to_string()],
contradiction_row,
)
.optional()?;
row.map(TryInto::try_into).transpose()
}
pub fn list(&self) -> StoreResult<Vec<ContradictionRecord>> {
let mut stmt = self
.pool
.prepare(contradiction_select_sql!("ORDER BY created_at, id"))?;
let rows = stmt.query_map([], contradiction_row)?;
collect_records(rows)
}
pub fn list_by_status(
&self,
status: ContradictionStatus,
) -> StoreResult<Vec<ContradictionRecord>> {
let mut stmt = self.pool.prepare(contradiction_select_sql!(
"WHERE status = ?1 ORDER BY created_at, id"
))?;
let rows = stmt.query_map(params![status.wire()], contradiction_row)?;
collect_records(rows)
}
pub fn list_open(&self) -> StoreResult<Vec<ContradictionRecord>> {
let mut stmt = self.pool.prepare(contradiction_select_sql!(
"WHERE status IN ('unresolved', 'interpreted') ORDER BY created_at, id"
))?;
let rows = stmt.query_map([], contradiction_row)?;
collect_records(rows)
}
pub fn interpret(
&self,
id: &ContradictionId,
interpretation: &str,
updated_at: DateTime<Utc>,
policy: &PolicyDecision,
) -> StoreResult<()> {
require_transition_policy(policy, "contradictions.interpret", false)?;
update_status(
self.pool,
id,
ContradictionStatus::Interpreted,
interpretation,
updated_at,
)
}
pub fn resolve(
&self,
id: &ContradictionId,
resolution: &str,
updated_at: DateTime<Utc>,
policy: &PolicyDecision,
) -> StoreResult<()> {
require_transition_policy(policy, "contradictions.resolve", true)?;
update_status(
self.pool,
id,
ContradictionStatus::Resolved,
resolution,
updated_at,
)
}
pub fn delete(&self, id: &ContradictionId, policy: &PolicyDecision) -> StoreResult<bool> {
require_transition_policy(policy, "contradictions.delete", true)?;
let changed = self.pool.execute(
"DELETE FROM contradictions WHERE id = ?1;",
params![id.to_string()],
)?;
Ok(changed > 0)
}
}
#[derive(Debug)]
struct ContradictionRow {
id: String,
left_ref: String,
right_ref: String,
contradiction_type: String,
status: String,
interpretation: Option<String>,
created_at: String,
updated_at: String,
}
fn contradiction_row(row: &Row<'_>) -> rusqlite::Result<ContradictionRow> {
Ok(ContradictionRow {
id: row.get(0)?,
left_ref: row.get(1)?,
right_ref: row.get(2)?,
contradiction_type: row.get(3)?,
status: row.get(4)?,
interpretation: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
impl TryFrom<ContradictionRow> for ContradictionRecord {
type Error = StoreError;
fn try_from(row: ContradictionRow) -> StoreResult<Self> {
Ok(Self {
id: row.id.parse()?,
left_ref: row.left_ref,
right_ref: row.right_ref,
contradiction_type: row.contradiction_type,
status: parse_status(&row.status)?,
interpretation: row.interpretation,
created_at: DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.updated_at)?.with_timezone(&Utc),
})
}
}
fn collect_records<F>(rows: rusqlite::MappedRows<'_, F>) -> StoreResult<Vec<ContradictionRecord>>
where
F: FnMut(&Row<'_>) -> rusqlite::Result<ContradictionRow>,
{
let mut records = Vec::new();
for row in rows {
records.push(row?.try_into()?);
}
Ok(records)
}
fn parse_status(status: &str) -> StoreResult<ContradictionStatus> {
match status {
"unresolved" => Ok(ContradictionStatus::Unresolved),
"interpreted" => Ok(ContradictionStatus::Interpreted),
"resolved" => Ok(ContradictionStatus::Resolved),
other => Err(StoreError::Validation(format!(
"invalid contradiction status {other}"
))),
}
}
fn validate_record(record: &ContradictionRecord) -> StoreResult<()> {
validate_not_empty("left_ref", &record.left_ref)?;
validate_not_empty("right_ref", &record.right_ref)?;
validate_not_empty("contradiction_type", &record.contradiction_type)?;
if matches!(
record.status,
ContradictionStatus::Interpreted | ContradictionStatus::Resolved
) && record
.interpretation
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return Err(StoreError::Validation(
"interpreted/resolved contradiction requires interpretation".into(),
));
}
Ok(())
}
fn update_status(
pool: &Pool,
id: &ContradictionId,
status: ContradictionStatus,
note: &str,
updated_at: DateTime<Utc>,
) -> StoreResult<()> {
validate_not_empty("interpretation", note)?;
let changed = pool.execute(
"UPDATE contradictions
SET status = ?2, interpretation = ?3, updated_at = ?4
WHERE id = ?1;",
params![id.to_string(), status.wire(), note, updated_at.to_rfc3339()],
)?;
if changed == 0 {
return Err(StoreError::Validation(format!(
"contradiction {id} not found"
)));
}
Ok(())
}
fn validate_not_empty(field: &str, value: &str) -> StoreResult<()> {
if value.trim().is_empty() {
return Err(StoreError::Validation(format!("{field} must not be empty")));
}
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 contradiction mutation",
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_transition_policy(
policy: &PolicyDecision,
surface: &str,
require_operator_temporal_authority: bool,
) -> StoreResult<()> {
require_policy_final_outcome(policy, surface)?;
require_contributor_rule(policy, TRANSITION_ACTOR_AUTHORITY_RULE_ID)?;
require_contributor_rule(policy, TRANSITION_RESOLUTION_EVIDENCE_RULE_ID)?;
if require_operator_temporal_authority {
require_contributor_rule(policy, TRANSITION_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID)?;
}
Ok(())
}
#[must_use]
pub fn contradiction_transition_temporal_authority_contribution(
report: &TemporalAuthorityReport,
) -> PolicyContribution {
let outcome = if report.valid_now {
PolicyOutcome::Allow
} else if report.valid_at_event_time {
PolicyOutcome::Quarantine
} else {
PolicyOutcome::Reject
};
let reason = if report.valid_now {
"operator temporal authority is currently valid"
} else if report.valid_at_event_time {
"operator temporal authority is historical only; current use blocked"
} else {
"operator temporal authority was invalid at event time"
};
PolicyContribution::new(
TRANSITION_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID,
outcome,
reason,
)
.expect("static contradiction temporal authority contribution is valid")
}
#[must_use]
pub fn insert_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
INSERT_SCOPE_VALIDITY_RULE_ID,
PolicyOutcome::Allow,
"test fixture: contradiction scope validated",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
INSERT_UNRESOLVED_STATE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: initial lifecycle state asserted",
)
.expect("static test contribution is valid"),
],
None,
)
}
#[must_use]
pub fn interpret_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
TRANSITION_ACTOR_AUTHORITY_RULE_ID,
PolicyOutcome::Allow,
"test fixture: actor authority present",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
TRANSITION_RESOLUTION_EVIDENCE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: interpretation evidence supplied",
)
.expect("static test contribution is valid"),
],
None,
)
}
#[must_use]
pub fn close_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
TRANSITION_ACTOR_AUTHORITY_RULE_ID,
PolicyOutcome::Allow,
"test fixture: actor authority present",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
TRANSITION_RESOLUTION_EVIDENCE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: resolution evidence supplied",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
TRANSITION_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID,
PolicyOutcome::Allow,
"test fixture: operator temporal authority current",
)
.expect("static test contribution is valid"),
],
None,
)
}