diff --git a/tools/VersioningRunner/bhom-subject-list.txt b/tools/VersioningRunner/bhom-subject-list.txt new file mode 100644 index 0000000..e69de29 diff --git a/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs b/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs new file mode 100644 index 0000000..d963270 --- /dev/null +++ b/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs @@ -0,0 +1,356 @@ +using System.Reflection; +using VersioningRunner.Commands; +using VersioningRunner.Models; +using VersioningRunner.Tests.Fixtures; +using Xunit; + +namespace VersioningRunner.Tests +{ + // Object records carry a type name and nothing else, so attribution has always guessed from + // the namespace. The dataset's `_asm` field closes that, and these pin the runner half. + // + // Every test here drives a synthetic TestResult, because the runner has no other view of the + // dataset and Versioning_Toolkit does not emit the event yet. That is the limit of this + // coverage: it proves the runner handles the format it defines, not that anything produces it. + public class ObjectRecordProvenanceTests + { + // The contract Versioning_Toolkit's FromJson.cs is bound to emit. + private static string ObjectEvent(string type, string assembly) => + $"Object {type} declared in \"{assembly}\""; + + // A closed generic. Its name contains commas, which is why the quoted part holds the + // assembly alone: a comma-delimited form silently lost all 23 of these in the 9.2 + // dataset. This one is the case that mattered most, because its declaring assembly is + // another repository's while its namespace is the subject's, so losing the field left + // the original misattribution in place. + private const string ClosedGeneric = + "BH.oM.Structure.Results.ResultEnvelope`1[[BH.oM.Structure.Results.ConnectionForce, StructuralEngineering_oM, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + + private const string MethodEvent = + "Method ApplyDuctInsulation from { \"_t\" : \"System.Type\", \"Name\" : \"BH.Revit.Engine.MechanicalPlumbing.Compute, Revit_MechanicalPlumbing_Engine_2022, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null\", \"_bhomVersion\" : \"9.2\" } failed to deserialise."; + + private static FakeTestResult Tree(string description, params string[] events) + { + var leaf = new FakeTestInfo + { + Status = "Error", + Description = description, + Message = "Error: Returned null from json.", + Information = events.Select(m => (object)new FakeEventMessage { Message = m }).ToList() + }; + return new FakeTestResult + { + Status = "Error", + Information = [new FakeTestResult { Status = "Error", Information = [leaf] }] + }; + } + + // Subject names arrive as file names with extension, exactly as ReadSubjectAssemblyListFrom + // produces them, so the extension handling is exercised rather than assumed away. + private static ClosureContext ClosureForSubject(params string[] subjectFileNames) + { + var loaded = new HashSet(StringComparer.Ordinal); + return new ClosureContext( + loaded, + new HashSet(loaded.Select(RunCommand.StripConfigSuffix), StringComparer.Ordinal), + new HashSet( + RunCommand.ReadSubjectAssemblyListFrom(subjectFileNames) + .Select(f => RunCommand.StripConfigSuffix(Path.GetFileNameWithoutExtension(f))), + StringComparer.Ordinal)); + } + + // ------------------------------------------------------------------ + // Reading the field + // ------------------------------------------------------------------ + + [Fact] + public void ObjectEvent_YieldsTheDeclaringAssemblyAndType() + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")); + + Assert.Equal("BH.oM.Acoustic.Panel", type); + Assert.Equal("Acoustic_oM", assembly); + } + + [Fact] + public void DeclaringAssemblyFromAnObjectEvent_ReachesTheDiagnostic() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Equal("Acoustic_oM", Assert.Single(diagnostics).DeclaringAssembly); + } + + // The whole point of the field: this record used to be attributable only by prefix. + [Fact] + public void ObjectRecordNamingTheSubjectsAssembly_IsAttributedByDeclaringAssembly() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + var (attributable, basis) = RunCommand.AttributeToSubject( + "BH.oM.Acoustic.Panel", "Acoustic_oM", new HashSet(StringComparer.Ordinal), closure); + + Assert.True(attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, basis); + } + + [Fact] + public void ObjectRecordNamingAnotherRepositorysAssembly_IsNotAttributed() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + var (attributable, basis) = RunCommand.AttributeToSubject( + "BH.oM.Structure.Elements.Panel", "Structure_oM", new HashSet(StringComparer.Ordinal), closure); + + Assert.False(attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, basis); + } + + // ------------------------------------------------------------------ + // Inertness. These are the tests that say this PR does nothing on its own. + // ------------------------------------------------------------------ + + [Fact] + public void NoObjectEvent_LeavesTheDeclaringAssemblyNull() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", "Failed to convert the string into a type: BH.oM.Acoustic.Panel"), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Null(Assert.Single(diagnostics).DeclaringAssembly); + } + + // A record cannot be both, and the method path must not change. If both events are + // present the Method event still wins, because it is read first and the object read is + // only consulted when it yielded nothing. + [Fact] + public void MethodEventStillWins_WhenBothArePresent() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.Revit.Engine.MechanicalPlumbing.Compute. }", + MethodEvent, + ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, + (_, _, _) => (null, ClassificationPath.DeclaringTypeNotLoaded, Array.Empty()), diagnostics); + + Assert.Equal("Revit_MechanicalPlumbing_Engine_2022", Assert.Single(diagnostics).DeclaringAssembly); + } + + // Whole closure discards the declaring assembly for attribution: Execute wires + // `(d, _) =>` and supplies no ClosureContext. So on a run with no subject list this + // change cannot alter which findings are reported, only what the artefact records. + // The backfill's own validation runs were whole-closure, so they could not have + // exercised any of the attribution behaviour above. + [Fact] + public void WholeClosure_IgnoresTheDeclaringAssemblyForAttribution() + { + var nsPrefixes = new HashSet(["BH.oM.Acoustic"], StringComparer.Ordinal); + Func wholeClosure = + (d, _) => (RunCommand.IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded); + + var withAsm = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Somebody_Elses_oM")), + wholeClosure, null, null, withAsm); + + Assert.Equal(AttributionBasis.NotRecorded, Assert.Single(withAsm).AttributedBy); + Assert.True(withAsm[0].CountedAsReal); + } + + // ------------------------------------------------------------------ + // The Revit year. The backfill wrote an arbitrary lowest year on 158 records, and this + // is what makes that harmless rather than a silent misattribution. + // ------------------------------------------------------------------ + + [Theory] + [InlineData("Revit_ModelQA_oM_2022")] + [InlineData("Revit_ModelQA_oM_2023")] + [InlineData("Revit_ModelQA_oM_2024")] + [InlineData("Revit_ModelQA_oM_2025")] + [InlineData("Revit_ModelQA_oM_2026")] + public void AnyYearAttributesToASubjectBuildingAnyOtherYear(string recordedAssembly) + { + // The subject built Release2024, so infer-verification-config staged exactly one year. + var closure = ClosureForSubject("Revit_ModelQA_oM_2024.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly(recordedAssembly, closure), + $"{recordedAssembly} must attribute to a Release2024 build: all five year variants are " + + "one repository, so the year carries no information about ownership"); + } + + // "More precise" would be wrong here, and this is the shape of the regression it would + // cause: an exact-year match fails in four configurations out of five and the finding is + // dropped as another repository's. Recorded as a test so nobody reintroduces it. + [Fact] + public void TheYearIsNotComparedExactly() + { + var closure = ClosureForSubject("Revit_ModelQA_oM_2024.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Revit_ModelQA_oM_2022", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("Revit_Tagging_oM_2022", closure)); + } + + // StripConfigSuffix is anchored `_20\d{2}$`, so an `_asm` carrying a file extension does + // not match the anchor, passes through unchanged, and attributes to nobody. This is why + // the backfill wrote bare simple names, matching how method records already express a + // declaring assembly. Pinned so the dataset convention cannot drift without a red test. + [Fact] + public void AnAssemblyNameCarryingAnExtensionDoesNotAttribute() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Acoustic_oM", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("Acoustic_oM.dll", closure)); + } + + // SubjectBaseNames is built with StringComparer.Ordinal while the file-name set upstream + // is OrdinalIgnoreCase, so the base-name comparison is case-sensitive where the name + // comparison is not. Not a defect today, because both sides come from the same build + // output, but it is a real edge and it should fail visibly if it ever starts mattering. + [Fact] + public void BaseNameComparisonIsCaseSensitive() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Acoustic_oM", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("acoustic_om", closure)); + } + + // ------------------------------------------------------------------ + // Ambiguity on object records + // ------------------------------------------------------------------ + + [Fact] + public void MoreThanOneAssemblyDeclaringTheType_IsRecordedAsCandidates() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Revit_ModelQA_oM_2022")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => ["Revit_ModelQA_oM_2022", "Revit_ModelQA_oM_2023"]); + + var only = Assert.Single(diagnostics); + Assert.NotNull(only.DeclaringTypeCandidates); + Assert.Equal(2, only.DeclaringTypeCandidates!.Count); + } + + // One candidate is the ordinary case; carrying it would add a noise row to every finding. + [Fact] + public void ASingleDeclaringAssembly_LeavesCandidatesNull() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => ["Acoustic_oM"]); + + Assert.Null(Assert.Single(diagnostics).DeclaringTypeCandidates); + } + + // Without the object event there is no type to scan, so the probe is never called and + // the pre-existing behaviour stands. This is the other half of inertness. + [Fact] + public void NoObjectEvent_DoesNotScanForCandidates() + { + bool called = false; + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", "Failed to convert the string into a type: BH.oM.Acoustic.Panel"), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => { called = true; return []; }); + + Assert.False(called, "a record with no object event has no type to scan and must not be probed"); + Assert.Null(Assert.Single(diagnostics).DeclaringTypeCandidates); + } + + [Fact] + public void ProbeTypeCandidates_ReturnsEveryAssemblyDeclaringTheType() + { + var loaded = new List { typeof(RunCommand).Assembly, typeof(object).Assembly }; + + Assert.Equal(["VersioningRunner"], + RunCommand.ProbeTypeCandidates(loaded, typeof(RunCommand).FullName!)); + Assert.Empty(RunCommand.ProbeTypeCandidates(loaded, "No.Such.Type")); + } + + // ------------------------------------------------------------------ + // Malformed input fails to parse rather than capturing something wrong + // ------------------------------------------------------------------ + + [Theory] + [InlineData("Object BH.oM.Acoustic.Panel declared in \"Acoustic_oM")] + [InlineData("Object declared in \"Acoustic_oM\"")] + [InlineData("BH.oM.Acoustic.Panel declared in \"Acoustic_oM\"")] + [InlineData("Object BH.oM.Acoustic.Panel declared in \"\"")] + [InlineData("")] + public void AMalformedObjectEventYieldsNothing(string message) + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly(message); + + Assert.Null(type); + Assert.Null(assembly); + } + + // ------------------------------------------------------------------ + // Closed generics. The class the first version of this contract silently lost. + // ------------------------------------------------------------------ + + [Fact] + public void AClosedGenericTypeNameParses() + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")); + + Assert.Equal(ClosedGeneric, type); + Assert.Equal("StructuralEngineering_oM", assembly); + } + + // The commas inside the type argument list are the whole reason the format changed. + [Fact] + public void TheTypeArgumentListDoesNotTruncateTheAssembly() + { + var (_, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")); + + Assert.DoesNotContain(",", assembly); + Assert.DoesNotContain("Version=", assembly!); + } + + // The case that mattered: the type's namespace is the subject's, so the namespace guess + // claims it, while the declaring assembly says it is another repository's. Under the old + // format this record fell back and stayed misattributed. + [Fact] + public void AClosedGenericDeclaredElsewhereIsNotAttributedToTheSubject() + { + var closure = ClosureForSubject("Structure_oM.dll"); + var subjectNs = new HashSet(["BH.oM.Structure.Results"], StringComparer.Ordinal); + + var withField = RunCommand.AttributeToSubject( + ClosedGeneric, "StructuralEngineering_oM", subjectNs, closure); + Assert.False(withField.Attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, withField.Basis); + + // What the fallback does when the field cannot be read, which is what the earlier + // comma-delimited format produced for this record. + var withoutField = RunCommand.AttributeToSubject(ClosedGeneric, null, subjectNs, closure); + Assert.True(withoutField.Attributable); + Assert.Equal(AttributionBasis.NamespaceFallback, withoutField.Basis); + } + + [Fact] + public void AClosedGenericReachesTheDiagnosticEndToEnd() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree(ClosedGeneric, ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Equal("StructuralEngineering_oM", Assert.Single(diagnostics).DeclaringAssembly); + } + } +} diff --git a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs index 0a1a324..8367896 100644 --- a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs +++ b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs @@ -134,6 +134,7 @@ public static int Execute( Func Candidates)> probeSignature = (typeFullName, methodName, declaringAssembly) => ProbeDeclaringType(loaded, typeFullName, methodName, declaringAssembly); + Func> probeTypeCandidates = t => ProbeTypeCandidates(loaded, t); foreach (var method in methods) { object? rawResult; @@ -160,7 +161,7 @@ public static int Execute( return 1; } - var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex); + var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, probeTypeCandidates); allFailures.AddRange(partial.Failures); } @@ -490,7 +491,8 @@ public static VersioningResult ExtractFilteredResult(object? rawResult, List (IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded), - typeIndex: BuildLoadedTypeIndex(loaded)); + typeIndex: BuildLoadedTypeIndex(loaded), + probeTypeCandidates: t => ProbeTypeCandidates(loaded, t)); } public static VersioningResult ExtractFilteredResult( @@ -502,7 +504,10 @@ public static VersioningResult ExtractFilteredResult( ClosureContext? closure = null, // Defaults to Empty so a caller that is not exercising resolution keeps the // pre-existing behaviour rather than having findings reclassified underneath it. - LoadedTypeIndex? typeIndex = null) + LoadedTypeIndex? typeIndex = null, + // Null means object-record ambiguity is not scanned, which is the pre-existing + // behaviour and what every caller that does not supply a closure should get. + Func>? probeTypeCandidates = null) { if (rawResult is null) return new VersioningResult @@ -513,7 +518,7 @@ public static VersioningResult ExtractFilteredResult( }; var failures = new List(); - CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex ?? LoadedTypeIndex.Empty, depth: 0); + CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex ?? LoadedTypeIndex.Empty, probeTypeCandidates, depth: 0); var status = failures.Count > 0 ? VersioningStatus.Error : VersioningStatus.Pass; return new VersioningResult @@ -531,7 +536,7 @@ private static void CollectLeafFailures( List? unresolvableSkips, Func Candidates)>? probeSignature, List? diagnostics, ClosureContext? closure, - LoadedTypeIndex typeIndex, int depth) + LoadedTypeIndex typeIndex, Func>? probeTypeCandidates, int depth) { // BHoM's TestResult tree has at most 3 levels under the root (outer → per-version // summary → individual type result). Depth 5 gives headroom for unexpected nesting @@ -583,6 +588,21 @@ private static void CollectLeafFailures( .Select(ParseMethodEventAssembly) .FirstOrDefault(a => a is not null); + // Object records carry no Method event, so the assembly comes from the dataset's + // `_asm` field instead, surfaced as its own event. Consulted only when the Method + // event yielded nothing, so the method path is untouched: a record cannot be both. + // Null throughout until Versioning_Toolkit emits the event, which is what makes + // this change inert on its own. + string? objectTypeName = null; + if (declaringAssembly is null) + { + var objectEvent = eventMessages + .Select(ParseObjectEventAssembly) + .FirstOrDefault(p => p.Assembly is not null); + objectTypeName = objectEvent.TypeName; + declaringAssembly = objectEvent.Assembly; + } + var (attributable, attributedBy) = isAttributable(desc, declaringAssembly); if (!attributable) return; @@ -609,7 +629,19 @@ private static void CollectLeafFailures( if (cause is null) { if (eventType is null || eventMethod is null) + { path = ClassificationPath.NoMethodEvent; + + // An object record has no method, so the signature probe cannot run and the + // path stays NoMethodEvent. The ambiguity question is still live and is + // answerable from the type alone: if more than one loaded assembly declares + // it, `_asm` resolved to one of several and the run should say so rather + // than normalise it away silently. Without this the metric below can never + // count an object record, because candidates only ever came from the + // method probe. + if (probeTypeCandidates is not null && objectTypeName is not null) + candidates = probeTypeCandidates(objectTypeName); + } else if (probeSignature is not null) (cause, path, candidates) = probeSignature(eventType, eventMethod, declaringAssembly); else @@ -697,7 +729,7 @@ private static void CollectLeafFailures( { string childStatus = child.GetType().GetProperty("Status")?.GetValue(child)?.ToString() ?? "Pass"; if (childStatus is "Error" or "Warning") - CollectLeafFailures(child, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, depth + 1); + CollectLeafFailures(child, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, probeTypeCandidates, depth + 1); } } } @@ -989,6 +1021,77 @@ public static (string? DeclaringType, string? MethodName) ParseMethodEvent(strin return assembly.Length > 0 ? assembly : null; } + // Object records have no Method event, so until now they had no declaring assembly at all + // and always fell to the namespace guess. The dataset's `_asm` field closes that, and this + // is the wire format it arrives in: + // + // Object declared in "" + // + // THIS IS A CONTRACT. Versioning_Toolkit's FromJson.cs must emit exactly this shape for the + // field to be read; nothing else in the runner can see the dataset. + // + // The quoted part holds the assembly ALONE, and that is a correction rather than a + // preference. This first mirrored the Method event's assembly-qualified "Name", + // `", "`, and that shape cannot represent the data: a closed generic + // type name contains commas, e.g. + // + // BH.oM.Structure.Results.ResultEnvelope`1[[BH.oM.Structure.Results.ConnectionForce, + // StructuralEngineering_oM, Version=...]] + // + // so a comma-delimited group stopped at the first argument and the match failed. Measured + // over the 9.2 dataset, the earlier shape parsed 1,690 of 1,713 records and **silently lost + // 23**, each falling back to the namespace guess with no diagnostic. One of the 23 is + // attributed to the wrong repository by that fallback, which is the exact defect this field + // exists to remove. An assembly simple name can never contain a quote, so the assembly-only + // form is unambiguous and parses 1,713 of 1,713. + // + // The type is still carried, in the leading position, where it needs no delimiter. + private static readonly Regex _objectEventAssemblyPattern = new( + @"^Object\s+(?.+?)\s+declared\s+in\s+""(?[^""]+)""$", + RegexOptions.Compiled); + + // The declaring type is returned alongside the assembly because the ambiguity scan needs a + // type name and the leaf's Description is not reliably one: DescriptionFromJson mangles + // some entries. The event states it directly. + public static (string? TypeName, string? Assembly) ParseObjectEventAssembly(string message) + { + if (string.IsNullOrEmpty(message)) + return (null, null); + + var match = _objectEventAssemblyPattern.Match(message); + if (!match.Success) + return (null, null); + + string type = match.Groups["type"].Value.Trim(); + string assembly = match.Groups["assembly"].Value.Trim(); + return (type.Length > 0 ? type : null, assembly.Length > 0 ? assembly : null); + } + + // Every loaded assembly that yields the named type. The type-only half of + // ProbeDeclaringType's candidate collection, for records that have no method to probe. + // + // Not shared with ProbeDeclaringType, deliberately. That loop interleaves the signature + // probe with the candidate walk and takes its verdict from the first assembly that + // answers; splitting it would give the method path a second pass over the closure and + // change code this PR has no reason to touch. + internal static IReadOnlyList ProbeTypeCandidates(List loaded, string typeFullName) + { + var candidates = new List(); + foreach (var asm in loaded) + { + Type? type; + try { type = asm.GetType(typeFullName, throwOnError: false); } + catch { continue; } + + if (type is null) + continue; + + try { candidates.Add(asm.GetName().Name ?? "(unnamed)"); } + catch { candidates.Add("(unnamed)"); } + } + return candidates; + } + // A failure attributed to the subject that could not actually be verified, and the // non-BHoM type identity that made it unverifiable. public readonly record struct UnverifiedFailure(string Description, string Cause);