use crate::canonical::compute_id;
use crate::capture::{harvested_test_check, Decision};
use crate::store::Store;
use crate::tick::{Ground, Tick};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub struct MigrationRecord {
pub source_key: String,
pub decision: String,
pub observe: String,
pub blame: Option<String>,
pub grounds: Vec<Ground>,
pub authority: Option<String>,
pub jurisdiction: Option<String>,
pub source_ref: Option<serde_json::Value>,
pub provenance: Option<String>,
}
fn first_round_or_issue_token(text: &str) -> Option<String> {
text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
.find(|tok| {
let rest = tok
.strip_prefix('#')
.or_else(|| tok.strip_prefix('R'))
.or_else(|| tok.strip_prefix('r'));
matches!(rest, Some(d) if !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
})
.map(|t| t.to_string())
}
fn structured_rejected_roads(block: &str) -> Vec<Ground> {
let mut out = Vec::new();
for line in block.lines() {
let l = line.trim_start_matches(['-', '*', ' ', '\t']).trim();
let body = l
.strip_prefix("rejected:")
.or_else(|| l.strip_prefix("rejected "))
.or_else(|| l.strip_prefix("reject:"))
.or_else(|| l.strip_prefix("reject "));
if let Some(rest) = body {
if let Some((opt, why)) = rest.split_once(':') {
let (opt, why) = (opt.trim(), why.trim());
if !opt.is_empty() && !why.is_empty() {
out.push(Ground {
claim: why.to_string(),
supports: format!("rejected:{opt}"),
check: None,
});
}
}
}
}
out
}
fn flush_record(header: &Option<(String, String)>, body: &str, out: &mut Vec<MigrationRecord>) {
if let Some((key, decision)) = header {
out.push(MigrationRecord {
source_key: key.clone(),
decision: decision.clone(),
observe: key.clone(),
blame: None,
grounds: structured_rejected_roads(body),
authority: None,
jurisdiction: None,
source_ref: Some(serde_json::Value::String(key.clone())),
provenance: None,
});
}
}
fn store_key(raw: &serde_json::Value) -> Option<String> {
raw.get("source_ref")
.map(crate::tick::source_ref_key)
.or_else(|| {
raw.get("observe")
.and_then(|x| x.as_str())
.and_then(first_round_or_issue_token)
})
}
const CANONICAL_KEYS: &[&str] = &[
"kind",
"decision",
"observe",
"grounds",
"blame",
"authority",
"jurisdiction",
"source_ref",
"provenance",
];
pub fn canonical_records(text: &str) -> Result<Vec<MigrationRecord>, String> {
use crate::capture::validate_authority;
use crate::tick::{
ground_from_value, only_keys, req_str, source_ref_key, validate_jurisdiction,
validate_provenance, validate_source_ref,
};
let mut out = Vec::new();
for (i, raw_line) in text.lines().enumerate() {
let n = i + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let v: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("canonical line {n}: not JSON: {e}"))?;
let obj = v
.as_object()
.ok_or_else(|| format!("canonical line {n}: not a JSON object"))?;
only_keys(obj, CANONICAL_KEYS, &format!("canonical line {n}"))?;
match obj.get("kind").and_then(|x| x.as_str()) {
Some("ev-decision-intake") => {}
other => {
return Err(format!(
"canonical line {n}: not an ev-decision-intake record (kind={other:?})"
))
}
}
let decision = req_str(obj, "decision").map_err(|e| format!("canonical line {n}: {e}"))?;
if decision.trim().is_empty() {
return Err(format!("canonical line {n}: decision is empty"));
}
let observe = obj
.get("observe")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let grounds_v = obj
.get("grounds")
.and_then(|x| x.as_array())
.ok_or_else(|| format!("canonical line {n}: grounds missing/not array"))?;
let mut grounds = Vec::new();
for gv in grounds_v {
grounds.push(ground_from_value(gv).map_err(|e| format!("canonical line {n}: {e}"))?);
}
let blame = obj
.get("blame")
.and_then(|x| x.as_str())
.map(str::to_string);
let opt_tag = |key: &str,
validate: fn(&str) -> Result<(), String>|
-> Result<Option<String>, String> {
match obj.get(key).and_then(|x| x.as_str()) {
None => Ok(None),
Some(v) => {
validate(v).map_err(|e| format!("canonical line {n}: {e}"))?;
Ok(Some(v.to_string()))
}
}
};
let authority = opt_tag("authority", validate_authority)?;
let jurisdiction = opt_tag("jurisdiction", validate_jurisdiction)?;
let provenance = opt_tag("provenance", validate_provenance)?;
let source_ref = match obj.get("source_ref") {
None => None,
Some(rv) => {
validate_source_ref(rv).map_err(|e| format!("canonical line {n}: {e}"))?;
Some(rv.clone())
}
};
let source_key = source_ref
.as_ref()
.map(source_ref_key)
.or_else(|| first_round_or_issue_token(&observe))
.unwrap_or_default();
out.push(MigrationRecord {
source_key,
decision,
observe,
blame,
grounds,
authority,
jurisdiction,
source_ref,
provenance,
});
}
Ok(out)
}
pub fn extract_gitlog(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None; let mut body = String::new();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("## ") {
flush_record(&header, &body, &mut records);
body.clear();
let key = first_round_or_issue_token(rest);
let decision = match key.as_deref() {
Some(k) => rest
.split_once(k)
.map(|x| x.1)
.unwrap_or(rest)
.trim_start_matches([' ', '—', '-', ':'])
.trim()
.to_string(),
None => rest.trim().to_string(),
};
header = key.map(|k| {
(
k,
if decision.is_empty() {
rest.trim().into()
} else {
decision
},
)
});
} else {
body.push_str(line);
body.push('\n');
}
}
flush_record(&header, &body, &mut records);
records
}
fn read_resolved_flag_blocks(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None;
let mut body = String::new();
for line in text.lines() {
let stripped = line
.trim_start_matches(['#', ' '])
.strip_prefix("RESOLVED")
.or_else(|| line.trim_start_matches(['#', ' ']).strip_prefix("FLAG"));
if let Some(rest) = stripped {
flush_record(&header, &body, &mut records);
body.clear();
let rest = rest.trim();
if let Some((key, decision)) = rest.split_once(':') {
let key = key.trim();
let source_key = first_round_or_issue_token(key).unwrap_or_else(|| key.to_string());
header = Some((source_key, decision.trim().to_string()));
} else {
let source_key =
first_round_or_issue_token(rest).unwrap_or_else(|| rest.to_string());
header = Some((source_key, rest.to_string()));
}
} else {
body.push_str(line);
body.push('\n');
}
}
flush_record(&header, &body, &mut records);
records
}
pub fn extract_to_human(text: &str) -> Vec<MigrationRecord> {
read_resolved_flag_blocks(text)
}
pub fn extract_escalation(text: &str) -> Vec<MigrationRecord> {
read_resolved_flag_blocks(text)
}
pub fn extract_decisions_immutable(text: &str) -> Vec<MigrationRecord> {
let mut records = Vec::new();
let mut header: Option<(String, String)> = None;
let mut body = String::new();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("## ") {
let rest = rest.trim();
let digits: String = rest
.trim_start_matches('§')
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
flush_record(&header, &body, &mut records);
body.clear();
let decision = rest
.trim_start_matches('§')
.trim_start_matches(|c: char| c.is_ascii_digit())
.trim_start_matches(['.', ' ', ':', '—', '-'])
.trim()
.to_string();
header = Some((format!("§{digits}"), decision));
continue;
}
}
body.push_str(line);
body.push('\n');
}
flush_record(&header, &body, &mut records);
records
}
#[derive(Debug, Default, PartialEq)]
pub struct BackfillSummary {
pub imported: usize,
pub skipped: usize,
pub relinked: usize,
pub source_only_gaps: usize,
}
fn store_key_index(
store: &Store,
) -> Result<std::collections::HashMap<String, (String, String)>, String> {
let files = store
.read_all()
.map_err(|e| format!("reading store: {e}"))?;
let mut idx = std::collections::HashMap::new();
for (name, raw) in &files {
let key = store_key(raw);
let parent = raw
.get("parent_id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if let Some(k) = key {
idx.insert(k, (name.clone(), parent));
}
}
Ok(idx)
}
pub fn backfill(
repo: &Path,
mut records: Vec<MigrationRecord>,
blame_fallback: Option<&str>,
jurisdiction_map: &HashMap<String, String>,
dry_run: bool,
) -> Result<BackfillSummary, String> {
records.sort_by(|a, b| a.source_key.cmp(&b.source_key));
let store = Store::at(repo);
if !store.exists() {
return Err("no .evolving/ store here — run `ev init` first".into());
}
let existing = store_key_index(&store)?;
let head = store
.read_head()
.map_err(|e| format!("reading HEAD: {e}"))?;
let first_is_stored_genesis = records
.first()
.and_then(|r| existing.get(&r.source_key))
.map(|(_, p)| p.is_empty())
.unwrap_or(false);
let mut prospective_parent = if first_is_stored_genesis {
String::new()
} else {
head
};
let mut summary = BackfillSummary::default();
for r in records {
if let Some((existing_id, existing_parent)) = existing.get(&r.source_key) {
if *existing_parent != prospective_parent {
summary.relinked += 1;
}
prospective_parent = existing_id.clone();
summary.skipped += 1;
continue;
}
let blame = match r.blame.as_deref().or(blame_fallback) {
Some(b) if !b.trim().is_empty() => b.trim().to_string(),
_ => {
summary.source_only_gaps += 1;
continue;
}
};
let jurisdiction = match (
r.jurisdiction.as_deref(),
jurisdiction_map.get(&r.source_key),
) {
(Some(inline), Some(mapped)) if inline != mapped => {
return Err(format!(
"source {:?}: inline jurisdiction {inline:?} conflicts with the --jurisdiction-map entry {mapped:?}",
r.source_key
));
}
(Some(inline), _) => Some(inline.to_string()),
(None, mapped) => mapped.cloned(),
};
let authority = r.authority.clone();
let source_ref = r.source_ref.clone();
let provenance = r
.provenance
.clone()
.or_else(|| Some("imported".to_string()));
if crate::tick::detect_only_carries_test(jurisdiction.as_deref(), &r.grounds) {
return Err(format!(
"source {:?}: a {} jurisdiction (detect-only) decision cannot carry a runnable test check",
r.source_key,
jurisdiction.as_deref().unwrap_or("")
));
}
for g in &r.grounds {
if let Some(crate::tick::Check::Test {
counter_test: None, ..
}) = &g.check
{
if provenance.as_deref() != Some("imported") {
return Err(format!(
"source {:?}: a harvested test check (no counter-test) is allowed only for imported history, not {}",
r.source_key,
provenance.as_deref().unwrap_or("human-now")
));
}
}
}
if dry_run {
let probe = Tick {
id: String::new(),
parent_id: prospective_parent.clone(),
observe: r.observe.clone(),
decision: r.decision.clone(),
grounds: r.grounds.clone(),
status: "live".into(),
held_since: String::new(),
blame: blame.clone(),
authority: authority.clone(),
jurisdiction: jurisdiction.clone(),
source_ref: source_ref.clone(),
provenance: provenance.clone(),
};
prospective_parent = compute_id(&probe);
summary.imported += 1;
continue;
}
let written = crate::capture::append(
repo,
Decision {
observe: r.observe,
decision: r.decision,
grounds: r.grounds,
blame,
authority,
jurisdiction,
source_ref,
provenance,
},
)?;
prospective_parent = written.id;
summary.imported += 1;
}
Ok(summary)
}
#[derive(Debug, Default, PartialEq)]
pub struct ReconcileReport {
pub in_both: usize,
pub source_only: usize,
pub store_only: usize,
pub un_keyable: usize,
}
pub fn reconcile(
repo: &Path,
source_records: &[MigrationRecord],
) -> Result<ReconcileReport, String> {
let store = Store::at(repo);
if !store.exists() {
return Err("no .evolving/ store here — run `ev init` first".into());
}
let files = store
.read_all()
.map_err(|e| format!("reading store: {e}"))?;
let mut store_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut un_keyable = 0usize;
for (_name, raw) in &files {
let key = store_key(raw);
match key {
Some(k) => {
store_keys.insert(k);
}
None => un_keyable += 1,
}
}
let source_keys: std::collections::HashSet<String> = source_records
.iter()
.map(|r| r.source_key.clone())
.collect();
let mut report = ReconcileReport {
un_keyable,
..Default::default()
};
for k in &source_keys {
if store_keys.contains(k) {
report.in_both += 1;
} else {
report.source_only += 1;
}
}
report.store_only = store_keys
.iter()
.filter(|k| !source_keys.contains(*k))
.count();
Ok(report)
}
pub fn bind_check(
selector: String,
verified_at_sha: String,
platforms: Vec<String>,
triggered_by: Vec<String>,
surfaces: Vec<String>,
) -> Result<crate::tick::Check, String> {
harvested_test_check(selector, verified_at_sha, platforms, triggered_by, surfaces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_gitlog_should_yield_one_record_per_round_header_when_given_a_chat_room_log() {
let text = "\
## R2289 QA — restore-safety counter DB-backed
- rejected: Redis: would add a new infra dependency
## R2290 Dev — ship the cross-pod drain
some prose nobody parses for grounds
";
let recs = extract_gitlog(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "R2289");
assert_eq!(recs[0].decision, "QA — restore-safety counter DB-backed");
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[0].grounds[0].supports, "rejected:Redis");
assert_eq!(recs[1].source_key, "R2290");
assert!(recs[0].observe.contains("R2289"));
}
#[test]
fn extract_to_human_should_read_a_resolved_block_when_given_the_authority_substrate() {
let text = "\
### RESOLVED R555: restore-safety counter DB-backed; reject Redis
- rejected: Redis: a new infra dependency
### FLAG R600: multi-pod relax policy still open
";
let recs = extract_to_human(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "R555");
assert_eq!(
recs[0].decision,
"restore-safety counter DB-backed; reject Redis"
);
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[1].source_key, "R600");
}
#[test]
fn extract_escalation_should_reuse_the_resolved_flag_reader_when_given_an_escalation_log() {
let text = "### FLAG #1194: re-milestoned without sign-off\n";
let recs = extract_escalation(text);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].source_key, "#1194");
assert_eq!(recs[0].decision, "re-milestoned without sign-off");
}
#[test]
fn extract_decisions_immutable_should_split_on_numbered_sections_when_given_a_doc() {
let text = "\
## 1. freeze the retrieval schema for v2
- rejected: pgvector: would lock our schema
## 2. restore-safety counter DB-backed
";
let recs = extract_decisions_immutable(text);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].source_key, "§1");
assert_eq!(recs[0].decision, "freeze the retrieval schema for v2");
assert_eq!(recs[0].grounds.len(), 1);
assert_eq!(recs[1].source_key, "§2");
}
#[test]
fn grounds_are_never_synthesized_when_a_block_has_no_structured_rejected_road() {
let text = "\
## R2289 we considered Redis but rejected it because it adds infra
this paragraph explains at length why redis was rejected, in prose
";
let recs = extract_gitlog(text);
assert_eq!(recs.len(), 1);
assert!(
recs[0].grounds.is_empty(),
"a prose reason must NEVER become a ground (no synthesis)"
);
}
fn canonical_line(extra: &str) -> String {
format!(
"{{\"kind\":\"ev-decision-intake\",\"decision\":\"no Redis\",\"grounds\":[]{extra}}}"
)
}
#[test]
fn canonical_reader_should_parse_a_full_ruling_record_when_given_a_valid_line() {
let text = "{\"kind\":\"ev-decision-intake\",\"decision\":\"rate-limit at the edge\",\
\"observe\":\"round R1043\",\"grounds\":[{\"claim\":\"edge sees every request\",\"supports\":\"chosen\"},\
{\"claim\":\"app tier double-counts\",\"supports\":\"rejected:app-tier\"}],\"blame\":\"Wang Yu\",\
\"authority\":\"user-ruled\",\"jurisdiction\":\"C\",\"source_ref\":\"R1043\",\"provenance\":\"imported\"}";
let recs = canonical_records(text).expect("valid record");
assert_eq!(recs.len(), 1);
let r = &recs[0];
assert_eq!(r.decision, "rate-limit at the edge");
assert_eq!(r.grounds.len(), 2);
assert_eq!(r.grounds[1].supports, "rejected:app-tier");
assert_eq!(r.blame.as_deref(), Some("Wang Yu"));
assert_eq!(r.authority.as_deref(), Some("user-ruled"));
assert_eq!(r.jurisdiction.as_deref(), Some("C"));
assert_eq!(r.source_ref, Some(serde_json::json!("R1043")));
assert_eq!(r.source_key, "R1043");
assert_eq!(r.provenance.as_deref(), Some("imported"));
}
#[test]
fn canonical_reader_should_reject_a_line_whose_kind_is_not_ev_decision_intake() {
let text = "{\"kind\":\"something-else\",\"decision\":\"x\",\"grounds\":[]}";
let result = canonical_records(text);
assert!(result.is_err());
}
#[test]
fn canonical_reader_should_reject_an_unknown_envelope_key() {
let text = canonical_line(",\"emoji\":\"✅\"");
let result = canonical_records(&text);
assert!(result.is_err());
}
#[test]
fn canonical_reader_should_reject_a_malformed_ground_via_ground_from_value() {
let text = "{\"kind\":\"ev-decision-intake\",\"decision\":\"x\",\
\"grounds\":[{\"claim\":\"c\",\"supports\":\"maybe\"}]}";
let result = canonical_records(text);
assert!(result.is_err());
}
#[test]
fn canonical_reader_should_import_zero_grounds_when_grounds_is_empty() {
let text = canonical_line("");
let recs = canonical_records(&text).expect("zero-grounds is first-class");
assert_eq!(recs.len(), 1);
assert!(recs[0].grounds.is_empty());
}
#[test]
fn canonical_reader_should_take_source_ref_verbatim_without_resniffing_tokens() {
let text = canonical_line(",\"observe\":\"see R2289\",\"source_ref\":\"ticket-42\"");
let recs = canonical_records(&text).expect("valid");
assert_eq!(recs[0].source_ref, Some(serde_json::json!("ticket-42")));
assert_eq!(recs[0].source_key, "ticket-42");
}
#[test]
fn canonical_reader_should_key_a_structured_source_ref_by_its_deterministic_json() {
let text = canonical_line(",\"source_ref\":{\"round\":\"R1\",\"sprint\":\"S7\"}");
let recs = canonical_records(&text).expect("valid");
assert_eq!(
recs[0].source_ref,
Some(serde_json::json!({"round": "R1", "sprint": "S7"}))
);
assert_eq!(recs[0].source_key, "{\"round\":\"R1\",\"sprint\":\"S7\"}");
}
#[test]
fn canonical_reader_should_skip_blank_and_comment_lines() {
let text = format!("\n# a comment\n{}\n\n", canonical_line(""));
let recs = canonical_records(&text).expect("valid");
assert_eq!(recs.len(), 1);
}
#[test]
fn canonical_reader_should_reject_an_out_of_vocab_provenance() {
let text = canonical_line(",\"provenance\":\"self-asserted\"");
let result = canonical_records(&text);
assert!(result.is_err());
}
}