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
use std::collections::HashSet;
use std::fmt;
use armour_core::{OnMissing, PathSeg, RowPart};
use super::plan::{gather_ids, ids_for_home_shape};
use super::registry::BuiltRegistry;
use crate::armour::db::Db;
#[allow(unused_imports)]
pub use super::plan::{AmbiguityReason, AmbiguousRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefDesc {
/// A branded reference the validator assembled. `shape` is in the target
/// home's **field order** (not sorted — see `AmbiguousRef::shape`) and
/// `ids` is parallel to it.
Assembled { shape: Vec<String>, ids: Vec<u64> },
/// A reference built by a `reg.fk` closure: the target key may be unbranded
/// and have no `u64` components at all, so only the label is meaningful.
Explicit { label: String },
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Finding {
/// A format migration changed a claim-bearing collection. Namespace rows
/// may happen to look correct, but only a complete registry-aware repair
/// can retire this durable conservative marker.
UniqueDirty {
collection: String,
},
DanglingFk {
collection: String,
key: String,
part: RowPart,
path: Vec<PathSeg>,
reference: RefDesc,
homes: Vec<String>,
on_missing: OnMissing,
},
AmbiguousRef(AmbiguousRef),
IndexMissing {
source: String,
index: String,
key: String,
},
IndexOrphan {
index: String,
key: String,
/// Target marked complete via [`SchemaRegistry::complete_index`].
complete: bool,
},
IndexValueMismatch {
index: String,
key: String,
},
IndexKeyCollision {
index: String,
key: String,
owners: Vec<(String, String)>,
},
/// A source row claims a key with no matching namespace row. Scoped to
/// the *expected* set (the union of every declared source's claims for
/// this namespace) — see `schema::expected::build_unique`'s doc.
UniqueMissing {
namespace: String,
source: String,
key: String,
},
/// Two or more source rows claim the same namespace key — unreachable
/// under live enforcement (`resolve_claims` refuses the second one at
/// commit time), so this can only come from data written before
/// adoption, a source written outside the registry, or a bug.
UniqueConflict {
namespace: String,
key: String,
owners: Vec<(String, String)>,
},
/// A permanent namespace row (`expires_at == 0`) that no declared
/// claimant's owner matches — either nobody claims that key at all, or
/// someone else's claim on it does (see
/// `schema::registry::run_decls`'s unique-namespace pass, which routes
/// the latter through [`crate::unique::resolve::decide`] and reports it
/// as [`Finding::UniqueConflict`] instead — this variant is only the
/// "nobody claims it" case). A reservation with no source row is its
/// normal state and is never reported (see the T10 spec's decisions
/// section).
///
/// Caveat: a claiming source with [`Finding::UndecodableRows`] has an
/// incomplete expected set (its unreadable rows' claims are simply
/// absent), so one of *its* live, correctly-claimed rows can show up here
/// as a false orphan. The spec only requires this to gate a destructive
/// *repair*, not to suppress the report-only finding here — see
/// `unique_validate_reports_a_false_orphan_when_the_source_has_undecodable_rows`
/// for the current (deliberately unchanged) behavior.
UniqueOrphan {
namespace: String,
key: String,
owner: String,
/// Namespace marked via [`super::SchemaRegistry::complete_namespace`]
/// — the unique-namespace twin of [`Finding::IndexOrphan`]'s own
/// `complete` field (same gate semantics, same reason: under partial
/// adoption the other, not-yet-declared sources' live claims are
/// indistinguishable from orphans, so an orphan here is only a hard
/// error once every producer of this namespace is known to the
/// registry).
complete: bool,
},
/// A namespace row that fails [`crate::UniqueRow::is_well_formed`] —
/// reported without decoding it further (`key_len` cannot be trusted to
/// address its own key buffer).
UniqueRowCorrupt {
namespace: 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,
},
/// Brand in this collection never participates in any checkable assembled,
/// declared, or single-brand edge (no home, or shape never assembles).
UnvalidatedEdge {
from: String,
brand: String,
},
RegistryError {
message: String,
},
UndecodableRows {
collection: String,
count: u64,
},
}
impl Finding {
pub fn is_error(&self) -> bool {
match self {
Finding::DanglingFk { on_missing, .. } => *on_missing == OnMissing::Error,
Finding::AmbiguousRef(_) => true,
Finding::UnclassifiedCollection { .. } | Finding::UnvalidatedEdge { .. } => false,
Finding::IndexOrphan { complete, .. } => *complete,
Finding::UniqueOrphan { complete, .. } => *complete,
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) use crate::hex::hex;
pub fn validate(db: &Db, reg: &BuiltRegistry) -> ValidationReport {
let mut report = ValidationReport::default();
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 });
}
}
for entry in ®.reg.entries {
if db.unique_dirty(entry.name) {
report.findings.push(Finding::UniqueDirty {
collection: entry.name.to_owned(),
});
}
let plan = &entry.match_plan;
for amb in &plan.ambiguities {
report.findings.push(Finding::AmbiguousRef(amb.clone()));
}
// Sorted: the set is a `HashSet`, and every other finding in the report
// is order-stable — a golden report must not depend on hash order.
let mut unvalidated: Vec<&str> = plan.unvalidated_brands.iter().copied().collect();
unvalidated.sort_unstable();
for brand in unvalidated {
report.findings.push(Finding::UnvalidatedEdge {
from: entry.name.to_string(),
brand: brand.to_string(),
});
}
let skipped = (entry.undecodable_rows)();
if skipped > 0 {
report.findings.push(Finding::UndecodableRows {
collection: entry.name.to_string(),
count: skipped,
});
}
// Resolved once per edge, not once per (row × flush × edge): the lookup
// sorts the shape into a fresh allocation every call.
let edge_homes: Vec<Option<&[super::registry::Home]>> = plan
.edges
.iter()
.map(|e| reg.homes_for_shape(&e.home_shape))
.collect();
(entry.visit_refs)(&mut |key_bytes, key_ids, value_ids, flush| {
for (edge, home_list) in plan.edges.iter().zip(&edge_homes) {
if !flush.matches_edge_scope(&edge.scope) {
continue;
}
let Some(ids_vec) = gather_ids(&edge.slots, key_ids, value_ids) else {
continue;
};
let Some(home_list) = home_list else {
continue;
};
let mut satisfied = false;
let mut registry_err = false;
for home in *home_list {
let Some(home_ids) =
ids_for_home_shape(&edge.slots, &ids_vec, home.shape, &edge.home_shape)
else {
continue;
};
match (home.contains)(&home_ids) {
Some(true) => {
satisfied = true;
break;
}
Some(false) => {}
None => {
report.findings.push(Finding::RegistryError {
message: format!(
"collection `{}`: home `{}` key construction failed",
entry.name, home.name
),
});
registry_err = true;
break;
}
}
}
if registry_err || satisfied {
continue;
}
report.findings.push(Finding::DanglingFk {
collection: entry.name.to_string(),
key: hex(key_bytes),
part: edge.part,
path: edge.reference_path.clone(),
reference: RefDesc::Assembled {
shape: edge.home_shape.iter().map(|b| (*b).to_string()).collect(),
ids: ids_vec,
},
homes: edge.home_names.iter().map(|n| (*n).to_string()).collect(),
on_missing: OnMissing::Error,
});
}
});
}
super::registry::run_decls(reg, &mut report);
report
}