Invarix.Gate.Evidence
1.0.0-rc.3
Invarix.Gate.Evidence 1.0.0-rc.4
Additional DetailsSuperseded by 1.0.0-rc.4 of the Invarix.Gate packages. With another package lifting Microsoft.Extensions.AI.Abstractions (ModelContextProtocol, the OpenAI provider), Invarix.Gate.Extensions.AI 1.0.0-rc.1 to rc.3 can fail on the first request; rc.4 requires Microsoft.Extensions.AI 10.4.0. rc.3 and later also fix approval requests that could carry credentials unmasked (advisory INVARIX-2026-001, https://invarix.dk/security) and an Agent Framework defect that ended every run once an agent's total tool calls passed the per-run ceiling. rc.4 also reads dynamic SQL, always-true WHERE clauses and file tools with an operation argument.
See the version list below for details.
dotnet add package Invarix.Gate.Evidence --version 1.0.0-rc.3
NuGet\Install-Package Invarix.Gate.Evidence -Version 1.0.0-rc.3
<PackageReference Include="Invarix.Gate.Evidence" Version="1.0.0-rc.3" />
<PackageVersion Include="Invarix.Gate.Evidence" Version="1.0.0-rc.3" />
<PackageReference Include="Invarix.Gate.Evidence" />
paket add Invarix.Gate.Evidence --version 1.0.0-rc.3
#r "nuget: Invarix.Gate.Evidence, 1.0.0-rc.3"
#:package Invarix.Gate.Evidence@1.0.0-rc.3
#addin nuget:?package=Invarix.Gate.Evidence&version=1.0.0-rc.3&prerelease
#tool nuget:?package=Invarix.Gate.Evidence&version=1.0.0-rc.3&prerelease
Invarix.Gate.Evidence
Writes Invarix.Gate tool-call verdicts into an Invarix.Guard.Evidence decision log.
Gate decides what an agent is allowed to do. Evidence keeps an append-only record of AI decisions in CloudEvents 1.0 form. This package is the bridge: every verdict Gate renders is mapped to a decision record and queued for writing, without putting evidence I/O on the tool-call path. A record the queue evicts or the store fails to write is counted, not retried (see "Latency and loss").
Needs a Gate license token, in observe mode as well as enforce mode, whether the bridge is registered or built by hand. The token is free for a company under the Community revenue threshold and paid above it; see License below.
Setup
services.AddInvarixGate(gate => gate
.Mode(GateMode.Enforce)
.UseDefaultPack());
services.AddInvarixGuardEvidence(evidence =>
{
evidence.AISystemId = "support-agent";
evidence.AISystemVersion = "3.1.0";
evidence.ModelId = "gpt-4o";
evidence.ModelVersion = "2024-11-20";
})
.UseJsonlStore("/var/evidence/decisions.jsonl");
services.AddInvarixGateEvidence();
Order matters. AddInvarixGateEvidence throws at startup if either of the other two calls is
missing, and names the one to add. It also throws InvalidLicenseException without a valid Gate
license token. It does not see a token passed to Mode(GateMode.Enforce, token): set
LicenseToken in its options, or set the INVARIX_GATE_LICENSE environment variable, which
Gate's own license check also reads. It does not check for a store: without .UseJsonlStore(...)
or another store or sink, AddInvarixGuardEvidence keeps its no-op sink and every record is
discarded.
A host that builds the bridge itself and attaches it with GateEngine.AddObserver, instead of
calling AddInvarixGateEvidence, gets the same license check from the GateEvidenceBridge
constructor. It reads the same LicenseToken option, falls back to the same environment
variable, and throws the same InvalidLicenseException with the same message.
The AI-system and model identity come from AddInvarixGuardEvidence and are not repeated in
the bridge's options. One declaration, validated once, so the two cannot drift.
modelId on a Gate record is the agent's model, not Gate. Gate runs no model; its decision
path is pattern matching. The field names the model whose tool call was judged.
The mapping
| Gate | Decision record |
|---|---|
| every evaluated call | one record of type dk.invarix.gate.toolcall.v1 |
| a resolved approval | a second record of type dk.invarix.gate.approval.v1 |
| tool name | subject |
ToolCallRequest.CanonicalArgumentsJson |
inputHashSha256, SHA-256 of its UTF-8 bytes with no NFC step (Evidence's FORMAT-SPEC.md 2.4 applies one) |
| no output (Gate decides before the tool runs) | outputHashSha256 = SHA-256 of the empty string |
each RuleFinding |
a detectorsFired entry: name = rule id, redactedPreview = redacted match, masked for credentials again |
finding action Allow / Warn / Escalate,Deny,Terminate |
detector verdict pass / warn / block |
| nothing fired | outcome: allowed |
| a warning | outcome: allowedWithWarnings |
| observe mode, would have blocked | outcome: allowedWithWarnings |
| enforce mode, denied or terminated | outcome: blocked |
| escalated, awaiting a human | outcome: blocked, oversight manualReview |
| approval granted | outcome: overridden, oversight override |
| approval refused | outcome: blocked, oversight reverse |
| operator stop-run | outcome: blocked, oversight stopButton |
nobody decided, on_approval_timeout: allow or warn (the call runs) |
outcome: allowedWithWarnings, oversight none, no operator |
nobody decided, on_approval_timeout: block (the call does not run) |
outcome: blocked, oversight none, no operator |
Only the exact argument digest is recorded. Gate's loop hash is deliberately lossy: it drops volatile arguments and folds names to lowercase, so it does not commit to what actually ran. It never appears in evidence.
Escalations are resolved in GateEngine.ResolveApprovalAsync. The overload taking the
call, which the shipped adapters use, notifies approval observers, this bridge among
them, and hands them the broker's ApprovalDecision, so the resolution and the operator
behind it are recorded with no extra wiring. A host that resolves approvals through its own
machinery calls GateEvidenceBridge.OnApprovalResolved(resolved, call, decision) with the
verdict it got back and the broker's decision. Pass the decision whenever an operator
answered: without one, the record says that nobody decided. The overload of
ResolveApprovalAsync without the call does not hand the decision back, so a host that wants
the resolution recorded uses the overload that takes the call.
When the engine gets no decision from the broker, because the approval timed out, the broker
failed, or the engine resolving it has no broker, it applies limits.on_approval_timeout and
the approval record says that nobody decided: no operatorId, oversight action none, and an
outcome that says whether the call ran. allow and warn run the call and are recorded as
allowedWithWarnings. block, the default, does not run it and is recorded as blocked. The
engine's gate.approval finding is recorded as a detector result, pass under allow and
warn under warn, which tells those two apart. A broker answer that is not approve, deny or
stop-run counts as a broker failure and is recorded the same way. The policy file and
GateBuilder accept only allow, warn and block for on_approval_timeout. A GateLimits
built by hand is not checked: a resolution it leaves escalated is recorded as blocked with
none, because no adapter runs it, and one that ends the run is recorded as blocked with
stopButton and no operator.
A decision the broker returns is recorded as that decision, whether or not a person gave it.
ConsoleApprovalBroker returns a deny under its own operator id (by default console:
followed by the OS user name) when no interactive console is attached, when console input has
ended or cannot be read, and when a request is cancelled before it reaches the prompt, as
happens when the approval timeout passes while another escalation holds the console. Those are
recorded as blocked with reverse under that id, and on_approval_timeout does not apply to
them. WebhookApprovalBroker in Invarix.Gate.AspNetCore throws when it cannot deliver a
request, so its failures are recorded as nobody deciding.
The Evidence format has no value for "the approval timed out and the policy decided", so this
is the existing combination that claims nothing that did not happen. overridden with
override would name an override nobody made, reverse a refusal nobody gave, and
manualReview a call that is still held. allowedWithWarnings is the outcome a call that ran
although a rule wanted it stopped already gets in observe mode. Under allow the flag behind it
is the escalating rule's block detector result, not a warning, which reads the format's "at
least one detector warned" loosely; allowed would say every detector passed. The oversight
object is written with none rather than left out. Invarix.Guard.Evidence omits it when no
operator interacted, so its presence could be read as an interaction; on an approval record it
is there so that a query finds every unanswered escalation by that one value.
The format's outcome and oversight values are closed sets, so a dedicated value would be a
change to Invarix.Guard.Evidence's FORMAT-SPEC.md. Its one open field is the CloudEvents
type, which may be any reverse-DNS name, so a separate dk.invarix.gate.* type for
unanswered escalations would need no spec change. The bridge keeps one approval type so that
every resolution is read from one place and the unanswered ones are found by oversight none.
The record does not say which of the three causes it was; Gate logs the cause through the
host's logger when it resolves the approval.
The tool-call record for the same call is written while the call is held, so it stays
blocked with manualReview whatever happens next; the approval record after it says what
did. A host that cancels the approval wait gets no resolution from the engine, and the held
tool-call record is then the only one. An engine in enforce mode with no broker never escalates
its own verdicts: it denies an escalating call as it evaluates it, so the shipped adapters
write a blocked tool-call record and no approval record.
The two logs carry different things
Gate's own verdict log (docs/VERDICT-LOG.md in the Gate package) records the same argument
digest, as argsSha256, alongside the mode, the applied and observed actions, each finding's
detail text, the ledger counters (calls, tokens, and an advisory cost estimate), and the policy
pack version. Apart from the digest, none of that reaches the evidence store.
DecisionData is sealed, has a fixed field set, no extension slot, and a wire format frozen by
golden-vector tests. There is no honest place to put those values, so the bridge does not put
them anywhere: nothing is encoded into a detector name, packed into a redacted preview, or
appended to the subject. A record carrying a value in a field that does not mean it is not
evidence, it is a decoding puzzle whose answer nobody wrote down.
The same limit costs the record the agent id, the run id, and the call id. If you need evidence
searchable by run, set CorrelateByRunId = true and the run id becomes the record's
correlationId instead of the ambient trace id.
An optional extensions object on DecisionData is a plausible future change to Evidence. It is
not something to work around now.
Latency and loss
IGateVerdictObserver.OnVerdict is synchronous and runs while the agent waits. Mapping happens
there, because it is pure CPU work and has to see the verdict as rendered. The write is queued
and drained by a background task, so no tool call waits on a disk.
The queue is bounded (QueueCapacity, default 4096). When it fills, the oldest record is
evicted: blocking would put evidence latency back on the tool-call path, and dropping the newest
would keep the start of an incident and lose the part where it went wrong. Every eviction,
failed write, and record abandoned at shutdown increments DroppedRecordCount, and the first
one logs a warning. Non-zero means the log is incomplete for that process, except that a count
taken at a drain timeout is an upper bound: the writer may still land some of those records.
A write failure is counted and swallowed, so a store that starts failing does not break a running agent. A store that cannot be opened is different: the JSONL store opens its file the first time the evidence sink is resolved, which is when the host starts or when the Gate engine is first resolved, whichever comes first, and a failure there throws.
Disposal stops intake and drains the queue, bounded by DrainTimeout (default 5 seconds).
Dispose the service provider before the process exits: records still queued when a process ends
without disposal are lost, and DroppedRecordCount does not count them.
Tamper evidence
Records written through this bridge are tamper-evident only once the host seals them with
its own keys. Invarix.Guard.Evidence ships Merkle batch commitments and Ed25519 signing as a
library you call: the host reads a batch of records, signs the batch's Merkle root with a key it
controls, and keeps the signed commitment. Nothing seals automatically, and
AddInvarixGuardEvidence registers no sealing step. Unsealed, a JSONL evidence file is plain
text that anyone with write access can edit.
Even with sealing, the claim is bounded. A signed batch proves that its records were not altered after the batch was sealed; a record can still be edited between being written and being sealed. A signature cannot prove that a compromised process wrote honest records in the first place: something that has already taken over the host can sign whatever it likes with the key that host holds. Sealing raises the cost of quiet retroactive edits. It is not proof of what happened.
License
Elastic License 2.0, with the Invarix commercial terms at the end of the packaged LICENSE file.
Evidence export needs a Gate license token (SKU gate-professional) whichever mode Gate runs
in, free for a company under the Community revenue threshold and paid above it. The package
checks it when the bridge is registered or built: AddInvarixGateEvidence at registration,
and the GateEvidenceBridge constructor when you build the bridge yourself. To get a Community
token, email sales@invarix.dk with the company name. A paid license includes a window of
updates, after which the last version you received keeps working, with no kill switch and no
phone home. No support of any kind is included. Current terms and prices:
invarix.dk/gate.
| Product | Versions 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. |
-
net10.0
- Invarix.Gate (>= 1.0.0-rc.3)
- Invarix.Guard.Evidence (>= 1.0.0-rc.1)
-
net8.0
- Invarix.Gate (>= 1.0.0-rc.3)
- Invarix.Guard.Evidence (>= 1.0.0-rc.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 1.0.0-rc.4 | 41 | 9/28/2026 | |
| 1.0.0-rc.3 | 64 | 9/27/2026 | |
| 1.0.0-rc.2 | 93 | 9/10/2026 | |
| 1.0.0-rc.1 | 118 | 8/6/2026 |