From 95414c9838154f55376da584fd1e6aa24ba44de9 Mon Sep 17 00:00:00 2001 From: Eul Bite Date: Sat, 30 May 2026 11:56:07 +0200 Subject: [PATCH] feat(cli): add strict verification profile and lenient profile hint spine-cli verify exposed only the lenient profile, so the demo WAL (signed under the strict, domain-separated contract that backs the browser playground) failed every signature check and read INVALID. Add --strict, routing to spine_core::verify_demo_wal: the signing key is pinned from --trusted-pubkey, --expected-root is mandatory, and each payload_hash is recomputed from the inline payload's canonical JSON. The two profiles stay cryptographically distinct; this exposes the strict contract on the CLI instead of merging it with lenient. Also surface a hint when every record fails the lenient signature check, pointing at --strict rather than leaving a wall of identical errors. Covered by six new cli_smoke tests. --- CHANGELOG.md | 15 ++- Cargo.lock | 1 + spine-cli/Cargo.toml | 4 + spine-cli/README.md | 15 ++- spine-cli/src/main.rs | 30 ++++- spine-cli/src/verify.rs | 201 +++++++++++++++++++++++++++++- spine-cli/tests/cli_smoke.rs | 233 ++++++++++++++++++++++++++++++++++- 7 files changed, 490 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d31891..7d458ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `spine-cli verify --strict`: verify a WAL under the strict profile (the same + contract the browser playground runs). Pins the signing key from + `--trusted-pubkey`, requires `--expected-root`, and recomputes each + `payload_hash` from the canonical JSON of the inline payload. This is the + profile the published demo WAL is signed under, so it now verifies on the CLI + as well as in the playground. +- `spine-cli verify`: when every record fails signature verification under the + default lenient profile, the report now hints that the WAL may be + strict-profile and points at `--strict`, rather than leaving a bare wall of + identical signature errors. - `spine-cli` integration tests covering argument parsing, exit codes, and - JSONL/CSV/text output. + JSONL/CSV/text output, plus the strict profile and the profile hint. ### Changed @@ -26,7 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `spine-core`: canonical JSON now rejects integer-valued floats outside `i64` - range instead of saturating the cast, tightening payload encoding so distinct payloads always serialise to distinct canonical bytes. + range instead of saturating the cast, tightening payload encoding so distinct + payloads always serialise to distinct canonical bytes. - `spine-cli`: write the export sidecar manifest before publishing the data file, so a failed manifest write can no longer leave a named export without a manifest. diff --git a/Cargo.lock b/Cargo.lock index ab3b0d5..ef95050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "chrono", "clap", "csv", + "ed25519-dalek", "hex", "serde", "serde_json", diff --git a/spine-cli/Cargo.toml b/spine-cli/Cargo.toml index 17a3785..cab0ced 100644 --- a/spine-cli/Cargo.toml +++ b/spine-cli/Cargo.toml @@ -37,3 +37,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } [dev-dependencies] tempfile = "3.14" +# Smoke tests build a strict-profile signed WAL fixture in-process to +# exercise `verify --strict`. A fixed 32-byte seed key (no randomness) +# keeps the fixture deterministic, so no `rand` feature is needed. +ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] } diff --git a/spine-cli/README.md b/spine-cli/README.md index 4179e1a..65fbc7c 100644 --- a/spine-cli/README.md +++ b/spine-cli/README.md @@ -12,7 +12,12 @@ playground, so a WAL that verifies here verifies there byte-for-byte. - `verify`: run BLAKE3 hash-chain and Ed25519 signature verification over a WAL directory. Exit code is `0` on `valid:true`, `1` on `valid:false`, `2` - on I/O or input errors. + on I/O or input errors. Defaults to the **lenient** profile (tolerates + unsigned records, `--expected-root` optional, trusts record-declared keys). + Pass `--strict` for the **strict** profile: the exact contract the browser + playground runs, with the signing key pinned from `--trusted-pubkey`, + `--expected-root` mandatory, and every `payload_hash` recomputed from the + inline payload's canonical JSON. - `export`: emit the audit trail as JSON-Lines, CSV, or RFC 5424 syslog. JSONL exports written to file are accompanied by a `.manifest.json` that pins the chain root (cross-verifiable with `verify`). @@ -21,12 +26,18 @@ playground, so a WAL that verifies here verifies there byte-for-byte. ## Quick example ```bash -# Verify a directory of .wal / .jsonl segments +# Verify a directory of .wal / .jsonl segments (lenient profile) spine-cli verify --wal /path/to/wal # Same, but emit a JSON report to stdout spine-cli --format json verify --wal /path/to/wal +# Strict profile: pin the signing key and chain root from outside the +# WAL. This is how the published demo WAL verifies (the lenient default +# rejects its domain-separated signatures by design). +spine-cli verify --strict --wal /path/to/wal \ + --trusted-pubkey <64-hex-pubkey> --expected-root <64-hex-root> + # Show 20 most recent events as a table spine-cli inspect --wal /path/to/wal -n 20 diff --git a/spine-cli/src/main.rs b/spine-cli/src/main.rs index 5e98b14..9fbf6ce 100644 --- a/spine-cli/src/main.rs +++ b/spine-cli/src/main.rs @@ -7,7 +7,11 @@ //! //! * `verify`: lenient verification of a WAL directory against //! `spine-core`. Optional `--keystore` enables receipt-signature -//! verification; `--trusted-pubkey` pins the signing key. +//! verification; `--trusted-pubkey` pins the signing key. Pass +//! `--strict` to switch to the strict profile (the playground +//! contract): the pinned pubkey and `--expected-root` become +//! mandatory and each `payload_hash` is recomputed from the inline +//! payload's canonical JSON. //! * `inspect`: human-readable view of WAL contents. No verification. //! * `export`: filter and export entries to JSONL, CSV or syslog. A //! `.manifest.json` sidecar is always written next to the output @@ -114,9 +118,27 @@ enum Commands { /// instead of being silently trusted. Without this flag the /// lenient verifier trusts the record-declared pubkey (and /// warns about it), which is sufficient for offline audit - /// but not for an enterprise CI gate. + /// but not for an enterprise CI gate. Under `--strict` this + /// is the externally pinned key and is mandatory. #[arg(long)] trusted_pubkey: Option, + + /// Verify under the strict profile: the same contract the + /// browser playground runs. Every record must be signed, the + /// signing key is pinned from `--trusted-pubkey` (mandatory), + /// `--expected-root` is mandatory, and each `payload_hash` is + /// recomputed from the canonical JSON of the inline payload. + /// Strict signatures are domain-separated, so a strict-profile + /// WAL (for example the published Spine demo WAL) fails the + /// default lenient path; pass `--strict` to verify it. + #[arg(long)] + strict: bool, + + /// Manifest version echoed into the strict report so a + /// consumer can pin the exact contract it expects. Strict + /// profile only; ignored in lenient mode. + #[arg(long, default_value_t = 1)] + manifest_version: u32, }, /// Export WAL records (JSONL, CSV or syslog) with optional time @@ -208,6 +230,8 @@ fn main() -> ExitCode { fail_fast, keystore, trusted_pubkey, + strict, + manifest_version, } => verify::run( &wal, expected_root.as_deref(), @@ -215,6 +239,8 @@ fn main() -> ExitCode { fail_fast, keystore.as_deref(), trusted_pubkey.as_deref(), + strict, + manifest_version, cli.format, ) .map_err(|e| e.to_string()), diff --git a/spine-cli/src/verify.rs b/spine-cli/src/verify.rs index 11d546d..ca142f3 100644 --- a/spine-cli/src/verify.rs +++ b/spine-cli/src/verify.rs @@ -6,7 +6,10 @@ use std::fs; use std::path::Path; -use spine_core::{verify_wal_bytes_with_options, Keystore, LenientOptions, VerificationResult}; +use spine_core::{ + verify_demo_wal, verify_wal_bytes_with_options, DemoRecordOutcome, DemoReport, DemoStatus, + Keystore, LenientOptions, VerificationResult, +}; use crate::wal_io::{read_wal_bytes, WalIoError}; use crate::OutputFormat; @@ -19,6 +22,12 @@ pub enum VerifyCmdError { #[error("Keystore could not be loaded: {0}")] Keystore(String), + /// Bad flag combination or a strict configuration the verifier + /// could not run with (for example a malformed pinned pubkey). + /// Maps to exit code 2. + #[error("{0}")] + Usage(String), + #[error("Output write failed: {0}")] OutputWrite(std::io::Error), @@ -26,6 +35,7 @@ pub enum VerifyCmdError { Serialize(#[from] serde_json::Error), } +#[allow(clippy::too_many_arguments)] pub fn run( wal_path: &Path, expected_root: Option<&str>, @@ -33,10 +43,24 @@ pub fn run( fail_fast: bool, keystore_path: Option<&Path>, trusted_pubkey: Option<&str>, + strict: bool, + manifest_version: u32, format: OutputFormat, ) -> Result { let bytes = read_wal_bytes(wal_path)?; + if strict { + return run_strict( + &bytes, + expected_root, + trusted_pubkey, + keystore_path, + manifest_version, + output_path, + format, + ); + } + let keystore = match keystore_path { Some(p) => { Some(Keystore::load_from_file(p).map_err(|e| VerifyCmdError::Keystore(e.to_string()))?) @@ -55,12 +79,104 @@ pub fn run( // partial report (records up to a fail-fast halt, plus the // failing error in result.errors) is always emitted. We just // surface it as-is. - let result = verify_wal_bytes_with_options(&bytes, &opts); + let mut result = verify_wal_bytes_with_options(&bytes, &opts); + maybe_add_profile_hint(&mut result); emit_report(&result, output_path, format)?; Ok(result.valid) } +/// When every record fails signature verification under the lenient +/// profile, the likeliest cause is not tampering but a profile +/// mismatch: a strict-signed WAL (domain-separated signatures, e.g. +/// the published Spine demo WAL) fed to the lenient verifier. A wall +/// of identical `invalid_signature` errors reads as catastrophe; this +/// hint points the user at `--strict` instead. We only fire it when +/// the failure count covers EVERY verified record, which is the +/// fingerprint of a wrong-profile run rather than partial tampering. +fn maybe_add_profile_hint(result: &mut VerificationResult) { + if result.valid || result.events_verified == 0 { + return; + } + let sig_failures = result + .errors + .iter() + .filter(|e| e.error_type == "invalid_signature") + .count() as u64; + if sig_failures > 0 && sig_failures == result.events_verified { + result.warnings.push( + "All records failed signature verification under the lenient profile. \ + If this is a strict-profile WAL (for example the Spine demo WAL), re-run \ + with --strict --trusted-pubkey --expected-root ." + .to_string(), + ); + } +} + +/// Strict-profile verification: a thin shim over +/// [`spine_core::verify_demo_wal`], the exact contract the browser +/// playground runs. The pinned key and expected root are mandatory; +/// `--keystore` is rejected because strict does not check receipts. +#[allow(clippy::too_many_arguments)] +fn run_strict( + bytes: &[u8], + expected_root: Option<&str>, + trusted_pubkey: Option<&str>, + keystore_path: Option<&Path>, + manifest_version: u32, + output_path: Option<&Path>, + format: OutputFormat, +) -> Result { + if keystore_path.is_some() { + return Err(VerifyCmdError::Usage( + "--keystore is not used by the strict profile (receipts are not checked in strict mode)" + .to_string(), + )); + } + let pubkey = trusted_pubkey.ok_or_else(|| { + VerifyCmdError::Usage( + "strict profile requires --trusted-pubkey (the externally pinned signing key)" + .to_string(), + ) + })?; + let root = expected_root.ok_or_else(|| { + VerifyCmdError::Usage("strict profile requires --expected-root".to_string()) + })?; + + // Match the lenient path's tolerance for an optional `0x` prefix + // and surrounding whitespace so the same root string works under + // either profile. + let pubkey = normalize_hex(pubkey); + let root = normalize_hex(root); + + let report = verify_demo_wal(bytes, &pubkey, &root, manifest_version); + emit_strict_report(&report, output_path, format)?; + + match report.status { + DemoStatus::Valid => Ok(true), + DemoStatus::Invalid => Ok(false), + // A configuration error (malformed pinned pubkey or root) is + // a usage problem, not a verdict on the WAL: surface it as + // exit 2 rather than letting it masquerade as "invalid". + DemoStatus::Error => { + Err(VerifyCmdError::Usage(report.error.unwrap_or_else(|| { + "strict verification could not run".to_string() + }))) + } + } +} + +/// Trim, strip an optional `0x`/`0X` prefix, and lowercase a hex +/// string, mirroring the lenient verifier's `expected_root` +/// normalisation. +fn normalize_hex(s: &str) -> String { + let t = s.trim(); + t.strip_prefix("0x") + .or_else(|| t.strip_prefix("0X")) + .unwrap_or(t) + .to_lowercase() +} + fn emit_report( result: &VerificationResult, output_path: Option<&Path>, @@ -97,6 +213,87 @@ fn emit_report( Ok(()) } +fn emit_strict_report( + report: &DemoReport, + output_path: Option<&Path>, + format: OutputFormat, +) -> Result<(), VerifyCmdError> { + // File output is always JSON, regardless of --format, matching the + // lenient path's contract (see main.rs's --format docstring). + match format { + OutputFormat::Json => { + let json = serde_json::to_string_pretty(report)?; + if let Some(out) = output_path { + fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?; + } else { + println!("{json}"); + } + } + OutputFormat::Text => { + if let Some(out) = output_path { + let json = serde_json::to_string_pretty(report)?; + fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?; + } else { + print_strict_text_report(report); + } + } + OutputFormat::Quiet => { + if let Some(out) = output_path { + let json = serde_json::to_string_pretty(report)?; + fs::write(out, &json).map_err(VerifyCmdError::OutputWrite)?; + } + } + } + Ok(()) +} + +fn print_strict_text_report(report: &DemoReport) { + let status = match report.status { + DemoStatus::Valid => "VALID", + DemoStatus::Invalid => "INVALID", + DemoStatus::Error => "ERROR", + }; + println!("Status: {status}"); + println!("Profile: strict"); + println!("Verifier version: {}", report.verifier_version); + println!("Events verified: {}", report.events_verified); + println!("Signatures verified: {}", report.signatures_verified); + println!("Expected pubkey fp: {}", report.expected_pubkey_fp); + if !report.chain_root.is_empty() { + let short = short_hex(&report.chain_root, 16); + println!("Chain root: {short}..."); + } + if let Some(err) = &report.error { + println!("\nError: {err}"); + } + let problems: Vec<&spine_core::DemoRecordEntry> = report + .records + .iter() + .filter(|r| !matches!(r.outcome, DemoRecordOutcome::Valid)) + .collect(); + if !problems.is_empty() { + println!("\nRecords with issues:"); + for r in problems { + println!(" [seq {}] {}", r.sequence, describe_outcome(&r.outcome)); + } + } +} + +/// Render a non-valid strict outcome as `: ` +/// (for example `invalid: payload_hash_mismatch`). Driven off the +/// serde tags so it stays correct as reason variants evolve, rather +/// than hand-maintaining a match over every variant. +fn describe_outcome(outcome: &DemoRecordOutcome) -> String { + let Ok(v) = serde_json::to_value(outcome) else { + return "unprintable outcome".to_string(); + }; + let tag = v.get("outcome").and_then(|t| t.as_str()).unwrap_or("?"); + v.get("reason") + .and_then(|r| r.get("kind")) + .and_then(|k| k.as_str()) + .map_or_else(|| tag.to_string(), |kind| format!("{tag}: {kind}")) +} + fn print_text_report(result: &VerificationResult) { let status = if result.valid { "VALID" } else { "INVALID" }; println!("Status: {status}"); diff --git a/spine-cli/tests/cli_smoke.rs b/spine-cli/tests/cli_smoke.rs index 345831e..b4b0c1f 100644 --- a/spine-cli/tests/cli_smoke.rs +++ b/spine-cli/tests/cli_smoke.rs @@ -14,8 +14,12 @@ use std::path::Path; use std::process::{Command, Output}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::{json, Value}; -use spine_core::{compute_entry_hash, WalEntry, GENESIS_PREV_HASH}; +use spine_core::{ + canonical_json, compute_entry_hash, compute_entry_hash_for_signing, WalEntry, + GENESIS_PREV_HASH, STRICT_DOMAIN_SEP, +}; use tempfile::TempDir; const fn bin() -> &'static str { @@ -75,6 +79,65 @@ fn path_str(p: &Path) -> &str { p.to_str().expect("path should be valid UTF-8") } +/// Write a 3-entry strict-profile WAL into `dir`: every record carries +/// an inline payload and a domain-separated Ed25519 signature, exactly +/// the contract `verify --strict` (and the browser playground) runs. +/// Returns the pinned pubkey hex and the expected chain root hex. +fn write_strict_wal(dir: &Path) -> (String, String) { + // Fixed seed: deterministic fixture, no randomness needed. + let signing = SigningKey::from_bytes(&[7u8; 32]); + let pubkey_hex = hex::encode(signing.verifying_key().to_bytes()); + + let mut prev = GENESIS_PREV_HASH.to_string(); + let mut accum = blake3::Hasher::new(); + let mut lines = String::new(); + let mut ts: i64 = 1_700_000_000_000_000_000; + for seq in 1u64..=3 { + ts += 1_000_000_000; + let payload = json!({ "amount": "100.00", "seq": seq }); + let canonical = canonical_json(&payload).expect("payload should canonicalise"); + let payload_hash = hex::encode(blake3::hash(&canonical).as_bytes()); + + let mut e: WalEntry = serde_json::from_value(json!({ + "format_version": 1, + "sequence": seq, + "timestamp_ns": ts, + "prev_hash": prev, + "payload_hash": payload_hash, + "payload": payload, + })) + .expect("strict fixture entry should deserialize"); + + // Sign STRICT_DOMAIN_SEP || sign_hash_hex, then chain on the + // full entry hash (which folds in signature + pubkey presence). + let sign_hash_hex = compute_entry_hash_for_signing(&e); + let mut msg = Vec::with_capacity(STRICT_DOMAIN_SEP.len() + sign_hash_hex.len()); + msg.extend_from_slice(STRICT_DOMAIN_SEP); + msg.extend_from_slice(sign_hash_hex.as_bytes()); + e.signature = Some(hex::encode(signing.sign(&msg).to_bytes())); + e.public_key = Some(pubkey_hex.clone()); + + let entry_hash = compute_entry_hash(&e); + accum.update(entry_hash.as_bytes()); + prev = entry_hash; + + lines.push_str(&serde_json::to_string(&e).expect("entry should serialize")); + lines.push('\n'); + } + std::fs::write(dir.join("00000001.jsonl"), lines).expect("strict wal segment should write"); + let root = hex::encode(accum.finalize().as_bytes()); + (pubkey_hex, root) +} + +/// A non-valid strict record matching `outcome` and (optionally) +/// `reason.kind`, if present in a strict JSON report. +fn find_strict_record<'a>(report: &'a Value, outcome: &str, kind: &str) -> Option<&'a Value> { + report["records"] + .as_array()? + .iter() + .find(|r| r["outcome"] == outcome && r["reason"]["kind"] == kind) +} + #[test] fn version_flag_exits_zero() { let out = run(&["--version"]); @@ -302,3 +365,171 @@ fn export_out_of_range_syslog_facility_exits_two() { ]); assert_eq!(code(&out), 2, "facility must be 0..=23"); } + +#[test] +fn verify_strict_valid_wal_passes() { + let dir = tempfile::tempdir().expect("tempdir"); + let (pubkey, root) = write_strict_wal(dir.path()); + + let out = run(&[ + "--format", + "json", + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--trusted-pubkey", + &pubkey, + "--expected-root", + &root, + ]); + assert_eq!(code(&out), 0, "valid strict WAL should pass"); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("strict report should be JSON"); + assert_eq!(report["status"], "valid"); + assert_eq!(report["events_verified"], 3); + assert_eq!(report["signatures_verified"], 3); +} + +#[test] +fn verify_strict_detects_payload_tamper() { + let dir = tempfile::tempdir().expect("tempdir"); + let (pubkey, root) = write_strict_wal(dir.path()); + + // Edit the amount inside the first payload without updating its + // declared payload_hash: the strict recompute must catch it. + let seg = dir.path().join("00000001.jsonl"); + let body = std::fs::read_to_string(&seg).expect("segment should read"); + std::fs::write(&seg, body.replacen("100.00", "999.99", 1)).expect("tamper write"); + + let out = run(&[ + "--format", + "json", + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--trusted-pubkey", + &pubkey, + "--expected-root", + &root, + ]); + assert_eq!(code(&out), 1, "tampered strict WAL should report issues"); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("strict report should be JSON"); + assert_eq!(report["status"], "invalid"); + assert!( + find_strict_record(&report, "invalid", "payload_hash_mismatch").is_some(), + "tamper must surface as payload_hash_mismatch" + ); +} + +#[test] +fn verify_strict_wrong_pubkey_is_rejected_not_signature_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + let (_pubkey, root) = write_strict_wal(dir.path()); + let wrong = "cc".repeat(32); + + let out = run(&[ + "--format", + "json", + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--trusted-pubkey", + &wrong, + "--expected-root", + &root, + ]); + assert_eq!(code(&out), 1, "wrong pinned pubkey should report issues"); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("strict report should be JSON"); + assert_eq!(report["status"], "invalid"); + assert!( + find_strict_record(&report, "rejected", "pubkey_mismatch").is_some(), + "wrong key must be a pubkey_mismatch rejection, never a signature failure" + ); +} + +#[test] +fn verify_strict_requires_pinned_pubkey_and_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let (pubkey, root) = write_strict_wal(dir.path()); + + // Missing --trusted-pubkey. + let no_key = run(&[ + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--expected-root", + &root, + ]); + assert_eq!( + code(&no_key), + 2, + "strict without a pinned pubkey is a usage error" + ); + + // Missing --expected-root. + let no_root = run(&[ + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--trusted-pubkey", + &pubkey, + ]); + assert_eq!( + code(&no_root), + 2, + "strict without an expected root is a usage error" + ); +} + +#[test] +fn verify_strict_rejects_keystore_flag() { + let dir = tempfile::tempdir().expect("tempdir"); + let (pubkey, root) = write_strict_wal(dir.path()); + + let out = run(&[ + "verify", + "--strict", + "--wal", + path_str(dir.path()), + "--trusted-pubkey", + &pubkey, + "--expected-root", + &root, + "--keystore", + "unused.json", + ]); + assert_eq!(code(&out), 2, "--keystore is not supported under --strict"); +} + +#[test] +fn verify_lenient_on_strict_wal_emits_profile_hint() { + // The whole point of the feature: a strict-signed WAL run through + // the default lenient path fails every signature. Instead of a + // bare wall of errors, the report must point the user at --strict. + let dir = tempfile::tempdir().expect("tempdir"); + let (_pubkey, _root) = write_strict_wal(dir.path()); + + let out = run(&["--format", "json", "verify", "--wal", path_str(dir.path())]); + assert_eq!( + code(&out), + 1, + "strict WAL fails the lenient signature check" + ); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("report should be JSON"); + assert_eq!(report["valid"], false); + let warnings = report["warnings"].as_array().expect("warnings array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().is_some_and(|s| s.contains("--strict"))), + "lenient failure on a strict WAL must hint at --strict" + ); +}