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
use std::collections::{HashMap, HashSet};
use std::fmt;
use armour_core::{CollectionKind, OnMissing};
use super::registry::{BuiltRegistry, RefIn};
use crate::armour::db::Db;
#[derive(Debug, Clone, PartialEq)]
pub enum Finding {
DanglingFk {
collection: String,
/// hex of the source record key
key: String,
target: String,
/// Brand for auto-FK; explicit `fk()` declarations store the label here instead.
brand: String,
/// Native id for auto-FK; explicit `fk()` declarations use 0.
id: u64,
on_missing: OnMissing,
},
IndexMissing {
source: String,
index: String,
key: String,
},
IndexOrphan {
index: String,
key: String,
},
IndexValueMismatch {
index: String,
key: String,
},
CounterMismatch {
collection: String,
key: String,
field: String,
stored: u64,
expected: u64,
},
DenormMissing {
source: String,
denorm: String,
key: String,
},
DenormMismatch {
source: String,
denorm: String,
key: String,
},
UnclassifiedCollection {
name: String,
},
/// auto-FK edge that cannot be validated (brand without entity target or
/// target with composite key without explicit fk).
UnvalidatedEdge {
from: String,
brand: String,
},
/// Registry usage error (type mismatch in declaration).
RegistryError {
message: String,
},
/// Rows whose stored value could not be decoded, so the scan skipped them and
/// none of their relations were checked. Without this the report would read
/// as clean over a partially-corrupt collection — the `warn!` the scan emits
/// is typically discarded by CI.
UndecodableRows {
collection: String,
count: u64,
},
}
impl Finding {
pub fn is_error(&self) -> bool {
match self {
Finding::DanglingFk { on_missing, .. } => *on_missing == OnMissing::Error,
// Both are configuration gaps in the *registry*, not data problems.
Finding::UnclassifiedCollection { .. } | Finding::UnvalidatedEdge { .. } => false,
// Error, unlike the two above: a value that will not decode is actual
// data corruption, on par with a dangling FK. Reporting it as a
// warning would leave the documented CI recipe (assert on `errors()`)
// green over a corrupt collection — the very hole this finding closes.
Finding::UndecodableRows { .. } => true,
_ => true,
}
}
}
#[derive(Debug, Default)]
pub struct ValidationReport {
pub findings: Vec<Finding>,
}
impl ValidationReport {
pub fn errors(&self) -> impl Iterator<Item = &Finding> {
self.findings.iter().filter(|f| f.is_error())
}
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.findings.is_empty() {
return writeln!(f, "schema validation: clean");
}
for finding in &self.findings {
writeln!(
f,
"{}: {finding:?}",
if finding.is_error() { "ERROR" } else { "WARN" }
)?;
}
Ok(())
}
}
pub(super) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// `db` is only needed for completeness (names of open collections);
/// data is read through handles captured at registration.
pub fn validate(db: &Db, reg: &BuiltRegistry) -> ValidationReport {
let mut report = ValidationReport::default();
// 5. Completeness: every open collection is classified.
let known: HashSet<&str> = reg
.reg
.entries
.iter()
.map(|e| e.name)
.chain(reg.reg.external.iter().map(|(n, _)| *n))
.collect();
for name in db.collection_names() {
if !known.contains(name.as_str()) {
report
.findings
.push(Finding::UnclassifiedCollection { name });
}
}
let entry_map: HashMap<&str, &_> = reg.reg.entries.iter().map(|e| (e.name, e)).collect();
// 1. Auto-FK: dangling + unvalidated.
for entry in ®.reg.entries {
// brands without entity target — one UnvalidatedEdge per (from, brand)
let mut unvalidated: HashSet<&'static str> = HashSet::new();
for (loc, r) in &entry.auto_refs {
if entry.kind == CollectionKind::Entity
&& *loc == RefIn::Key
&& entry.self_brand == Some(r.brand)
{
continue;
}
if !reg.brand_targets.contains_key(r.brand) {
unvalidated.insert(r.brand);
}
}
for brand in unvalidated {
report.findings.push(Finding::UnvalidatedEdge {
from: entry.name.to_string(),
brand: brand.to_string(),
});
}
// A row the scan cannot decode contributes no refs, so every check below
// silently passes over it. Surface it before running them.
let skipped = (entry.undecodable_rows)();
if skipped > 0 {
report.findings.push(Finding::UndecodableRows {
collection: entry.name.to_string(),
count: skipped,
});
}
let self_brand = (entry.kind == CollectionKind::Entity)
.then_some(entry.self_brand)
.flatten();
(entry.visit_ids)(&mut |key_bytes, loc, brand, id| {
if entry.kind == CollectionKind::Entity
&& loc == RefIn::Key
&& Some(brand) == self_brand
{
return; // entity's own branded key, not a value-field FK
}
let Some(target_name) = reg.brand_targets.get(brand) else {
return; // already recorded as UnvalidatedEdge
};
let target = entry_map.get(target_name).expect("brand target registered");
let Some(contains) = &target.contains_branded else {
return;
};
if !contains(id) {
report.findings.push(Finding::DanglingFk {
collection: entry.name.to_string(),
key: hex(key_bytes),
target: target_name.to_string(),
brand: brand.to_string(),
id,
on_missing: OnMissing::Error,
});
}
});
}
// 2-4. Declarations (Index/Fk/Denorm/Counter) — Task 9/10/11.
super::registry::run_decls(reg, &mut report);
report
}