Every security-relevant write to the Memory Database leaves an entry in the audit log. Each entry carries a SHA-256 hash linking it to the previous entry. The chain is verifiable from the Settings UI and from any external SQL client. Tampering with the database, removing rows, or reordering events breaks the chain at the point of interference and the verifier reports the exact row index where the break occurs.
This chapter covers the algorithm, the verification workflow, the on-disk schema, and the interpretation of failure states.
What gets recorded #
Every entry has the shape
Id GUID
EventType "PackageBuilt" / "Distributed" / "IdentityRenamed" / …
EntityType "PackageRecord" / "DistributionRecord" / "Settings" / …
EntityId GUID of the affected entity
Actor DOMAIN\user
ActedAt UTC ISO-8601 with offset
PayloadJson optional JSON snapshot of the change (before/after for updates,
full record for creates and deletes)
PrevHash 32 bytes — SHA-256 of the previous entry's RowHash, or 32×0x00
on the genesis (first) entry
RowHash 32 bytes — SHA-256 over (Id, EventType, EntityType, EntityId,
Actor, ActedAt, PayloadJson, PrevHash) with 0x1F as field separator
The current set of recorded event types:
| EventType | When it fires | EntityType |
|---|---|---|
PackageBuilt |
Build phase completes successfully | PackageRecord |
Distributed |
SCCM / Intune push completes (success or failure) | DistributionRecord |
IdentityRenamed |
Vendor or AppName edit on the History page | SoftwareIdentity |
NotesUpdated |
Notes field edited on a build record | PackageRecord |
PackageDeleted |
Delete on the History page | PackageRecord |
IdentityDeleted |
Orphan-identity delete | SoftwareIdentity |
HistoryCleared |
Clear-all-history confirm | Database |
DatabaseMoved |
Storage section's Move-database operation | Database |
DatabaseBackedUp |
Portable backup written (see chapter 680) | Database |
DatabaseRestored |
Portable backup restored | Database |
GdprExport |
Subject Access Request bundle written (see Enterprise chapter 820) | GdprExport |
PreCheckVerdict |
Compliance pre-check ran before a Distribute push | PreCheck |
ReportExported |
Pipeline Health PDF/CSV exported (see Enterprise chapter 880) | Report |
ReportPerActorToggled |
Settings → Reports → Per-operator-toggle flipped | Settings |
This list grows as new event types ship. New types do not need a schema migration — the column is free-form text.
How the chain is built #
The algorithm lives in AuditHashChain.ComputeRowHash and is shared by
the writer, the backfill pass, and the verifier — a single source of
truth so writer and verifier cannot drift apart.
For each entry the hasher accumulates:
Id UTF-8 of "00000000-0000-0000-0000-000000000000"
0x1F ASCII Unit Separator
EventType UTF-8
0x1F
EntityType UTF-8
0x1F
EntityId UTF-8 of the GUID
0x1F
Actor UTF-8
0x1F
ActedAt UTF-8 of ISO-8601 round-trip format ("o")
0x1F
PayloadJson UTF-8, or a single 0x00 marker if NULL
0x1F
PrevHash 32 raw bytes
The Unit Separator was chosen because it does not occur in any of the field encodings:
- GUIDs are hex with hyphens
- EventType and EntityType are ASCII identifiers
- Actor is
DOMAIN\user(no control bytes) - ActedAt is digits,
T,Z,+,-,:,. - PayloadJson is JSON, which forbids unescaped control bytes per RFC 8259
This avoids the canonical-JSON rabbit hole while still being injection-safe: an attacker who controls one field cannot collapse it into an adjacent field by injecting the separator.
The genesis row uses 32 zero bytes as PrevHash. Every later row's
PrevHash equals the previous row's RowHash, computed in ActedAt, Id
order.
Verifying the chain #
Three paths exist.
From the Settings UI #
Settings → Database → Storage health → Verify chain integrity. The button reads every audit row in chronological order, recomputes each hash, and reports one of:
- Chain valid — N rows verified — every entry matches.
- N rows verified · M need backfill — entries existed before the hash-chain shipped (v0.x or v1.0 pre-RC) and are awaiting an automatic backfill on next app start.
- Chain broken at row #N (Id=…) — the recomputed RowHash does not match the stored RowHash, or the stored PrevHash does not match the previous row's RowHash. The break point is reported with the entry's Id, EventType, and ActedAt so the auditor can investigate.
MAS runs an automatic verification pass on every app start that
persists the result under %LOCALAPPDATA%\MAS\audit-chain-status.json.
The Diagnostics section shows the last-verified status as a compliance
card ("Audit log integrity · last verified 2h ago: 1,247 rows OK") so a
broken chain raises an alarm even on machines where Settings is rarely
opened.
From the Diagnostics section #
Settings → Diagnostics → Audit log integrity card shows the same status
the Storage button surfaces, plus the trigger (AppStart /
Storage-Manual / Diagnostics-Manual) so it is clear whether the
snapshot is from the unattended boot-time scan or an operator-initiated
run.
When the verdict turns BROKEN the card displays a red Tamper-Alert
banner with the broken row index and reason. The banner persists across
section navigations until either the chain is repaired or the chain
status file is cleared.
From an external SQL client #
The chain can be verified independently of MAS — useful for forensic
review or for compliance teams that prefer their own tooling. The
required inputs are documented in the algorithm section above. A
reference implementation in Python is available in the MAS distribution
repo at docs/reference/audit-chain-verify.py.
Interpretation of failure states #
| Symptom | Most likely cause | Next step |
|---|---|---|
| Chain valid, N rows | Nothing to do | — |
| Chain valid, M need backfill | App was upgraded across the hash-chain ship date; backfill pending | Wait for the next app start to backfill automatically, or run the Verify button to trigger it |
| Chain broken at row #N | Direct UPDATE / DELETE on the AuditEntry table, restore from a divergent backup, or column-level data corruption | Identify the broken row's EntityType + EntityId, investigate why that row changed, document the finding for the audit trail |
| All rows verified, but the count is lower than expected | Direct DELETE on the AuditEntry table — the chain stays self-consistent from the post-delete state because the verifier re-walks from row #0 | Cross-reference against any earlier known-good count snapshots |
For a backup-restore boundary case, see chapter 680-Portable-Backup-and-Restore §Audit chain after restore.
Performance #
Verification scans the AuditEntry table linearly in ActedAt, Id order.
On a populated database with 50 000 audit rows the scan completes in
under one second on commodity hardware. The auto-verify-on-start runs
in a background task; the app window appears before the scan finishes
and the result populates the Diagnostics section asynchronously.
Indexes on ActedAt (single column) and on (EntityType, EntityId)
(composite) ship with migration 001. No additional indexing is required
for chain verification.
What this does NOT protect against #
- A privileged operator with direct SQLite access who replaces the
entire AuditEntry table with a consistent but fabricated chain.
The verifier sees a valid chain — it has no anchor outside the
database to compare against. For higher assurance, periodically
export the latest
RowHashto an external immutable store (write-once medium, customer-managed key vault, third-party timestamping service). - Backup-restore boundary forgery. A restore replaces the chain wholesale. See chapter 680.
- In-flight tampering during write. The chain detects post-hoc
edits; it does not prevent malicious writes by an authorised actor at
write time. The
Actorcolumn is the accountability mechanism for that case.
Related #
680-Portable-Backup-and-Restore— backup/restore boundary behaviour../800-Enterprise/820-GDPR-Export— Subject Access Request bundle includes the chain integrity proof../800-Enterprise/880-Pipeline-Health-Reports— audit-log activity surfaces in the reports