Invarix.Guard.Evidence 1.0.0-rc.1

This is a prerelease version of Invarix.Guard.Evidence.
dotnet add package Invarix.Guard.Evidence --version 1.0.0-rc.1
                    
NuGet\Install-Package Invarix.Guard.Evidence -Version 1.0.0-rc.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Invarix.Guard.Evidence" Version="1.0.0-rc.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Invarix.Guard.Evidence" Version="1.0.0-rc.1" />
                    
Directory.Packages.props
<PackageReference Include="Invarix.Guard.Evidence" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Invarix.Guard.Evidence --version 1.0.0-rc.1
                    
#r "nuget: Invarix.Guard.Evidence, 1.0.0-rc.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Invarix.Guard.Evidence@1.0.0-rc.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Invarix.Guard.Evidence&version=1.0.0-rc.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Invarix.Guard.Evidence&version=1.0.0-rc.1&prerelease
                    
Install as a Cake Tool

Invarix.Guard.Evidence

Tamper-evident decision records for .NET AI.

Sooner or later something asks what your AI decided and whether the log can be trusted: an enterprise security review, an auditor, opposing counsel, a regulator. This package is how you answer. It records the AI decisions you instrument as an append-only audit trail inside your own process, keyed on SHA-256 hashes of the prompt and completion rather than the text itself, and gives you the primitives to seal batches of those records under a signature only you can produce. No SaaS, no outbound network calls, no model downloads.

using Invarix.Guard.Evidence;              // UseJsonlStore, ToSha256Hex, DecisionOutcome
using Invarix.Guard.Evidence.Extensions;   // AddInvarixGuardEvidence

builder.Services.AddInvarixGuardEvidence(options =>
{
    options.AISystemId = "invoice-classifier";
    options.AISystemVersion = "2.3.1";
    options.ModelId = "gpt-4o-mini";
    options.ModelVersion = "2024-07-18";
})
.UseJsonlStore("/var/evidence/decisions.jsonl");

Then, per decision, with IDecisionRecordBuilderFactory factory and IEvidenceSink sink injected from DI:

var record = factory.NewBuilder()
    .WithStartTime(started)
    .WithEndTime(DateTimeOffset.UtcNow)
    .WithInputHashSha256(prompt.ToSha256Hex())
    .WithOutputHashSha256(completion.ToSha256Hex())
    .WithOutcome(DecisionOutcome.Allowed)
    .Build();

await sink.WriteAsync(record);

Sealing a batch and proving a record was in it

Recording builds the trail; sealing is what makes it evidence. On whatever cadence you choose (daily is typical), build a Merkle tree over the records, sign the root with your Ed25519 key, and you can later hand an auditor a self-contained bundle for any single record:

using Invarix.Guard.Evidence;

// 1. Collect the period's records and derive the canonical leaf bytes.
var records = new List<DecisionRecord>();
await foreach (var record in store.QueryAsync(dayStart, dayEnd))
{
    records.Add(record);
}
var leaves = records.Select(DecisionRecordLeaf.Encode).ToList();

// 2. Seal: build the tree once, sign the root. The key is a 32-byte Ed25519
//    seed from your secret manager; it never leaves your infrastructure.
var tree = MerkleTree.Build(leaves);
var commitment = MerkleBatchSigner.Sign(
    new BatchCommitmentRequest
    {
        LeafData = leaves,
        BatchId = $"batch-{dayStart:yyyy-MM-dd}",
        KeyId = "prod-audit-2026",
        SignedAt = DateTimeOffset.UtcNow,
    },
    privateKey);

// 3. When an auditor asks about record i, give them one JSON file.
var bundle = EvidenceBundle.Create(
    records[i], tree.ComputeInclusionProof(i), commitment);
await File.WriteAllTextAsync("evidence.json", bundle.ToJson());

// 4. The auditor verifies it with nothing but your public key, obtained
//    out of band. The bundle deliberately does not carry the key.
var received = EvidenceBundle.FromJson(await File.ReadAllTextAsync("evidence.json"));
var result = EvidenceBundleVerifier.Verify(received, publicKey);
Console.WriteLine(result.IsValid ? "verified" : $"{result.Failure}: {result.Detail}");

One detail worth knowing: you can seal straight from the JSONL file instead of re-encoding, because each stored line, minus its terminator, is byte-identical to DecisionRecordLeaf.Encode of the record it holds. Call JsonlDecisionRecordStore.ReadRawLinesAsync() to get those bytes. That is the supported way to build leaves from a store, and it owns the two details you would otherwise own yourself: the store's live append handle means a reader needs a FileStream with FileShare.ReadWrite (a plain File.ReadAllLines throws on Windows), and decoding each line to a string before re-encoding it substitutes U+FFFD for anything that is not valid UTF-8, so leaves built that way can differ from the bytes on disk.

What you get

  • CloudEvents 1.0 wire format. Each record serialises as a standard CloudEvents envelope: system and model identity, use-period timestamps and content digests on every record, with detector results and human-oversight actions attached when they apply. A CloudEvents-speaking consumer (Azure Event Grid, Knative Eventing, custom Kafka topics, plain JSONL files) can take them as-is.
  • Hashes, not raw content. The record has no field for the prompt or completion. It carries SHA-256 digests over canonicalised content instead, which reconciles traceability with data-minimisation obligations. Three caveats: DetectorResult.RedactedPreview and OversightRecord.Reason are caller-supplied free text and nothing validates or scrubs what you put in them; and canonicalisation normalises to Unicode NFC, which needs ICU. In globalization-invariant mode that step silently does nothing, so non-ASCII digests become byte-exact rather than canonical. ContentHasher.UnicodeNormalizationAvailable tells you which world you are in, a startup warning is logged when it is false, and EvidenceOptions.RequireUnicodeNormalization turns that warning into a startup failure if your evidence must cover non-ASCII content.
  • Tamper evidence. MerkleTree and MerkleBatchSigner seal a batch of records into a Merkle tree and sign the root with your own Ed25519 key. You choose the batch boundary and call the signer; nothing seals automatically. The tree implements the RFC 6962 (Certificate Transparency) tree of section 2.1 in full, including unpaired-node promotion, so roots and inclusion proofs check out against any conformant RFC 6962 implementation. Consistency proofs (RFC 6962 section 2.1.2), which prove a log is append-only between two published roots, are not provided: each batch is sealed as its own tree, so there is no sequence of tree heads to relate. The parts that are Invarix-specific (the signed payloads, the evidence bundle) are fully specified with test vectors in the docs/FORMAT-SPEC.md packed into this package, so an auditor can implement the whole verification independently.
  • Retention with legal holds. Retention policies with presets for common statutory periods (six months, three years, four years). Pass your hold registry to a prune run and matching records survive it whatever the cutoff says; holds are an argument to the call rather than state stored on the record, so persisting the registry is your job. PruneAndCertifyAsync takes a PruneAndCertifyRequest (the signing key stays a separate parameter, as with the two signers) and returns a RetentionPruneResult carrying the run report and an Ed25519-signed certificate recording the cutoff, tenant scope, delete count and consulted hold IDs, so a gap in the trail comes with a signed account of it. Every identifier the certificate will bind is validated before anything is deleted: a blank certificate ID or a key that is not 32 bytes fails the call with the trail intact, rather than leaving records gone and nothing signed. Plain PruneAsync deletes without signing anything.
  • Export endpoint. MapEvidenceExport() serves the trail as NDJSON or a JSON array over a required time window, with an optional tenant filter, written straight out of the store rather than buffered. Add your own authorization: it ships with none on purpose.
  • Stores included. In-memory (tests and dev) and single-file JSONL out of the box. For anything larger, implement IDecisionRecordStore over Postgres, blob storage, or whatever your team already trusts for regulated data.

Operating the JSONL store

  • One writer process per file. That is the contract on every OS. On Windows the file system enforces it for you: the store holds its append handle with a share mode that makes a second writer fail fast at construction. On Unix, share modes are not enforced, so a second writer is not rejected; it will silently interleave writes and corrupt lines. Do not rely on the OS to catch this outside Windows.
  • Durability. By default each append is flushed to the operating system before returning, so the worst case for a crash of your process is losing the single in-flight event. A power failure or OS crash can still lose events sitting in the OS page cache; pass flushToDisk: true to UseJsonlStore (or the store constructor) to force every append to stable storage. The cost is one synchronous disk flush per event, which turns a sub-millisecond append into a multi-millisecond one. For zero-loss requirements, back the sink with a WAL-journalled database via a custom adapter instead.
  • Corrupt lines are skipped, visibly. A line that fails to parse (typically the healed remnant of an interrupted write) is skipped with a logger warning, kept through retention rewrites, and counted on SkippedLineCount, so you have a programmatic signal even without a logger. Torn tails from crashes are healed automatically at startup and after failed writes.
  • Scale. Queries and retention runs scan the whole file; there is no index. That is fine into the low hundreds of thousands of records. Around a million records (a few hundred MB) every query becomes a multi-second full-file pass, and you should move to a database-backed IDecisionRecordStore.
  • Fail fast on misconfiguration. Until you configure a store or sink, the default sink discards everything and a startup warning says so. Set EvidenceOptions.RequireConfiguredSink in production to turn that warning into a startup failure, so a forgotten .UseJsonlStore(...) cannot become a silent evidence gap discovered at audit time.

Wiring Invarix.Guard verdicts into records

There is deliberately no integration code between Guard and Evidence; the few lines that copy verdicts across are yours, which also means they are yours to adapt. A reasonable starting point, given the GuardResult from a scan:

using Invarix.Guard.Models;   // GuardResult, GuardAction, ThreatLevel

var builder = factory.NewBuilder()
    .WithStartTime(started)
    .WithEndTime(DateTimeOffset.UtcNow)
    .WithInputHashSha256(prompt.ToSha256Hex())
    .WithOutputHashSha256(completion.ToSha256Hex())
    .WithOutcome(guard.Action switch
    {
        GuardAction.Block => DecisionOutcome.Blocked,
        GuardAction.Flag  => DecisionOutcome.AllowedWithWarnings,
        _                 => DecisionOutcome.Allowed,
    });

// GuardResult reports one overall action, not a verdict per scanner, so this
// attributes the overall action to each area that found something. Refine it
// if you need per-detector truth.
var verdict = guard.Action == GuardAction.Block
    ? DetectorVerdict.Block
    : DetectorVerdict.Warn;

if (guard.InjectionThreatLevel > ThreatLevel.None)
    builder.AddDetectorResult(new DetectorResult("prompt-injection", verdict));
if (guard.ContainsPii)
    builder.AddDetectorResult(new DetectorResult("pii", verdict));
if (guard.MLToxicityConfidence is { } toxicity)
    builder.AddDetectorResult(new DetectorResult("toxicity", verdict, toxicity));

await sink.WriteAsync(builder.Build());

Why in-process

An audit trail collected by an external service is only as trustworthy as that service. This one is generated at the point of decision, in your process, signed with a key only you hold. The signing key never leaves your infrastructure. To check a record, an auditor needs four things from you (the record, its inclusion proof, the signed commitment, and your public key) and nothing else: no service to call, no key server, no licence check. The Merkle roots and inclusion proofs are plain RFC 6962, so that part of the check runs against any conformant Certificate Transparency implementation. The signed commitment is Invarix-specific, but its byte layout is fully specified, with test vectors, in the docs/FORMAT-SPEC.md packed into this package, so an auditor can implement the entire verification in any language with SHA-256 and Ed25519, without running this library and without Invarix existing.

Security notes

  • SignedAt is self-asserted. The commitment signature proves the key holder committed to exactly this batch; the timestamp inside it is supplied by the caller and anchored to nothing. If your evidence must survive "you could have signed this later and back-dated it", pair commitments with an RFC 3161 timestamping authority; the packed docs/KEY-MANAGEMENT.md documents the pattern (no TSA integration ships in the package).
  • A deletion certificate does not rule out selective deletion. It binds the run's cutoff, scope, hold IDs and delete count, but it references no individual events. Per-event accountability across a prune requires the batch commitments over the data itself. Use both together.
  • Serialize with DecisionRecordJson.Options, always. It is the canonical serializer that defines the leaf bytes everything downstream hashes. A bare JsonSerializer.Serialize(record) produces different output (integer enums, for a start) that will never verify against a sealed batch. The options object is locked read-only for the same reason.
  • Bundles never carry the public key. A key inside the bundle could be swapped alongside a re-signed commitment, making tampering self-certifying. Distribute public keys out of band and resolve them by the commitment's KeyId. Key generation, storage, rotation and compromise handling are covered in the packed docs/KEY-MANAGEMENT.md.

What this is not

  • Not a compliance certification. It produces evidence; whether that evidence satisfies a given obligation is a question for your counsel.
  • Not automatic. Nothing is recorded, batched, sealed or pruned unless your code calls it. Coverage is whatever you instrument, and the default sink discards everything until you configure a store (a startup warning tells you so; see above for making it a startup error instead).
  • Not a guardrail. Prompt-injection and PII scanning live in Invarix.Guard. The two pair well: Guard screens what goes in and comes out, Evidence records what was decided. The wiring section above is the whole integration. Neither requires the other.

Supported frameworks

Targets net8.0 and net10.0, both LTS. Microsoft support for .NET 8 ends on 2026-11-10; plan to be on the net10.0 target (supported to November 2028) before then. .NET 9 hosts run the net8.0 asset via roll-forward.

Support

This package ships without support of any kind: no email support, no chat, no issue tracker, no SLA. The documentation packed into it (this README, CHANGELOG.md, docs/FORMAT-SPEC.md, docs/COMPATIBILITY.md, docs/KEY-MANAGEMENT.md) is the resource, and the formats are fully specified there precisely so nothing about your evidence ever depends on reaching us. Security reports: security@invarix.dk.

License

Elastic License 2.0. Commercial use, modification and self-hosting are all fine. The limits are the ELv2 ones: you cannot offer it to third parties as a hosted or managed service, you cannot circumvent licence key functionality, and you have to keep the licence notices intact. See the packaged LICENSE file for the text.


Built by Invarix. Questions: sales@invarix.dk

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Invarix.Guard.Evidence:

Package Downloads
Invarix.Gate.Evidence

Writes Invarix.Gate tool-call verdicts into an Invarix.Guard.Evidence decision log. Every verdict the action firewall renders becomes a CloudEvents 1.0 decision record: the tool that was asked for, the SHA-256 digest of the exact canonical arguments, each rule that matched as a detector result, the outcome, and the operator who approved or refused an escalation. Mapping runs on the tool-call thread and the write is handed to a bounded background queue, so an agent never waits on evidence I/O and a broken evidence store never breaks a run. Requires a commercial Gate license.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-rc.1 71 7/26/2026
0.1.0-beta.2 63 7/23/2026
0.1.0-beta.1 54 7/20/2026