1use blake2::{Blake2b512, Digest as _};
45use serde::{Deserialize, Deserializer, Serialize};
46
47const TAG_GRAMMAR: &[u8] = b"nedb:constitution_v1:nql_grammar_surface";
53const TAG_CONSTITUTION: &[u8] = b"nedb:constitution_v1:constitution";
54
55const GRAMMAR_SURFACE_VERSION: u32 = 1;
61
62fn h(parts: &[&[u8]]) -> [u8; 32] {
63 let mut hasher = Blake2b512::new();
64 for p in parts {
65 hasher.update(p);
66 }
67 let out = hasher.finalize();
68 let mut d = [0u8; 32];
69 d.copy_from_slice(&out[..32]);
70 d
71}
72
73fn lp(buf: &mut Vec<u8>, bytes: &[u8]) {
76 buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
77 buf.extend_from_slice(bytes);
78}
79
80fn count(buf: &mut Vec<u8>, n: usize) {
81 buf.extend_from_slice(&(n as u64).to_le_bytes());
82}
83
84fn intern(s: String) -> &'static str {
99 Box::leak(s.into_boxed_str())
100}
101
102#[derive(Deserialize)]
103struct OwnedFormatVersion {
104 name: String,
105 version: u32,
106}
107
108#[derive(Deserialize)]
109struct OwnedInvariant {
110 id: String,
111 statement: String,
112}
113
114#[derive(Deserialize)]
115struct OwnedConstitution {
116 engine_version: String,
117 formats: Vec<OwnedFormatVersion>,
118 capabilities: Vec<String>,
119 invariants: Vec<OwnedInvariant>,
120 grammar_digest: String,
121}
122
123impl From<OwnedFormatVersion> for FormatVersion {
124 fn from(o: OwnedFormatVersion) -> Self {
125 FormatVersion { name: intern(o.name), version: o.version }
126 }
127}
128
129impl From<OwnedInvariant> for Invariant {
130 fn from(o: OwnedInvariant) -> Self {
131 Invariant { id: intern(o.id), statement: intern(o.statement) }
132 }
133}
134
135impl From<OwnedConstitution> for Constitution {
136 fn from(o: OwnedConstitution) -> Self {
137 Constitution {
138 engine_version: intern(o.engine_version),
139 formats: o.formats.into_iter().map(Into::into).collect(),
140 capabilities: o.capabilities.into_iter().map(intern).collect(),
141 invariants: o.invariants.into_iter().map(Into::into).collect(),
142 grammar_digest: o.grammar_digest,
143 }
144 }
145}
146
147macro_rules! deserialize_via_owned {
152 ($t:ty, $owned:ty) => {
153 impl<'de> Deserialize<'de> for $t {
154 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
155 <$owned>::deserialize(d).map(Into::into)
156 }
157 }
158 };
159}
160
161deserialize_via_owned!(FormatVersion, OwnedFormatVersion);
162deserialize_via_owned!(Invariant, OwnedInvariant);
163deserialize_via_owned!(Constitution, OwnedConstitution);
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
175pub struct FormatVersion {
176 pub name: &'static str,
177 pub version: u32,
178}
179
180impl FormatVersion {
181 pub fn spelled(&self) -> String {
183 format!("{}_v{}", self.name, self.version)
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct Invariant {
194 pub id: &'static str,
195 pub statement: &'static str,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct Constitution {
201 pub engine_version: &'static str,
202 pub formats: Vec<FormatVersion>,
203 pub capabilities: Vec<&'static str>,
204 pub invariants: Vec<Invariant>,
205 pub grammar_digest: String,
208}
209
210const FORMATS: &[(&str, u32)] = &[
215 ("state_root", 1),
217 ("node", 2),
219 ("object_store", 2),
221 ("segment", 3),
224 ("collection_registry", 1),
226 ("root_record", 1),
228];
229
230const GRAMMAR_CAPABILITY_PREFIX: &str = "nql.";
236
237const CAPABILITIES: &[&str] = &[
240 "nql.from",
243 "nql.as_of",
244 "nql.valid_as_of",
245 "nql.where.comparison",
246 "nql.where.boolean",
247 "nql.where.in",
248 "nql.where.between",
249 "nql.where.like",
250 "nql.where.regex_subset",
251 "nql.where.is_null",
252 "nql.search",
253 "nql.order_by.multi_key",
254 "nql.limit",
255 "nql.offset",
256 "nql.group_by",
257 "nql.aggregate.bare",
258 "nql.having",
259 "nql.trace",
260 "nql.traverse",
261 "state_root.compute",
263 "state_root.as_of",
264 "root.persist",
265 "root.verify.three_state",
266 "history.as_of",
267 "history.floor",
268 "collections.registry",
269 "collections.drop",
270 "delete.tombstone",
271 "graph.edges",
272 "index.sorted",
273 "replication.since",
274 "storage.encryption.aes256gcm",
275 "storage.compaction",
276 "storage.segment_v3",
277 "wire.http",
278 "wire.pgwire",
279];
280
281fn depends_on_grammar(capability: &str) -> bool {
283 capability.starts_with(GRAMMAR_CAPABILITY_PREFIX)
284}
285
286const INVARIANTS: &[(&str, &str)] = &[
292 (
293 "INV-HISTORY-APPEND-ONLY",
294 "Committed history is append-only: no operation rewrites or removes a committed node, \
295 so a revert, rollback or merge is expressed as new nodes appended to history rather \
296 than as an edit to the old ones.",
297 ),
298 (
299 "INV-OBJECT-IMMUTABLE",
300 "An object is addressed by the BLAKE2b digest of its stored bytes, written atomically, \
301 and hash-verified on every read, so a stored version can never change under a reader.",
302 ),
303 (
304 "INV-DELETE-TOMBSTONE",
305 "A delete writes a tombstone node and moves the id to the graveyard index; the prior \
306 versions remain reachable, so a delete hides a document rather than erasing it.",
307 ),
308 (
309 "INV-RESERVED-NAMESPACE",
310 "Everything under the `_nedb` prefix is engine-owned and refused to user writes, because \
311 a client able to write there could forge the namespace a state root commits to.",
312 ),
313 (
314 "INV-COLLECTION-BY-RECORD",
315 "A collection exists because a record in `_nedb.collections` says it is live, never \
316 because a directory is lying around, so emptying a collection does not destroy it and \
317 only an explicit drop does.",
318 ),
319 (
320 "INV-NAME-BYTE-EXACT",
321 "A collection name is committed as the exact UTF-8 bytes it was created with — never \
322 Unicode-normalised and never trimmed — so an unusable name is refused at creation \
323 rather than silently rewritten into a different collection.",
324 ),
325 (
326 "INV-ROOT-LOGICAL-CONTENT",
327 "A state root commits to logical content rather than to object hashes, so it is \
328 identical across encrypted and plaintext replicas and across disk and memory holding \
329 the same data.",
330 ),
331 (
332 "INV-ROOT-CURRENT-STATE",
333 "A state root commits to what the database currently says — live collections and live \
334 documents, with tombstones contributing nothing — and not to the history by which that \
335 state was reached, which the running Merkle head covers instead.",
336 ),
337 (
338 "INV-MERKLE-PROMOTE-ODD",
339 "An odd leaf is promoted unchanged to the next level and never duplicated, because leaf \
340 duplication is the CVE-2012-2459 construction in which two different leaf sets produce \
341 one root.",
342 ),
343 (
344 "INV-MERKLE-COUNT-COMMITTED",
345 "The leaf count is committed alongside the fold in each subtree root, so promotion can \
346 never leave two different leaf sets sharing a tree shape.",
347 ),
348 (
349 "INV-COMPACTION-ONLY-DISCARD",
350 "Compaction is the only operation that discards history, it runs only when an operator \
351 explicitly asks, and nothing — no timer, no HTTP route — triggers it automatically.",
352 ),
353 (
354 "INV-VERIFY-SEPARATE-FACTS",
355 "A persisted root may outlive the material needed to recompute it, so verification \
356 reports record validity and recomputation outcome as two separate facts and never \
357 collapses `could not check` into either pass or fail.",
358 ),
359 (
360 "INV-QUERY-STRICT",
361 "A token the parser does not recognise is a query error, never a skipped token, because \
362 silently dropping a clause answers a different question than the one that was asked.",
363 ),
364];
365
366struct GrammarClause {
370 name: &'static str,
371 forms: &'static [&'static str],
372}
373
374const GRAMMAR: &[GrammarClause] = &[
409 GrammarClause { name: "FROM", forms: &["FROM <collection>"] },
410 GrammarClause { name: "AS OF", forms: &["AS OF <seq:number>"] },
411 GrammarClause { name: "VALID AS OF", forms: &["VALID AS OF <date:string>"] },
412 GrammarClause {
413 name: "WHERE",
414 forms: &[
415 "<predicate> := <or>",
417 "<or> := <and> [OR <and>]*",
418 "<and> := <not> [AND <not>]*",
419 "<not> := [NOT] <primary>",
420 "<primary> := ( <predicate> ) | <comparison>",
421 "<comparison> := <field> (= | != | > | < | >= | <=) <value>",
422 "<comparison> := <field> [NOT] IN ( <value> [, <value>]* )",
423 "<comparison> := <field> [NOT] BETWEEN <value> AND <value>",
424 "<comparison> := <field> [NOT] LIKE <pattern:string>",
425 "<comparison> := <field> [NOT] ILIKE <pattern:string>",
426 "<comparison> := <field> (~ | ~* | !~ | !~*) <pattern:string>",
427 "<comparison> := <field> IS [NOT] NULL",
428 "<value> := <string> | <number> | TRUE | FALSE | NULL | <bare_ident_as_string>",
429 "BETWEEN is inclusive on both bounds",
431 "LIKE wildcards: % matches any run, _ matches any one character",
432 "regex subset: ^ $ . | ( ) [ ] * + ? and literal text; any other metacharacter is refused",
433 "IS NULL is true when the field is JSON null OR absent",
434 "a repeated WHERE clause is a conjunction",
435 ],
436 },
437 GrammarClause { name: "SEARCH", forms: &["SEARCH <text:string>"] },
438 GrammarClause {
439 name: "ORDER BY",
440 forms: &["ORDER BY <field> [ASC|DESC] [, <field> [ASC|DESC]]*", "default direction is ASC"],
441 },
442 GrammarClause { name: "LIMIT", forms: &["LIMIT <n:non-negative-number>"] },
443 GrammarClause { name: "OFFSET", forms: &["OFFSET <n:non-negative-number>"] },
444 GrammarClause {
445 name: "GROUP BY",
446 forms: &[
447 "GROUP BY <field>",
448 "GROUP BY <field> COUNT",
449 "GROUP BY <field> (SUM|AVG|MIN|MAX) <field>",
450 "a bare GROUP BY implies COUNT",
451 ],
452 },
453 GrammarClause {
454 name: "<aggregate>",
455 forms: &[
456 "COUNT",
457 "(SUM|AVG|MIN|MAX) <field>",
458 "an ungrouped aggregate returns exactly one row",
459 "at most one aggregate per query",
460 ],
461 },
462 GrammarClause {
463 name: "HAVING",
464 forms: &["HAVING <predicate>", "HAVING filters aggregated rows, WHERE filters input rows"],
465 },
466 GrammarClause { name: "TRACE", forms: &["TRACE <edge_type> [REVERSE]"] },
467 GrammarClause { name: "TRAVERSE", forms: &["TRAVERSE <relation>"] },
468];
469
470const GRAMMAR_OPERATORS: &[&str] = &["=", "!=", ">", "<", ">=", "<=", "~", "~*", "!~", "!~*"];
473
474const GRAMMAR_KEYWORDS: &[&str] = &[
478 "FROM", "AS", "OF", "VALID", "WHERE", "AND", "OR", "ORDER", "BY", "ASC", "DESC", "LIMIT",
479 "OFFSET", "GROUP", "HAVING", "COUNT", "SUM", "AVG", "MIN", "MAX", "TRACE", "TRAVERSE",
480 "REVERSE", "SEARCH", "NOT", "NULL", "TRUE", "FALSE", "IN", "BETWEEN", "LIKE", "ILIKE", "IS",
481];
482
483fn grammar_digest_of(
485 clauses: &[GrammarClause],
486 operators: &[&str],
487 keywords: &[&str],
488) -> String {
489 let mut buf = Vec::new();
490 buf.extend_from_slice(&(GRAMMAR_SURFACE_VERSION as u64).to_le_bytes());
491 count(&mut buf, clauses.len());
492 for c in clauses {
493 lp(&mut buf, c.name.as_bytes());
494 count(&mut buf, c.forms.len());
495 for f in c.forms {
496 lp(&mut buf, f.as_bytes());
497 }
498 }
499 count(&mut buf, operators.len());
500 for o in operators {
501 lp(&mut buf, o.as_bytes());
502 }
503 count(&mut buf, keywords.len());
504 for k in keywords {
505 lp(&mut buf, k.as_bytes());
506 }
507 hex::encode(h(&[TAG_GRAMMAR, &buf]))
508}
509
510pub fn grammar_digest() -> String {
512 grammar_digest_of(GRAMMAR, GRAMMAR_OPERATORS, GRAMMAR_KEYWORDS)
513}
514
515pub fn constitution() -> Constitution {
519 Constitution {
520 engine_version: env!("CARGO_PKG_VERSION"),
521 formats: FORMATS
522 .iter()
523 .map(|(name, version)| FormatVersion { name, version: *version })
524 .collect(),
525 capabilities: CAPABILITIES.to_vec(),
526 invariants: INVARIANTS
527 .iter()
528 .map(|(id, statement)| Invariant { id, statement })
529 .collect(),
530 grammar_digest: grammar_digest(),
531 }
532}
533
534impl Constitution {
535 pub fn digest(&self) -> String {
544 let mut buf = Vec::new();
545 lp(&mut buf, self.engine_version.as_bytes());
546
547 count(&mut buf, self.formats.len());
548 for f in &self.formats {
549 lp(&mut buf, f.name.as_bytes());
550 buf.extend_from_slice(&(f.version as u64).to_le_bytes());
551 }
552
553 count(&mut buf, self.capabilities.len());
554 for c in &self.capabilities {
555 lp(&mut buf, c.as_bytes());
556 }
557
558 count(&mut buf, self.invariants.len());
559 for i in &self.invariants {
560 lp(&mut buf, i.id.as_bytes());
561 lp(&mut buf, i.statement.as_bytes());
562 }
563
564 lp(&mut buf, self.grammar_digest.as_bytes());
565 hex::encode(h(&[TAG_CONSTITUTION, &buf]))
566 }
567}
568
569pub fn digest() -> String {
571 constitution().digest()
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct ClientClaim {
584 pub client_name: String,
585 pub client_version: String,
586 pub grammar_digest: String,
589 pub required_capabilities: Vec<String>,
590 pub required_formats: Vec<FormatVersion>,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594#[serde(rename_all = "snake_case", tag = "verdict")]
595pub enum Compatibility {
596 Compatible,
597 CompatibleWithGaps { client_missing: Vec<String>, engine_missing: Vec<String> },
608 Incompatible { reasons: Vec<String> },
609}
610
611impl Compatibility {
612 fn incompatible(reasons: Vec<String>) -> Self {
618 assert!(
619 !reasons.is_empty(),
620 "Incompatible with no reasons: a refusal a caller cannot act on is a bug, not a verdict"
621 );
622 Compatibility::Incompatible { reasons }
623 }
624
625 pub fn is_compatible(&self) -> bool {
626 !matches!(self, Compatibility::Incompatible { .. })
627 }
628}
629
630pub fn check_compatibility(client: &ClientClaim) -> Compatibility {
654 let engine = constitution();
655 let grammar_agrees = client.grammar_digest == engine.grammar_digest;
656 let mut reasons: Vec<String> = Vec::new();
657
658 for required in &client.required_capabilities {
659 let engine_has = engine.capabilities.iter().any(|c| c == required);
660 if !engine_has {
661 reasons.push(format!(
662 "capability {:?} is required by {} {} and is not implemented by nedb-engine {}",
663 required, client.client_name, client.client_version, engine.engine_version
664 ));
665 } else if !grammar_agrees && depends_on_grammar(required) {
666 reasons.push(format!(
667 "capability {:?} is defined by the NQL grammar, and the two sides do not agree on \
668 that grammar (client {}, engine {}), so the engine cannot promise the client's \
669 reading of it",
670 required, client.grammar_digest, engine.grammar_digest
671 ));
672 }
673 }
674
675 for required in &client.required_formats {
676 if engine
677 .formats
678 .iter()
679 .any(|f| f.name == required.name && f.version == required.version)
680 {
681 continue;
682 }
683 let have: Vec<String> = engine
684 .formats
685 .iter()
686 .filter(|f| f.name == required.name)
687 .map(|f| format!("v{}", f.version))
688 .collect();
689 if have.is_empty() {
690 reasons.push(format!(
691 "format {:?} is required at v{} and this engine implements no version of it",
692 required.name, required.version
693 ));
694 } else {
695 reasons.push(format!(
696 "format {:?} is required at v{}; this engine implements {}",
697 required.name,
698 required.version,
699 have.join(", ")
700 ));
701 }
702 }
703
704 if !reasons.is_empty() {
705 return Compatibility::incompatible(reasons);
706 }
707
708 let client_missing: Vec<String> = engine
711 .capabilities
712 .iter()
713 .filter(|c| !client.required_capabilities.iter().any(|r| r == *c))
714 .map(|c| c.to_string())
715 .collect();
716
717 let mut engine_missing: Vec<String> = Vec::new();
718 if !grammar_agrees {
719 engine_missing.push(format!(
724 "nql_grammar_surface: client {} vs engine {} — the engine may not accept every form \
725 this client can emit; no required capability depends on the grammar, so this is a \
726 gap rather than a refusal",
727 client.grammar_digest, engine.grammar_digest
728 ));
729 }
730
731 if client_missing.is_empty() && engine_missing.is_empty() {
732 Compatibility::Compatible
733 } else {
734 Compatibility::CompatibleWithGaps { client_missing, engine_missing }
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn identical_claim() -> ClientClaim {
744 let c = constitution();
745 ClientClaim {
746 client_name: "nesql".into(),
747 client_version: "1.0.0".into(),
748 grammar_digest: c.grammar_digest.clone(),
749 required_capabilities: c.capabilities.iter().map(|s| s.to_string()).collect(),
750 required_formats: c.formats.clone(),
751 }
752 }
753
754 #[test]
755 fn the_digest_is_stable_across_calls() {
756 let a = digest();
757 let b = digest();
758 assert_eq!(a, b);
759 assert_eq!(a.len(), 64, "32 bytes, hex");
760 assert_eq!(a, constitution().digest());
762 }
763
764 #[test]
765 fn adding_a_capability_changes_the_digest() {
766 let base = constitution();
768 let mut grown = base.clone();
769 grown.capabilities.push("nql.window_functions");
770 assert_ne!(base.digest(), grown.digest());
771 }
772
773 #[test]
774 fn reordering_capabilities_changes_the_digest() {
775 let base = constitution();
778 let mut shuffled = base.clone();
779 shuffled.capabilities.swap(0, 1);
780 assert_ne!(base.digest(), shuffled.digest());
781 }
782
783 #[test]
784 fn length_prefixing_stops_the_concatenation_collision() {
785 let base = constitution();
786 let mut a = base.clone();
787 let mut b = base.clone();
788 a.capabilities.extend(["xy", "z"]);
789 b.capabilities.extend(["x", "yz"]);
790 assert_ne!(a.digest(), b.digest());
791 }
792
793 #[test]
794 fn the_grammar_digest_is_stable_and_is_not_the_constitution_digest() {
795 assert_eq!(grammar_digest(), grammar_digest());
796 assert_eq!(grammar_digest().len(), 64);
797 assert_ne!(grammar_digest(), digest());
799 }
800
801 #[test]
802 fn a_grammar_change_changes_the_grammar_digest() {
803 let mine = grammar_digest();
804 let theirs = grammar_digest_of(
805 &GRAMMAR[..GRAMMAR.len() - 1], GRAMMAR_OPERATORS,
807 GRAMMAR_KEYWORDS,
808 );
809 assert_ne!(mine, theirs);
810 }
811
812 #[test]
813 fn an_identical_client_is_compatible() {
814 assert_eq!(check_compatibility(&identical_claim()), Compatibility::Compatible);
815 }
816
817 #[test]
818 fn an_unknown_capability_is_incompatible_and_the_reason_names_it() {
819 let mut claim = identical_claim();
820 claim.required_capabilities.push("nql.window_functions".into());
821 match check_compatibility(&claim) {
822 Compatibility::Incompatible { reasons } => {
823 assert!(
824 reasons.iter().any(|r| r.contains("nql.window_functions")),
825 "reason must name the capability: {:?}",
826 reasons
827 );
828 }
829 other => panic!("expected Incompatible, got {:?}", other),
830 }
831 }
832
833 #[test]
834 fn a_future_format_version_is_incompatible_and_names_both_versions() {
835 let mut claim = identical_claim();
836 claim.required_formats.push(FormatVersion { name: "state_root", version: 2 });
837 match check_compatibility(&claim) {
838 Compatibility::Incompatible { reasons } => {
839 let r = reasons.join(" | ");
840 assert!(r.contains("state_root"), "{}", r);
841 assert!(r.contains("v2"), "must name what was asked for: {}", r);
842 assert!(r.contains("v1"), "must name what the engine has: {}", r);
843 }
844 other => panic!("expected Incompatible, got {:?}", other),
845 }
846 }
847
848 #[test]
849 fn an_unknown_format_name_is_incompatible_and_says_so_distinctly() {
850 let mut claim = identical_claim();
851 claim.required_formats.push(FormatVersion { name: "quantum_root", version: 1 });
852 match check_compatibility(&claim) {
853 Compatibility::Incompatible { reasons } => {
854 let r = reasons.join(" | ");
855 assert!(r.contains("quantum_root") && r.contains("no version"), "{}", r);
856 }
857 other => panic!("expected Incompatible, got {:?}", other),
858 }
859 }
860
861 #[test]
862 fn a_capability_the_client_never_asked_for_is_a_gap_not_an_error() {
863 let mut claim = identical_claim();
866 claim.required_capabilities.retain(|c| c != "nql.traverse" && c != "wire.pgwire");
867 match check_compatibility(&claim) {
868 Compatibility::CompatibleWithGaps { client_missing, engine_missing } => {
869 assert!(client_missing.contains(&"nql.traverse".to_string()));
870 assert!(client_missing.contains(&"wire.pgwire".to_string()));
871 assert!(engine_missing.is_empty(), "{:?}", engine_missing);
872 }
873 other => panic!("expected a gap, got {:?}", other),
874 }
875 }
876
877 #[test]
878 fn a_grammar_digest_mismatch_alone_is_a_gap_not_an_incompatibility() {
879 let engine = constitution();
882 let claim = ClientClaim {
883 client_name: "nedb-admin".into(),
884 client_version: "0.2.0".into(),
885 grammar_digest: "00".repeat(32),
886 required_capabilities: vec!["root.verify.three_state".into(), "state_root.compute".into()],
887 required_formats: vec![FormatVersion { name: "state_root", version: 1 }],
888 };
889 match check_compatibility(&claim) {
890 Compatibility::CompatibleWithGaps { engine_missing, .. } => {
891 let joined = engine_missing.join(" | ");
892 assert!(joined.contains(&"00".repeat(32)), "must report the client digest: {}", joined);
893 assert!(joined.contains(&engine.grammar_digest), "must report the engine digest: {}", joined);
894 }
895 other => panic!("expected a gap, got {:?}", other),
896 }
897 }
898
899 #[test]
900 fn a_grammar_mismatch_plus_a_grammar_dependent_requirement_is_incompatible() {
901 let claim = ClientClaim {
904 client_name: "nesql".into(),
905 client_version: "9.9.9".into(),
906 grammar_digest: "ff".repeat(32),
907 required_capabilities: vec!["nql.where.regex_subset".into()],
908 required_formats: vec![],
909 };
910 match check_compatibility(&claim) {
911 Compatibility::Incompatible { reasons } => {
912 let r = reasons.join(" | ");
913 assert!(r.contains("nql.where.regex_subset"), "{}", r);
914 assert!(r.contains(&"ff".repeat(32)), "must name the client digest: {}", r);
915 assert!(r.contains(&grammar_digest()), "must name the engine digest: {}", r);
916 }
917 other => panic!("expected Incompatible, got {:?}", other),
918 }
919 }
920
921 #[test]
922 fn a_grammar_mismatch_does_not_poison_non_grammar_capabilities() {
923 let claim = ClientClaim {
926 client_name: "nesql".into(),
927 client_version: "9.9.9".into(),
928 grammar_digest: "ff".repeat(32),
929 required_capabilities: vec!["storage.compaction".into()],
930 required_formats: vec![],
931 };
932 assert!(check_compatibility(&claim).is_compatible());
933 }
934
935 #[test]
936 fn every_incompatible_verdict_carries_at_least_one_reason() {
937 let engine = constitution();
938 let mut claims = vec![];
939
940 let mut c = identical_claim();
942 c.required_capabilities.push("does.not.exist".into());
943 claims.push(c);
944
945 let mut c = identical_claim();
947 c.required_formats = engine
948 .formats
949 .iter()
950 .map(|f| FormatVersion { name: f.name, version: f.version + 1 })
951 .collect();
952 claims.push(c);
953
954 let mut c = identical_claim();
956 c.required_formats.push(FormatVersion { name: "nope", version: 7 });
957 claims.push(c);
958
959 for cap in CAPABILITIES.iter().filter(|c| depends_on_grammar(c)) {
962 claims.push(ClientClaim {
963 client_name: "x".into(),
964 client_version: "0".into(),
965 grammar_digest: "ab".repeat(32),
966 required_capabilities: vec![cap.to_string()],
967 required_formats: vec![],
968 });
969 }
970
971 let mut seen_incompatible = 0;
972 for claim in &claims {
973 if let Compatibility::Incompatible { reasons } = check_compatibility(claim) {
974 seen_incompatible += 1;
975 assert!(!reasons.is_empty(), "empty reasons for {:?}", claim.client_name);
976 for r in &reasons {
977 assert!(!r.trim().is_empty(), "blank reason for {:?}", claim.client_name);
978 }
979 }
980 }
981 assert_eq!(seen_incompatible, claims.len(), "every one of these must be refused");
982 }
983
984 #[test]
985 #[should_panic(expected = "Incompatible with no reasons")]
986 fn an_incompatible_with_no_reasons_is_refused_at_construction() {
987 let _ = Compatibility::incompatible(vec![]);
988 }
989
990 #[test]
991 fn the_invariants_are_present_uniquely_identified_and_non_empty() {
992 let c = constitution();
993 assert!(!c.invariants.is_empty());
994 let mut ids: Vec<&str> = c.invariants.iter().map(|i| i.id).collect();
995 let total = ids.len();
996 ids.sort_unstable();
997 ids.dedup();
998 assert_eq!(ids.len(), total, "invariant ids must be unique");
999 for i in &c.invariants {
1000 assert!(!i.id.trim().is_empty(), "an invariant with no id cannot be pinned");
1001 assert!(!i.statement.trim().is_empty(), "invariant {} has no statement", i.id);
1002 }
1003 for required in [
1005 "INV-HISTORY-APPEND-ONLY",
1006 "INV-DELETE-TOMBSTONE",
1007 "INV-RESERVED-NAMESPACE",
1008 "INV-COLLECTION-BY-RECORD",
1009 "INV-ROOT-LOGICAL-CONTENT",
1010 "INV-MERKLE-PROMOTE-ODD",
1011 "INV-COMPACTION-ONLY-DISCARD",
1012 "INV-VERIFY-SEPARATE-FACTS",
1013 ] {
1014 assert!(ids.contains(&required), "missing invariant {}", required);
1015 }
1016 }
1017
1018 #[test]
1019 fn capabilities_and_formats_are_unique_and_non_empty() {
1020 let c = constitution();
1021 assert!(!c.capabilities.is_empty());
1022 let mut caps = c.capabilities.clone();
1023 let n = caps.len();
1024 caps.sort_unstable();
1025 caps.dedup();
1026 assert_eq!(caps.len(), n, "capability names must be unique");
1027 for cap in &c.capabilities {
1028 assert!(!cap.trim().is_empty());
1029 }
1030 let mut fs: Vec<String> = c.formats.iter().map(|f| f.spelled()).collect();
1031 let n = fs.len();
1032 fs.sort();
1033 fs.dedup();
1034 assert_eq!(fs.len(), n, "format name+version pairs must be unique");
1035 }
1036
1037 #[test]
1038 fn constitution_survives_a_json_round_trip() {
1039 let c = constitution();
1040 let json = serde_json::to_string(&c).unwrap();
1041 let back: Constitution = serde_json::from_str(&json).unwrap();
1042 assert_eq!(c, back);
1043 assert_eq!(c.digest(), back.digest());
1046 }
1047
1048 #[test]
1049 fn client_claim_and_compatibility_survive_a_json_round_trip() {
1050 let claim = identical_claim();
1051 let back: ClientClaim = serde_json::from_str(&serde_json::to_string(&claim).unwrap()).unwrap();
1052 assert_eq!(claim, back);
1053
1054 for verdict in [
1055 Compatibility::Compatible,
1056 Compatibility::CompatibleWithGaps {
1057 client_missing: vec!["nql.traverse".into()],
1058 engine_missing: vec!["nql_grammar_surface: ...".into()],
1059 },
1060 Compatibility::Incompatible { reasons: vec!["because".into()] },
1061 ] {
1062 let json = serde_json::to_string(&verdict).unwrap();
1063 let back: Compatibility = serde_json::from_str(&json).unwrap();
1064 assert_eq!(verdict, back);
1065 }
1066 }
1067}