31. Audit log and diagnostics¶
This chapter specifies the evidence machinery in src/GridSim.Core/Diagnostics/: the persistent, hash-chained audit
log (gridsim-audit/1) with its writer, run recorder, verifier and anchors; the solve trace that records the internals
of a power-flow solve; the forward-inference guard that refuses any dated input newer than 169 hours; the provenance
tags; and the restriction review gate that fences the one seam where dynamics could be fused into estimation. It gives
the exact hash payload, the line format, the order of the verifier's checks and what each check can and cannot
detect, every dated seam that calls the guard, and the tests that pin each rule.
How to use the log (running a verb with --audit-log, reading and verifying a log, keeping the anchor) is in the user
chapter 26, Audit log, deployment and assurance. The
file schema in the context of every other format is in ch. 10, File and wire formats.
The meaning and one-way flow rules of the provenance tags are in
ch. 8, Provenance and invariants and are not repeated here.
Model¶
The record and its hash¶
An audit entry is the record AuditLogEntry (src/GridSim.Core/Diagnostics/AuditLog.cs):
The hash is computed by one function, AuditLog.ComputeHash. The in-memory AuditLog, the on-disk
AuditLogWriter and the verifier AuditLogFile all call it; there is no second copy. The payload is the six fields
joined with the vertical bar |, encoded as UTF-8:
payload = seq | runUtc | stateUtc | provenance | summary | prevHash
seq decimal integer, invariant culture
runUtc UtcTime.Normalise(runTimestampUtc).ToString("o") e.g. 2026-10-04T11:01:57.2144688Z
stateUtc UtcTime.Normalise(stateTimeUtc).ToString("o"), or the single character "-" when there is no state time
provenance ProvenanceTag name, e.g. Synthetic
summary the summary text exactly as stored
prevHash hash of the previous entry, or the literal GENESIS for entry 0
hash = upper-case hexadecimal of SHA256(UTF8(payload)) 64 characters
The - stands in for a null stateUtc inside the payload only. In the file a null state time is the JSON value
null, and the line never contains a - field. The payload has no trailing separator and no field for the format tag.
UtcTime.Normalise (src/GridSim.Core/Diagnostics/UtcTime.cs) is the single rule that turns a DateTime into UTC:
Unspecified is relabelled as UTC with no shift, Local is converted, Utc is unchanged. The same rule is used when
a line is written and when its hash is computed, so an entry hashes identically on a machine on British Summer Time and
on a UTC server.
The chain is:
prevHash(0) = "GENESIS"
prevHash(n) = hash(n-1)
hash(n) = SHA256( n | runUtc(n) | stateUtc(n) or "-" | provenance(n) | summary(n) | prevHash(n) )
There is no key. Anyone who can write the file can recompute the whole chain; what detects that is the anchor (below).
The file format¶
AuditLogFile.FormatTag is gridsim-audit/1. A log is UTF-8 with no byte-order mark, one compact JSON object per
line, each line ended by a single LF. Each object has exactly these eight fields, in this order
(AuditLogFile.EncodeLine):
| Field | JSON type | Content |
|---|---|---|
format |
string | Always gridsim-audit/1. |
seq |
number | 0-based position; a non-negative 32-bit integer; must equal the line's position in the file. |
runUtc |
string | The time the entry was written, o format, UTC, ending in Z. A fact about the run, not a grid claim. |
stateUtc |
string or null | The present or past grid instant the entry describes, same notation, or null. The field is always present. |
provenance |
string | A ProvenanceTag name. A number, a lower-case spelling or an undefined name is refused. |
summary |
string | Plain text; for product events the layout below. |
prevHash |
string | The previous entry's hash, or GENESIS. |
hash |
string | The 64-character upper-case hex hash. |
The writer uses Utf8JsonWriter with its default options: compact, no indentation, and the default encoder, which
escapes every non-ASCII character and the HTML-sensitive ASCII characters, so a line is plain ASCII and the same bytes
are produced for the same entry on every machine. The hash is over the unescaped summary text, not over the escaped JSON.
The summary layout¶
AuditEvent.FormatSummary (src/GridSim.Core/Diagnostics/AuditEvent.cs) builds the summary for every product event:
actor=<who> action=<verb> inputs=<name>:<SHA-256>,<name>:<SHA-256>,... outcome=<text>
inputs=- when there are no inputs
actor,actionand every inputnamemust be a non-empty run of ASCII letters, digits and. _ - @ /, so the layout can always be split back into fields. Anything else throwsArgumentExceptionbefore a byte is written.AuditEvent.DefaultActor()isuser@machinefromEnvironment.UserNameandEnvironment.MachineName, with every character outside that set replaced by_; an empty user name becomesunknownand an empty machine name becomesunknown, each on its own.- Each input hash must be 64 upper-case hex characters (
AuditInput). Two inputs with the same name throw. outcomeis free text on one line, last in the summary, with no control character (no newline or tab).- The summary is stored in the clear. It is hashed, not encrypted, and no code path removes a secret that a caller puts in it.
AuditInput builds a hash from a file's bytes (OfFile), a byte buffer (OfBytes) or a string's UTF-8 bytes
(OfText). AuditRunRecorder.InputForArgument hashes a case given on the command line by what it is: a file by its
bytes (name as given), a directory by HashDirectory (name as given), and anything else, such as a bundled case name,
by the UTF-8 bytes of the name itself under the name <name>-id, so the log never claims a content hash it did not
take. HashDirectory forms one line <relative path with / separators>:<SHA-256 of the file> plus LF for every file
below the directory, sorts the lines ordinally, and hashes their concatenation, so the result does not depend on
enumeration order, and any change of content, name or presence changes it. An empty directory hashes the empty string.
Decision (GR-847, October 2026): a file name holding a control character (a line break could forge a second line) or,
on Linux and macOS, a backslash (it would become /, so a\b would hash as b in folder a) is refused with an
InvalidDataException rather than escaped, so the lines stay unambiguous and every hash recorded before keeps its
meaning.
The estimate outcome text¶
EstimationAuditTrail.Outcome (src/GridSim.Core/Estimation/EstimationAuditTrail.cs) is the single source of the
outcome text of an estimate:
method=<m> status=<s> observable=<bool> J=<objective> chi2=<PASS|FAIL|n/a> dof=<n> removed=<n> measurements=<n>
followed, only when true, by irls_cap_reached=true, then by the caller's extra. J is written with the
round-trip format R, so the logged objective is the exact double. chi2 is n/a when the estimate did not
converge, when the chi-square threshold is undefined, or when the test did not run; a test that did not run is never
recorded as PASS. The estimate verb passes the hypothesis-testing and FDIA fields as extra
(bad_data=hti hti_removed=<n> hti_passes=<n> hti_stop=<reason> and fdia=<verdict>), present only when those
flags were given. crossval writes verdict=AGREES|MISMATCH buses=<n> max_dvm_pu=<R> max_dva_deg=<R> tol=<R>.
interpose records one input, crosswalk (the SHA-256 of the map file, taken before the file is loaded), and writes
started protocol=<name> listen=<host>:<port> monitor=<True|False> at the start (no exit=; monitor is the .NET text
of a bool). Its final entry repeats the input and the outcome is exit=<code> stopped accepted=<n> rejected=<n>
decode-errors=<n> map-errors=<n> points=<n> windows-written=<n> duplicate-names=<n> client-resets=<n>
unsupported-type-ids=<list or none>. crossval records the input case (the case, or the solve-export it was given)
and, when it solves a named case itself, solve-export, the export it wrote. The captured interpose entries are in
ch. 26.
EstimationAuditTrail.Record also exists: it appends a stand-alone entry to an in-memory AuditLog, tagged
DerivedFromRecorded, with the text estimate followed by Outcome. Nothing in src calls it (see Limits).
Verification¶
AuditLogFile.Verify(path, anchor) reads the whole file (opened with FileShare.ReadWrite, so a log can be read while
a writer holds it) and calls VerifyBytes. It stops at the first broken entry and returns AuditLogVerification
(IsValid, VerifiedCount, FirstBrokenIndex, Reason, HeadHash, Entries); Entries holds only the clean prefix.
The checks run in this order, and the first failure ends the run:
| Order | Check | Reason reported | Detects |
|---|---|---|---|
| 0 | The file starts with EF BB BF |
byte-order mark | a re-save by another program |
| 1 | The line has a terminating LF | the last line has no terminating newline | a crash during a write, a truncated copy |
| 2 | The line is not empty | a blank line stands where an entry was expected | an inserted or deleted blank line |
| 3 | The line does not end in CR | the line ends with CR LF | an editor re-saving as CRLF |
| 4 | The line parses: a JSON object, exactly the eight known fields, no duplicate, seq a non-negative integer, format equal to the tag, timestamps round-trip UTC, provenance a defined name, strings present |
one reason per failure | damaged, extra, missing or mistyped fields |
| 4b | The line equals the writer's bytes for the parsed entry (run inside step 4, after the fields parse) | the line is not in the writer's canonical form | spacing, field order, a different notation for an equal value |
| 5 | seq equals the line's position |
the sequence number is N but it is at position P | deletion, insertion, reordering |
| 6 | prevHash equals the previous hash (GENESIS for entry 0) |
prevHash does not equal the hash of entry N-1; or the first entry's prevHash is not GENESIS | an earlier entry edited and re-hashed, removed or reordered; the start of the log removed |
| 7 | The stored hash equals the recomputed hash |
the stored hash does not match the hash recomputed from the entry's fields | an edited entry |
Step 4b sits inside the parser: once the eight fields are read, the entry is re-encoded by EncodeLine and the line must
match byte for byte, so a change the hash does not cover is reported as well as one it does.
After the last line, when an anchor is given (AuditAnchor(Count, HeadHash)):
if entries < anchor.Count -> broken at index entries (tail truncation)
anchoredHead = GENESIS if anchor.Count == 0 else hash(entries[anchor.Count - 1])
if anchoredHead != anchor.HeadHash -> broken at index max(Count-1, 0) (whole-log rewrite, or another log's anchor)
A log may hold more entries than the anchor records. An anchor covers exactly the entries that existed when it was
taken. A zero-byte file is a valid empty log; a missing file throws FileNotFoundException, never an empty result.
AuditLogFile.Load verifies and throws AuditLogTamperException (an IOException) on failure.
What verification detects, derived from the order above:
| Damage | Without an anchor | With an anchor |
|---|---|---|
| An entry edited, hash left alone | Yes, step 7 at that entry | Yes |
| An entry edited and its hash recomputed | Yes, step 6 at the next entry (or no, if it is the last entry) | Yes |
| An entry deleted, inserted or moved | Yes, step 5 | Yes |
| A hand edit the hash does not cover | Yes, step 4b | Yes |
| A cut-off last line | Yes, step 1 | Yes |
| Whole entries removed from the end | No | Yes, for every entry the anchor covers |
| The whole log rewritten with recomputed hashes | No | Yes, for every entry the anchor covers |
| Entries appended after the anchor was taken | Not an error | Not an error; they are not covered |
An edited and re-hashed last entry has no successor to disagree with it, so only an anchor whose count covers that entry detects it.
The writer and its lock¶
AuditLogWriter.Open(path, expectedAnchor) (src/GridSim.Core/Diagnostics/AuditLogWriter.cs) performs these steps in
order and fails loud at the first that cannot be done:
- Resolve the full path. If its directory does not exist, throw
DirectoryNotFoundException. The directory is never created. - Open
<log>.lockwithFileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None. The lock is the open handle, not the file's existence: the file stays on disk between runs and holds no data. If another writer in this or any other process holds it, throwInvalidOperationException("already open for writing"). - Open the log with
FileMode.OpenOrCreateandFileShare.Read: verifiers may read it, nobody else may write it. A missing log is created empty here, before verification. Failure throwsInvalidOperationException. - Read the existing bytes and run
VerifyBytesagainstexpectedAnchor(when given). A log that does not verify throwsAuditLogTamperExceptionand is not touched. - Seek to the end. Every later write is an append.
The lock file is created in step 2 and stays on disk on every path, including a run whose verification then fails or whose process is killed.
Append(runUtc, stateUtc, provenance, summary) normalises both timestamps with UtcTime.Normalise, refuses an
undefined provenance (ArgumentOutOfRangeException), then, under the writer's lock: refuses a disposed or faulted
writer, refuses int.MaxValue entries, computes the hash from the current head, encodes the line, writes it and calls
Flush(flushToDisk: true) before returning. The head and count advance only after the write succeeds. If the write
throws, the writer marks itself faulted and refuses every later append: it cannot know how much of the line reached
the file. Several threads may append to one writer; the lock serialises them and the chain stays gap-free.
Anchor returns AuditAnchor(count, head) taken atomically. Dispose releases the log and the lock.
The run recorder¶
AuditRunRecorder (src/GridSim.Core/Diagnostics/AuditRunRecorder.cs) records one run of one verb.
| Member | Behaviour |
|---|---|
Start(path, actor, action, provenance, anchorPath = null) |
Validates actor, action and provenance first, reads the anchor file (anchorPath, default <log>.anchor), then opens the log against it (verifying it) before the verb does any work. Throws on a bad token, an undefined provenance, a tampered or locked log (fewer entries than the anchor, or a different hash at the anchored entry: AuditLogTamperException), a malformed anchor file, or a missing directory. A log with entries but no anchor file is accepted: AnchorWasMissing is set, the CLI and the web host warn on stderr, and the entry ends audit-anchor=missing. |
AddInput(input) |
Adds one named input. Throws on a duplicate name, and after Finish. |
SetOutcome(text) |
Sets the run's one-line result, replacing any earlier text. Throws on a blank text or any control character, at the call, not at exit. |
StateTimeUtc |
The grid instant the result describes; set by estimate to the measurement set's TakenUtc after a completed run when the set has one. A set with no stamp, a refused run and a failed load leave it null. |
RecordNow(outcome) |
Writes an extra entry immediately with the inputs added so far; interpose uses it for its started entry. Throws after Finish. |
Finish(exitCode) |
Writes the single entry with outcome exit=<code> followed by a space and the run's outcome text when one was set, stores FinalAnchor, and disposes the writer. Only the first call writes; later calls return null. |
After every append (RecordNow, Finish) the recorder rewrites the anchor file (AuditAnchorFile: one ASCII line
gridsim-audit-anchor/1 count=N head=H, written atomically through a flushed temporary file and a move). A failed
anchor write is kept in AnchorWriteError and printed (error: audit anchor ... could not be updated); it is never
thrown from the exit handler. --audit-anchor <file> (needs --audit-log) keeps the anchor elsewhere. A truncated log
therefore stops the next run with exit 1 before anything is appended.
All members are serialised by one lock, because Finish can be reached from a process-exit handler on another thread.
The CLI wiring is in src/GridSim.Cli/Program.cs. The order of events decides what a log can contain:
- The unknown-flag check runs first. An unknown flag prints
error: unknown flag ...and exits 2 before the log is opened, so no log, no lock file and no entry exist. --audit-log <file>(and--audit-anchor <file>) is removed from the argument list before the verb is dispatched. A missing value (error: --audit-log expected an audit log file path but got ''), the flag given twice,--audit-anchorwithout--audit-log, and any verb other thanestimate,crossval,interposeorserveexit 2, still before the log is opened.- The provenance is chosen by the verb:
estimatewritesSyntheticunder--synthandDerivedFromRecordedotherwise,crossvalwritesSynthetic,interposewritesRecorded,servewritesDerivedFromRecorded(inputs the case and a hash of every window file's name and bytes; outcomemethod=... processed=... converged=... rejected-fresh=... failed=... unloadable=... now=<instant>; state time the newest estimated window; a refusal recordsrejected=ForwardInferenceException reason=<Reason>). The web host writes one entry (actionweb-realtime) per resident-estimator run whenAudit:LogPath(and optionallyAudit:AnchorPath) is set; the log is opened and verified at start-up, and either setting on a host with no resident estimator stops start-up.AuditRunRecorder.Startfailures that areIOException,UnauthorizedAccessException,InvalidOperationExceptionorArgumentExceptionprinterror: --audit-log '<file>': <message>and exit 1 with no work done. - The recorder's
Finishis attached toAppDomain.ProcessExitwith the finalEnvironment.ExitCode, so a normal return, an early return, a flag the verb refuses andEnvironment.Exitall record the run. - A process-wide handler turns a missing-file or missing-folder exception and a
CaseLoadExceptionthat nothing caught intoerror: <message>andEnvironment.Exit(1); the run is recorded asexit=1with nounhandled=text. Any other unhandled exception writes a crash log (CrashLog: the folder named byGRIDSIM_CRASH_DIRwhen it is set, else the per-user application-data folderGridSim/crash, else the system temporary folder; on Linux and macOS a folder it creates is owner-only (0700) and the log is created new and owner-only (0600), so another user of a shared/tmpcannot read or redirect it, GR-839), then sets the outcomeunhandled=<exception type name>and finishes with the exit code0xE0434352(-532462766), the code the .NET runtime exits with on Windows.
When the entry is written the CLI prints audit: <full path> entries=<count> head=<head hash> to stderr: the anchor.
The audit verbs and the shells¶
gridsim audit verify|show <file> [--anchor-count N --anchor-head HEX] (RunAudit in Program.cs) calls
AuditLogFile.Verify. A valid log prints OK <file>: <n> entries, head <hash> and exits 0; show first prints one
AuditLogFile.Describe line per entry. An invalid log prints
TAMPERED <file>: entry <i>: <reason> (<n> entries verified before it) to stderr and exits 3, with no entry listed.
A missing or unreadable file prints audit: cannot read '<file>': <message> and exits 1. A missing file, a verb other
than verify or show, or only one of the two anchor flags is a usage error and exits 2. An --anchor-count that is
negative or not a number is refused as a flag error and also exits 2.
| Exit | Meaning |
|---|---|
| 0 | The log verified, and matched the anchor when one was given. |
| 1 | The file is missing or unreadable. |
| 2 | Usage error. |
| 3 | The log did not verify. |
AuditLogFile.Describe is the one display form, used by the CLI, the desktop app and the web console:
The summary is printed with every character outside 32 to 126 replaced by ? and each line break replaced by a space.
AuditLogDisplay (src/GridSim.Core/Presentation/AuditLogDisplay.cs) wraps verification for the shells: on success
the entry lines and the OK verdict, on failure no entries and only the TAMPERED verdict. It refuses a log larger
than MaxLogBytes (64 MiB), so a stray upload cannot fill the web server's memory.
SolveTrace¶
SolveTrace (src/GridSim.Core/Diagnostics/SolveTrace.cs) is an opt-in recorder for what one power-flow solve
computes. A solver fills it through PowerFlowOptions.Trace; when Trace is null the solver does no capture work.
The recorder holds raw numbers and primitive indices only; GridSim.Core.IO.SolveExport turns it into the
gridsim-solve-export/1 JSON document.
| Captured | Source call | Content |
|---|---|---|
| Header | Header(solver, n, baseMva) |
Engine name (dense-newton, sparse-newton, bfs), bus count, system base. |
| Y-bus | AddYBus(i, j, g, b) |
Every non-zero admittance entry (dense engine: every (i, j) with G or B non-zero; sparse engine: every stored entry; the sweep records none). |
| Spec | CaptureSpec(...) |
One BusSpecRow per bus: type, initial Vm and Va (degrees), Pspec, Qspec, Qd, Vg, Qmin, Qmax. |
| Passes | BeginPass(pass, effType, pvpq, pq) |
One SolvePass per reactive-limit outer pass: effective bus types and the P-equation and Q-equation bus index lists. |
| Iterations | CaptureIteration(iter, maxMismatch, mismatch, correction, jacobian) |
Per Newton iteration: the largest mismatch, the mismatch vector, the correction vector (ordered P equations over the non-slack buses, then Q equations over the Pq buses) and, for the dense engine only, the Jacobian. The sparse engine passes no Jacobian. The sweep records its voltage delta as the "mismatch" and an empty correction. |
| Q-limit events | AddQLimitEvent(busIndex, busId, pinnedAt, pass) |
Each Pv to Pq switch (with the limit it pinned at) and each revert (pinnedAt is Revert). |
Jacobian capture in the dense engine is trace.CaptureJacobian || n <= trace.JacobianMaxN. CaptureJacobian defaults
to false and JacobianMaxN to 300, so a dense solve of up to 300 buses records its Jacobian by default and a larger
one records none and sets JacobianOmittedForSize, unless CaptureJacobian forces the capture. A dense Jacobian is
O(n^2) per iteration, which is why the gate exists.
Where a trace is created: gridsim <case> --enableExport[=path] (one traced solve; --export-jacobian sets
CaptureJacobian), the transient verb (sampled slices of the run), the crossval solve export, the estimate
verb's --export-solve (an empty trace beside the projected state), GdaReplayLoop when an onSolved callback is
given, and LiveSim.Step when a caller passes one. The estimator has a sibling for its own iterations,
EstimationTrace, documented in ch. 6, State estimation. A trace only observes: the recorder
receives primitives and clones every array it is given, and SolveExportTests.Attaching_a_trace_does_not_change_the_solution
compares Vm and VaDeg with and without a trace on case14, for the dense and the sparse engine. That test does not run
the sweep engine.
The forward-inference guard¶
ForwardInferenceGuard (src/GridSim.Core/Diagnostics/ForwardInferenceGuard.cs) makes GridSim a historical estimator.
The horizon is one constant:
HorizonHours = 169.0 (7 days + 1 hour)
Horizon(utcNow) = utcNow - 169 h
RejectFuture(stamp, utcNow, what):
stamp' = UtcTime.Normalise(stamp)
throw ForwardInferenceException if stamp' > utcNow - 169 h (utcNow defaults to DateTime.UtcNow)
AssertHistorical(stamps, utcNow, what): RejectFuture on each stamp in order; the first violation throws
The comparison is strict: a stamp exactly on the horizon passes and one tick later throws. A stamp equal to or later
than utcNow throws. A Local stamp is compared by the instant it names; an Unspecified stamp is read as UTC. The
extra hour beyond seven days keeps a full settlement week behind the boundary across a daylight-saving change; the
rationale is in the class remarks and in ch. 8, The forward-inference guard.
No configuration key, role or per-source option changes the horizon, and no overload skips the check. The values a
caller supplies are utcNow (the clock the stamp is judged against) and what (the label in the message). The constant is
fixed, and the effective limit moves only backwards: ForwardInferenceGuard.RequireNotAfterRealClock(requested, what)
refuses a supplied clock later than the real one (REJECTED (forward inference): --utc-now ... is later than the real
clock ..., reason NowLaterThanRealClock, exit 2) and returns the effective now. gridsim serve <case>
--measurements-dir <dir> --utc-now <iso> accepts only an ISO 8601 instant (UtcTime.TryParseIso8601) at or before the
real clock, so a window stamped 2099-01-01 is counted as rejected-fresh with the real clock and the 2100 clock is
refused before any window is read:
$ gridsim serve case9 --measurements-dir results/ch31-future-dir
serving wls estimator over 1 window(s) from results/ch31-future-dir
horizon guard: now=2026-10-04T11:53:30.3707312Z, newest processable=2026-09-27T10:53:30.3707312Z
done: processed 0, converged 0, rejected-fresh 1, failed 0
error: no window produced a usable estimate: all 1 window(s) are newer than the 169 h horizon.
[exit 3]
gridsim serve case9 --measurements-dir results/ch31-future-dir --utc-now 2100-01-01T00:00:00Z exits 2 with that
refusal and reads no window. An earlier --utc-now is allowed: it makes a run reproducible and only tightens the
horizon. Widening HorizonHours itself is a source change, which RestrictionInvariantTests fails. An undated input
is refused too (ForwardInferenceGuard.RequireDated, reason Undated).
The message is `
=169 h). GridSim replays history only - it never processes recent or future data, so it cannot forward-infer.
The CLI verbs that load dated input catchForwardInferenceExceptionand printREJECTED (forward inference):`, exit 2.
The dated seams that call it:
| Seam | Call | What is checked | Clock |
|---|---|---|---|
MeasurementSet.Load |
RequireDated, RejectFuture |
TakenUtc; an undated set is refused |
utcNow argument, else the real clock; serve passes its effective now |
HarmonicSourceSet.Load |
RejectFuture |
TakenUtc when dated; an undated set with a measured source (Recorded or DerivedFromRecorded) is refused, an all-assumed library set may stay undated |
utcNow argument |
EventCatalog.Validate (called by Load and by the built-in catalogue) |
RejectFuture |
every event's WhenUtc |
utcNow argument |
NesoFrequencyCsv.Read |
RejectFuture |
every parsed row, as it is enumerated | the real clock only |
GdaTimeSeries.Load |
AssertHistorical |
every step's TimeUtc |
utcNow argument |
GdaReplayLoop.Replay |
AssertHistorical |
every step's TimeUtc, again at the ingestion boundary |
utcNow argument |
RtuMeasurementSetMapper.Map |
RejectFuture |
the window instant, before any RTU value is admitted | utcNow argument; the live interpose flush judges against the real clock |
PmuMeasurementSetMapper.Map |
RejectFuture |
the superframe's TimeUtc, before any PMU value is admitted |
utcNow argument, else the real clock |
pmu-ingest |
RejectFuture |
every decoded DATA frame's time, before anything is printed | the real clock |
PresolvedReplay.Load(dir, utcNow) |
RejectFuture |
every time_utc row of the sidecar (named by CSV line) |
utcNow argument (not after the real clock), else the real clock |
web ReplayDirectoryWindowFeed |
RejectFuture, RequireDated |
each window; a fresh or undated window is skipped and listed in SkippedFiles |
the real clock |
GridBridgeClient state handler |
RejectFuture |
the tick timestamp, before the frame is built; DateTime.MinValue (an undated frame) is refused first |
DateTime.UtcNow, no allowance |
RealtimeEstimatorEngine.Run |
RequireNotAfterRealClock, RejectFuture |
opt.UtcNow not after the real clock; each window's TakenUtc; an undated window throws (reason Undated); a window inside the horizon is counted as rejected-fresh and skipped |
opt.UtcNow |
RealtimeDseEngine |
the same | the same, per window | opt.UtcNow |
estimate --phaseangle |
RejectFuture |
the target time of the phase-angle override rows | the real clock |
No seam is switched off on the live paths. serve loads every window with its effective now and counts a fresh one
as rejected-fresh. The live interpose flush (InterposePipeline.FlushWindow(fallback, InterposeFreshPolicy, utcNow))
judges each window against the real horizon: a window older than the horizon (a replayed capture) maps through the
guarded Map and is written; a window newer than it (live telemetry) is never written to --measurements-dir, is
counted held-fresh, and is kept only as a quarantine capture in --out-store
(RtuMeasurementSetMapper.MapQuarantineCapture, never an estimator input). A run with --measurements-dir that held
any window ends REJECTED (forward inference), exit 2. interpose has no --utc-now.
Two engines also hold an output guard. RestrictionReview.AssertOutputHistorical (below) is called by
RealtimeEstimatorEngine and RealtimeDseEngine on the stamp of each estimate before it is emitted, with the same
horizon. The serve loop's rejected-fresh count is the serve verb's rejected-fresh figure.
Code that reads ForwardInferenceGuard.Horizon or HorizonHours without judging a stamp: the web session's bridge
start time (Horizon(UtcNow) less PresentationLimits.SettlementLagMarginDays, 2 days), the default register as-of
date of the model materialiser (Horizon(UtcNow) unless --as-of is given), the quarantine store's sidecar marker
(horizon_hours), and the messages printed by serve and interpose.
Provenance¶
ProvenanceTag (src/GridSim.Core/Diagnostics/Provenance.cs) has seven members. The audit log stores the member
name; the numeric value is never written.
| Value | Member |
|---|---|
| 0 | Recorded |
| 1 | DerivedFromRecorded |
| 2 | CuratedPlaceholder |
| 3 | Synthetic |
| 4 | Counterfactual |
| 5 | Interpolated |
| 6 | UnvalidatedAtScale |
The meaning of each member, the one-way flow (trust only moves down, never up) and the [VERIFY] pairing for
CuratedPlaceholder are specified in ch. 8, The ProvenanceTag taxonomy.
In the audit log the tag is the trust class of what the run produced: DerivedFromRecorded for an estimate from a
measurement file and for serve and the web host's resident estimator, Synthetic for --synth and for crossval,
Recorded for interpose. A log entry with a value
outside the enum is refused at write time and at read time.
RestrictionReview¶
RestrictionReview (src/GridSim.Core/Diagnostics/RestrictionReview.cs) is the review gate for invariant 5 of
UnderstandingRestrictions.md: fusing a dynamic process model into the estimator (recursive or forecasting-aided
estimation) is the one place where an estimator could quietly become a predictor.
AssertPermitted(method, usesDynamicsModel):
if not usesDynamicsModel -> return (static, measurement-only estimation is always allowed)
entry = Reviewed[method] (ordinal name match)
if entry is missing or not entry.UsesDynamicsModel -> throw RestrictionViolationException
if not entry.OutputPresentPastOnly -> throw RestrictionViolationException
AssertOutputHistorical(outputStamp, utcNow, what):
throw RestrictionViolationException if UtcTime.Normalise(outputStamp) > Horizon(utcNow)
Reviewed is a code-resident table of RestrictionReviewEntry(Method, UsesDynamicsModel, OutputPresentPastOnly,
ReviewNote). It holds four entries: Ekf, Ukf, EnKf and FrequencyRocofKf, each marked as using a dynamics
model and as certifying present or past output only. Each of the four filters calls
AssertPermitted(MethodName, usesDynamicsModel: true) when it is constructed
(KalmanStateEstimator, UnscentedKalmanStateEstimator, EnsembleKalmanStateEstimator, FrequencyRocofFilter), so an
unreviewed method fails before it runs. AssertPermitted checks a reviewer's attestation; AssertOutputHistorical is
the mechanical check at the emit boundary that a predict step cannot leak a future-dated state into an output frame.
The emitted estimate of a filter is the filtered present, stamped at the window time.
Parameters and defaults¶
| Name | Value | Unit | Meaning | Source symbol |
|---|---|---|---|---|
| Format tag | gridsim-audit/1 |
text | Written into every line; any other value is a parse failure | AuditLogFile.FormatTag |
| Genesis | GENESIS |
text | prevHash of entry 0 |
AuditLog.Genesis |
| Hash | SHA-256, upper-case hex | 64 characters | Over the six-field payload | AuditLog.ComputeHash |
| Payload separator | \| |
character | Joins the six fields | AuditLog.ComputeHash |
| Null state time in the payload | - |
character | Stands for a null stateUtc in the payload only |
AuditLog.ComputeHash |
| Timestamp notation | o, UTC, Z |
text | runUtc, stateUtc in the file and in the payload |
AuditLogFile.FormatUtc |
| Line terminator | LF | byte | Exactly one per entry; CRLF, a BOM and a missing final LF fail verification | AuditLogFile.EncodeLine |
| Lock file | <log>.lock |
path | Exclusive handle held for the life of a writer; left on disk | AuditLogWriter.Open |
| Entry limit | int.MaxValue |
entries | Append refuses to go beyond it |
AuditLogWriter.Append |
| Verifier read limit | Array.MaxLength |
bytes | A larger file cannot be verified in one pass | AuditLogFile.ReadAll |
| Shell display cap | 64 MiB (64 * 1024 * 1024) |
bytes | Largest log the desktop and web shells accept | AuditLogDisplay.MaxLogBytes |
| Token characters | letters, digits, . _ - @ / |
characters | Allowed in actor, action and input names | AuditEvent.RequireToken |
| Input hash | 64 upper-case hex characters | text | AuditInput refuses anything else |
AuditInput |
| Unhandled-exception exit code | 0xE0434352 (-532462766) |
int | Recorded when the CLI sees an unhandled exception | Program.cs, AuditRunRecorder wiring |
| Horizon | 169.0 | hours | Minimum age of any processed datum | ForwardInferenceGuard.HorizonHours |
| Bridge start margin | 2 | days | Extra age beyond the horizon for a web bridge session's start | PresentationLimits.SettlementLagMarginDays |
SolveTrace.CaptureJacobian |
false |
bool | Force Jacobian capture whatever the size | SolveTrace.CaptureJacobian |
SolveTrace.JacobianMaxN |
300 | buses | Dense Jacobian is captured up to this size | SolveTrace.JacobianMaxN |
| Reviewed dynamics-fused methods | Ekf, Ukf, EnKf, FrequencyRocofKf |
names | The only dynamics-fused methods that may run | RestrictionReview.Reviewed |
Invariants¶
Every test below is in tests/GridSim.Tests.
| Invariant | Pinned by |
|---|---|
| The file and in-memory logs use one hash function: a file round-trips to the same hashes, and a saved in-memory log loads back and can be appended to | AuditLogFileTests.Round_trip_file_entries_equal_the_in_memory_log_hash_for_hash, Save_of_an_in_memory_log_loads_back_identically_and_can_be_appended_to, AuditLogTests.A_clean_log_verifies_and_chains_from_genesis |
An Unspecified timestamp is stored as UTC with no shift and hashes as in memory |
AuditLogFileTests.An_unspecified_kind_timestamp_is_stored_as_utc_with_no_shift_and_hashes_as_in_memory |
| A line is one compact canonical JSON object; any re-spelling is refused | Line_format_is_one_compact_canonical_json_object_per_line, A_hand_reformatted_line_is_rejected_even_though_its_values_are_unchanged, A_timestamp_rewritten_as_non_utc_is_rejected, A_line_that_is_not_json_is_rejected, An_extra_field_is_rejected, Crlf_line_endings_from_an_editor_are_rejected, A_byte_order_mark_from_an_editor_is_rejected (all AuditLogFileTests) |
| An edit is found at the edited entry, or at the next entry when its hash was recomputed | Editing_an_entry_is_detected_at_that_entry, Editing_an_entry_and_recomputing_its_hash_is_detected_at_the_next_entry, AuditLogTests.Tampering_with_an_entry_breaks_verification |
| Deletion, reordering, a blank line and a cut-off line are found at the exact entry | Deleting_a_middle_entry_is_detected_where_it_was, Deleting_the_first_entry_is_detected_at_index_zero, Reordering_entries_is_detected_at_the_first_moved_entry, Inserting_a_blank_line_is_detected, Truncating_through_the_last_entry_is_detected_at_that_entry, Truncating_through_a_middle_entry_is_detected_at_that_entry, AuditLogTests.Reordering_entries_is_detected |
| Tail truncation and a whole-log rewrite pass verification alone and are caught by the anchor; an older anchor still verifies a grown log | Removing_whole_entries_from_the_end_is_caught_by_the_anchor_and_only_by_the_anchor, Rewriting_the_whole_log_with_fresh_hashes_is_caught_by_the_anchor, An_anchor_taken_earlier_still_verifies_a_log_that_has_grown_since |
| An empty file is a valid empty log; a missing file is an error | An_empty_log_verifies_with_zero_entries_and_the_genesis_head, A_missing_log_is_an_error_never_an_empty_log |
| A damaged, torn or anchor-mismatched log is never appended to, and is left untouched; a missing directory is an error | Opening_a_tampered_log_for_writing_fails_loud_and_leaves_it_untouched, Opening_a_log_with_a_torn_last_line_fails_loud, Opening_with_an_anchor_the_log_no_longer_matches_fails_loud, A_missing_directory_is_an_error_not_created_silently |
| One writer at a time; concurrent appends keep a gap-free chain; a disposed writer refuses to append | A_second_writer_on_the_same_log_fails_loud_while_the_first_is_open, Concurrent_appends_from_two_threads_keep_a_valid_gap_free_chain, A_disposed_writer_refuses_to_append |
| An undefined provenance is refused before anything is written | An_undefined_provenance_is_refused_before_anything_is_written |
| The summary layout is fixed; ambiguous fields fail before a write; input hashes are upper-case SHA-256 | Recorded_event_has_the_fixed_summary_layout_and_verifies, Event_with_no_inputs_records_a_dash, Ambiguous_or_multi_line_event_fields_fail_loud_and_write_nothing, Input_names_and_hashes_are_validated, Input_hashes_are_sha256_upper_hex, Default_actor_is_a_valid_token |
A run writes exactly one entry, with its exit code, however it ends; the log is opened before any work and released after Finish |
AuditRunRecorderTests.A_finished_run_writes_one_verifiable_entry_with_exit_code_inputs_and_outcome, Finish_writes_exactly_once_however_often_it_is_reached, A_run_without_an_outcome_still_records_its_exit_code, The_log_is_released_after_finish_so_the_next_run_can_append, Start_holds_the_single_writer_lock_until_finish, Start_refuses_a_tampered_log_before_any_work, Start_refuses_a_missing_directory_and_a_bad_actor_or_action_without_creating_a_file, RecordNow_writes_a_start_entry_before_the_final_one |
| A bundled case name is recorded as an id, never as a content hash; a directory hash covers content, names and presence and ignores enumeration order | A_case_name_is_recorded_as_an_id_never_as_a_content_hash, A_directory_hash_covers_content_names_and_presence_and_ignores_enumeration_order |
--audit-log is refused on every other verb and the verbs record their inputs and outcome (pinned by assertions on the text of Program.cs, not by running the verbs) |
Cli_audit_log_is_a_known_flag_refused_on_unaudited_verbs_and_opened_before_the_verb_runs, Cli_audited_verbs_record_their_inputs_and_outcome |
| The estimate outcome text has one source and an unchanged form for a run that did not hit the IRLS cap | N064AuditShowAndIrlsExportTests.Outcome_of_a_run_that_did_not_hit_the_cap_is_unchanged, Outcome_names_the_irls_cap_when_it_was_reached_before_the_extra_fields, EstimationAuditTrailTests.Records_estimates_as_a_verifiable_chained_trail |
audit show prints every entry then OK, prints nothing of a tampered log, exits 3, and the usage errors exit 2 |
N064AuditShowAndIrlsExportTests.Cli_audit_show_prints_every_entry_then_the_ok_line, Cli_audit_show_prints_nothing_of_a_tampered_log_and_exits_3, Cli_audit_verify_prints_no_entries, Cli_audit_usage_errors_exit_2, Describe_gives_one_ascii_line_with_every_field |
The shells show a log only through AuditLogDisplay, line for line as the CLI, and refuse an oversize upload |
N151ShellAuditIrlsHarmonicsTests.The_shell_display_matches_the_built_cli_audit_show_line_for_line, Both_shells_render_the_audit_log_only_through_the_core_display, An_upload_over_the_cap_is_refused, A_missing_log_fails_loud_never_an_empty_clean_log |
| The horizon is one constant, 169 hours | RestrictionInvariantTests.Inv1_The_horizon_is_a_single_knob, ForwardInferenceGuardTests.The_horizon_boundary_is_exactly_HorizonHours |
| The comparison is strict: the horizon instant passes, one tick later throws; recent and future stamps throw | ForwardInferenceGuardTests.The_exact_horizon_instant_passes_and_one_tick_inside_throws, A_recent_stamp_inside_the_horizon_is_rejected, A_future_stamp_is_rejected, A_stamp_beyond_the_horizon_passes |
Unspecified is UTC and Local is compared by instant |
ForwardInferenceGuardTests.Unspecified_kind_is_treated_as_utc, A_local_kind_stamp_is_compared_by_true_instant_not_naive_ticks |
AssertHistorical throws on the first violation and passes a clean series |
ForwardInferenceGuardTests.AssertHistorical_throws_on_the_first_future_row, AssertHistorical_passes_an_all_historical_series |
| The ingestion seams refuse a fresh stamp | EstimationMeasurementTests.Rejects_a_timestamp_newer_than_the_historical_horizon, RestrictionInvariantTests.Inv1_A_dated_ingestion_seam_rejects_a_fresh_stamp, Inv2_The_state_time_channel_is_guarded_end_to_end, GdaReplayLoopTests.A_future_dated_series_is_rejected_at_ingestion, Load_itself_rejects_a_series_dated_just_inside_the_horizon, EventCatalogTests.A_future_dated_event_is_rejected_on_load, HarmonicSourceSetTests.A_fresh_timestamp_is_refused_but_a_historical_one_is_accepted, RtuMeasurementSetMapperTests.Present_time_window_is_rejected_by_the_horizon_guard, PmuMeasurementSetMapperTests.A_fresh_superframe_is_refused_by_the_guard, BridgeClientRobustnessTests.A_frame_inside_the_horizon_by_ten_minutes_is_refused |
| A violation at the bridge ends the receive loop; an undated real-time window is refused | BridgeClientRobustnessTests.A_forward_inference_violation_is_still_loop_fatal, RealtimeEstimatorEngineTests.An_undated_window_is_refused, RealtimeDseEngineTests.An_undated_window_is_refused, RestrictionInvariantTests.Inv4_A_realtime_window_stamped_fresh_is_refused |
| An unreviewed dynamics-fused method is refused; a static method is permitted; every reviewed entry certifies present or past output and carries a note | RestrictionInvariantTests.Inv5_A_dynamics_fused_method_without_review_is_rejected, Inv5_A_static_measurement_only_method_is_permitted_without_review, Inv5_Every_reviewed_entry_certifies_present_past_output_and_carries_a_note |
| The output guard uses the input horizon: a future-dated output is refused, a historical one passes, the boundary matches | RestrictionInvariantTests.Inv5_A_present_past_stamped_output_is_permitted, Inv5_A_future_dated_output_is_rejected_mechanically, Inv5_The_output_guard_boundary_matches_the_input_horizon |
Model-output provenance labels are distinct from Recorded |
RestrictionInvariantTests.Inv3_Model_output_provenance_labels_are_distinct_from_recorded |
| The trace records Y-bus, spec, iterations and Jacobian; the Jacobian is gated by size and forceable; the sweep records sweeps and no Y-bus | SolveExportTests.Dense_trace_captures_ybus_spec_iterations_and_jacobian, Jacobian_is_gated_by_size_and_forceable, Bfs_trace_has_no_ybus_but_records_sweeps |
| Attaching a trace changes no result bit, dense or sparse | SolveExportTests.Attaching_a_trace_does_not_change_the_solution |
| Reactive-limit switching is recorded | S4SolverConvergenceTests (the QLimitEvents assertions) |
No test pins that NesoFrequencyCsv.Read refuses a fresh row, that MeasurementSet.Load accepts a set with no
takenUtc, or that a rejected input is itself written to the audit log; see Limits.
Worked example¶
Entry 0 of a log written by gridsim estimate case14 --synth --audit-log results/ch26.log, exactly as stored (the
capture is the file docs/_captures/audit/log-line.txt):
$ head -n 1 results/ch26.log
{"format":"gridsim-audit/1","seq":0,"runUtc":"2026-10-04T11:01:57.2144688Z","stateUtc":"2024-03-01T12:00:00.0000000Z","provenance":"Synthetic","summary":"actor=analyst@workstation action=estimate inputs=case-id:23CF1F80D96A7350F3BB4001DC5422B2B3BC021EA4EFB31264FE199A68D4C271,synthetic-measurements:A59ED1067B27834A405D499E1C4090C10BAD6DF51800370A6923B10F54D6F53F outcome=exit=0 method=Wls status=Converged observable=True J=80.01052231478454 chi2=PASS dof=95 removed=0 measurements=122","prevHash":"GENESIS","hash":"1B78AE0F3653078034D6F6D46E71BDC42CB5B2156DFDD7FAFED0C3B80347E8A4"}
[exit 0]
The hash is recomputed by hand with the payload rule above. The script is
docs/_captures/audit/recompute-hash.py; it reads the JSON line from the file it is given, builds the payload and
hashes it:
import hashlib, json, sys
for line in open(sys.argv[1], encoding="utf-8"):
if not line.startswith("{"):
continue
e = json.loads(line)
state = "-" if e["stateUtc"] is None else e["stateUtc"]
payload = "|".join([str(e["seq"]), e["runUtc"], state, e["provenance"], e["summary"], e["prevHash"]])
print("payload:", payload)
print("stored: ", e["hash"])
print("recomputed:", hashlib.sha256(payload.encode("utf-8")).hexdigest().upper())
if "status=Converged" in payload:
tampered = payload.replace("status=Converged", "status=Diverged")
print("one word changed (status=Diverged):", hashlib.sha256(tampered.encode("utf-8")).hexdigest().upper())
$ python docs/_captures/audit/recompute-hash.py docs/_captures/audit/log-line.txt
payload: 0|2026-10-04T11:01:57.2144688Z|2024-03-01T12:00:00.0000000Z|Synthetic|actor=analyst@workstation action=estimate inputs=case-id:23CF1F80D96A7350F3BB4001DC5422B2B3BC021EA4EFB31264FE199A68D4C271,synthetic-measurements:A59ED1067B27834A405D499E1C4090C10BAD6DF51800370A6923B10F54D6F53F outcome=exit=0 method=Wls status=Converged observable=True J=80.01052231478454 chi2=PASS dof=95 removed=0 measurements=122|GENESIS
stored: 1B78AE0F3653078034D6F6D46E71BDC42CB5B2156DFDD7FAFED0C3B80347E8A4
recomputed: 1B78AE0F3653078034D6F6D46E71BDC42CB5B2156DFDD7FAFED0C3B80347E8A4
one word changed (status=Diverged): 92EAC185FC4348F4E00437959545B6EB7838873D9AADF90BE6F1569F39F82D2D
[exit 0]
The recomputed hash equals the stored hash to the last character. Changing one word of the summary gives a completely
different hash, which is why audit verify reports an entry with that edit as the stored hash does not match.
The payload has prevHash last and no field for format, and the state time appears as the full o timestamp.
A null state time hashes as -. This is the case of a run refused by the guard before it measured anything. The command
below points estimate at a measurement set stamped in 2099 with --audit-log; the guard rejects it, the run exits 2,
and the run is still recorded, with exit=2 and no state time. The log's own hash and the head printed on stderr agree
(the run time, and so every hash, differs on every run):
$ gridsim estimate case9 --measurements docs/_captures/estimation/case9-telemetry-future.json --out results/ch31-future --audit-log results/ch31-future.log
REJECTED (forward inference): measurement set timestamp 2099-01-01T12:00:00.0000000Z is newer than the historical horizon 2026-10-13T04:23:45.3695438Z (data must lag real time by >=169 h). GridSim replays history only - it never processes recent or future data, so it cannot forward-infer.
audit: results\ch31-future.log entries=2 head=C796C9B48CCF2321C435514F86A0C301C925F3466C844CA0D1B31235BB9A5581 security-events=1
[exit 2]
The horizon in the message is 169 hours before the moment of the run (2026-10-20T05:23:45 less 7 days and 1 hour).
The audit: line counts two entries, one of them a security event (security-events=1). The head capture below is
of an earlier run of the same command, whose log held the one estimate entry.
$ gridsim audit show results/ch31-future.log
#0 run=2026-10-04T11:16:34.0620693Z state=- provenance=DerivedFromRecorded hash=FB0C45EDA313ADBC307CE54F6FD886E1B7B628AB82046E312549FD19EC0906B6 actor=analyst@workstation action=estimate inputs=case-id:D784A646F9FED50DEE41E22A244DBAC3BF6B790D2476876CCB2B3A737613CBEB,measurements:7B6797C69054B59D7E3D8A232C68A43C1C7C686EF486892F378C16FD661A25EA outcome=exit=2
OK results/ch31-future.log: 1 entries, head FB0C45EDA313ADBC307CE54F6FD886E1B7B628AB82046E312549FD19EC0906B6
[exit 0]
The stored line has "stateUtc":null:
$ head -n 1 results/ch31-future.log
{"format":"gridsim-audit/1","seq":0,"runUtc":"2026-10-04T11:16:34.0620693Z","stateUtc":null,"provenance":"DerivedFromRecorded","summary":"actor=analyst@workstation action=estimate inputs=case-id:D784A646F9FED50DEE41E22A244DBAC3BF6B790D2476876CCB2B3A737613CBEB,measurements:7B6797C69054B59D7E3D8A232C68A43C1C7C686EF486892F378C16FD661A25EA outcome=exit=2","prevHash":"GENESIS","hash":"FB0C45EDA313ADBC307CE54F6FD886E1B7B628AB82046E312549FD19EC0906B6"}
[exit 0]
and the same script recomputes it with the - in the third field:
$ python docs/_captures/audit/recompute-hash.py docs/_captures/audit/guard-refused-line.txt
payload: 0|2026-10-04T11:16:34.0620693Z|-|DerivedFromRecorded|actor=analyst@workstation action=estimate inputs=case-id:D784A646F9FED50DEE41E22A244DBAC3BF6B790D2476876CCB2B3A737613CBEB,measurements:7B6797C69054B59D7E3D8A232C68A43C1C7C686EF486892F378C16FD661A25EA outcome=exit=2|GENESIS
stored: FB0C45EDA313ADBC307CE54F6FD886E1B7B628AB82046E312549FD19EC0906B6
recomputed: FB0C45EDA313ADBC307CE54F6FD886E1B7B628AB82046E312549FD19EC0906B6
[exit 0]
Every timestamp, hash and the actor in these captures differ by machine and by run; the commands are real and the
structure is exact. The measurement file's hash (7B67...) is in the entry although the run was refused, because the
file was hashed as an input before it was loaded. The same refusal in a run with no --audit-log is captured in
docs/_captures/estimation/err-future-stamp.txt. Verification of damaged logs, with and without an anchor, is shown
in the user chapter 26.
Source map¶
| File | Type | Responsibility |
|---|---|---|
src/GridSim.Core/Diagnostics/AuditLog.cs |
AuditLogEntry, AuditLog |
The entry record, the in-memory chain, Genesis, and the one ComputeHash. |
src/GridSim.Core/Diagnostics/AuditLogFile.cs |
AuditLogFile, AuditAnchor, AuditLogVerification, AuditLogTamperException |
Line encoding and strict parsing, Verify, Load, Save, Describe, anchor checks. |
src/GridSim.Core/Diagnostics/AuditLogWriter.cs |
AuditLogWriter |
Single-writer lock, open-time verification, durable append, fault state, Anchor. |
src/GridSim.Core/Diagnostics/AuditEvent.cs |
AuditEvent, AuditInput |
Summary layout, token rules, input hashes, default actor. |
src/GridSim.Core/Diagnostics/AuditRunRecorder.cs |
AuditRunRecorder |
One-run lifecycle, RecordNow, Finish, InputForArgument, HashDirectory. |
src/GridSim.Core/Diagnostics/UtcTime.cs |
UtcTime |
The one DateTime to UTC rule. |
src/GridSim.Core/Estimation/EstimationAuditTrail.cs |
EstimationAuditTrail |
Outcome text for an estimate; stand-alone Record. |
src/GridSim.Core/Presentation/AuditLogDisplay.cs |
AuditLogDisplay |
Shell display of a log, anchor boxes, the 64 MiB cap. |
src/GridSim.Cli/Program.cs |
--audit-log block, RunAudit |
Flag parsing, provenance choice, process-exit recording, audit verify and audit show. |
src/GridSim.Core/Diagnostics/SolveTrace.cs |
SolveTrace and its row types |
Opt-in solve recorder. |
src/GridSim.Core/IO/SolveExport.cs |
SolveExport |
Serialises a trace as gridsim-solve-export/1. |
src/GridSim.Core/Diagnostics/ForwardInferenceGuard.cs |
ForwardInferenceGuard, ForwardInferenceException |
The 169 hour horizon. |
src/GridSim.Core/Diagnostics/Provenance.cs |
ProvenanceTag |
The seven trust classes. |
src/GridSim.Core/Diagnostics/RestrictionReview.cs |
RestrictionReview, RestrictionReviewEntry, RestrictionViolationException |
Review table, AssertPermitted, AssertOutputHistorical. |
The tests are AuditLogFileTests.cs, AuditLogTests.cs, AuditRunRecorderTests.cs, EstimationAuditTrailTests.cs,
N064AuditShowAndIrlsExportTests.cs, N151ShellAuditIrlsHarmonicsTests.cs, ForwardInferenceGuardTests.cs,
RestrictionInvariantTests.cs, SolveExportTests.cs and the seam tests named in the invariants table.
Limits¶
- The chain uses plain SHA-256 with no key: a log rewritten from end to end with recomputed hashes verifies unless an anchor kept out of the writer's reach is supplied.
- The anchor file sits beside the log by default; an attacker who can write both can rewrite both.
--audit-anchor(orAudit:AnchorPath) puts it out of the writer's reach. A log whose anchor file was deleted is accepted once, with the gap recorded in the next entry. runUtcis the writing machine's clock andactoris the operating-system account name; neither is attested.- A run killed forcibly, or cut by power loss, leaves no entry; only
interposewrites an entry before the end of the run. - Only
estimate,crossval,interpose,serveand the web host's resident estimator write audit entries; live dashboard sessions andPOST /api/solveare model solves and are not audited. An unknown flag, a missing--audit-logvalue and--audit-logon an unaudited verb are refused before the log opens and leave no entry. - Verification reads the whole file into one array, so a log larger than
Array.MaxLengthcannot be verified, and the desktop and web shells refuse one larger than 64 MiB. - The summary is stored in clear text, and no code removes a secret placed in an outcome string.
EstimationAuditTrail.Recordwrites a stand-alone entry to an in-memoryAuditLogand nothing insrccalls it; the CLI usesEstimationAuditTrail.Outcome.- An all-assumed harmonic source set (no
RecordedorDerivedFromRecordedsource) may be undated and then has no horizon check. NesoFrequencyCsv.Readjudges every row against the real clock only (Doc3HorizonGuardTests.D194_a_fresh_row_is_refused_and_a_historical_file_readspins the refusal).- The review register is the code table
RestrictionReview.Reviewed; no separate document mirrors it. - The web AUDIT LOG tab belongs to a live session's tab list; a replay-only instance opens no live session (its
/dashboardgate answers 404 for every id but a configured raw replay), so the tab is not reachable there. - The solve trace of the sparse engine carries no Jacobian, and the sweep records no Y-bus.
See also¶
- Ch. 26, Audit log, deployment and assurance - the user view: running, reading and verifying a log.
- Ch. 10, File and wire formats -
gridsim-audit/1among the other formats. - Ch. 8, Provenance and invariants - the tags, their flow rules and the guard's rationale.
- Ch. 6, State estimation - what
estimatecomputes and the estimator trace. - Ch. 9, GDA integration and ch. 20, The RTU/SCADA interposer - the data lake and live-telemetry seams.
- Ch. 18, Real-time, distribution and dynamic estimation - the real-time engines and the filters that
RestrictionReviewgates. - Ch. 35, Deployment and security - the web host, CI and the assurance documents.
- Ch. 19, audit, estimate, crossval and interpose - the verbs' flags and exit codes.