Audit Trail & Change-Log Compliance Layer
How a compliance add-on closes every application-level path to disabling or editing a mandatory change log — enforced append-only, not cryptographically immutable
1. Requirements
1.1 Functional requirements
- Provide a single, instance-wide switch turning on mandatory change logging for a defined set of financial and stock-affecting record types, in response to a statutory requirement (effective for a recent financial year) that companies keep an edit log of every change to their books of account.
- Once on, force the “track every change” schema property for every record type in that set automatically, without an administrator configuring each type by hand.
- Prevent the change-log rows produced for those types from being edited or deleted through any standard save/delete path — the most direct way to erase evidence of a prior change.
- Prevent the “track every change” property itself from being turned off, edited, or deleted for a protected type, via the schema-customization editor or a direct edit of the underlying schema-override record.
- Once on, force off — and refuse to re-enable — a separate, pre-existing setting that lets a transaction’s ledger/stock footprint be deleted along with the transaction, since that would otherwise erase the entries the log exists to protect.
- Surface a one-time, dismissible prompt encouraging relevant users to enable the feature; stop showing it once enabled or dismissed.
- Provide a descriptive report — by change, by record type, by user — over creation/modification activity for protected types, filterable by date range and, where supported, by legal entity.
- Let other installed applications extend the protected-type set rather than fixing it inside this layer alone.
1.2 Non-functional requirements
- No single bypass door. The settings toggle, the customization editor, and the schema-override record each need their own independent guard — closing only one leaves the others open.
- Anti-circumvention on save. A guard inspecting only the state being saved can be defeated by retargeting a record to an unprotected type; guards that matter inspect both the incoming and the pre-save state.
- Idempotent activation. Turning the feature on twice, or re-running activation after a migration, must not create duplicate overrides or rejections.
- Provisioning must not be blocked by its own guard. The code path creating the schema overrides this layer depends on must run during installation and migration without tripping the guard it installs.
1.3 Constraints
- Enforcement is entirely at the application layer. A write directly against the underlying data store is invisible to every mechanism here — nothing can detect it, let alone block it.
- The activation switch lives on a single, instance-wide settings record, not a per-legal-entity one; turning it on affects every legal entity at once, with no per-entity opt-in.
- The protected-type set is contributed through an extension hook rather than enumerated in this layer’s own code — the same extension point the localization framework (documented separately) uses to let a regional add-on register against core record types.
- Guard checks step aside during installation and migration, since those flows legitimately need to create or recreate the schema-override records this layer relies on.
2. High-Level Design
2.1 Component diagram
2.2 Activation and enforcement, as a procedure
- An administrator sets the activation flag on and saves. A validator rejects the save outright if the flag instead moves from on to off — the latch’s only behavior in that direction.
- On a genuine off-to-on transition, the same save zeroes the ledger-deletion setting if set, and enqueues a background job that walks the registry, creating a schema-override record (tracking on) for any type lacking one. The job checks for an existing override first, so re-running it — including automatically after a migration — is safe.
- A second validator independently refuses to let the ledger-deletion setting be turned back on while the flag is on, so step 2’s zeroing cannot be quietly undone later.
- From then on, three guards run on their respective save/delete paths (§ 3.3), each rejecting the bypass named in its diagram box.
- Separately, a per-user boot-time flag drives a one-time, dismissible prompt urging activation — no enforcement weight, dismissing it is a legitimate permanent choice, and it clears itself once the trail is active.
The cascade in step 2 is asynchronous, so a real window exists between the save completing and every type’s tracking property actually being forced on (§ 4).
3. Deep Dive
3.1 Data model
Shared Accounting Settings A single, instance-wide settings record, not scoped to a legal entity. Carries the Activation Latch and the pre-existing Ledger-Deletion Setting this layer neutralizes once the latch is on.
Protected-Type Registry Not a stored record — a list contributed through an extension hook, read fresh everywhere it is consulted. By role: the settings record itself (so changes to the flag are logged too); ledger-posting documents (invoices, settlements, journal entries, period-closing runs, a deferred-recognition run, fixed-asset transactions); stock-affecting documents with a ledger impact (dispatch records, goods receipts, stock movements and reconciliations, landed-cost allocations, subcontract goods receipts); a point-of-sale sales-invoice variant; and an import-customs declaration document that also posts ledger entries. Other applications can add their own types without the lattice changing, since every guard re-reads the registry rather than caching it.
Schema-Override Record A generic mechanism for adjusting a record type’s schema-level properties without touching the schema definition. This layer uses exactly one such property — the “track every change” flag — and the cascade creates one system-generated override per protected type with that property on.
Customization Record A record-type-specific editor where an administrator adjusts several schema-level properties at once, including the same tracking flag. Loading it for a protected type returns a flag telling the client the trail is active for that type, so the interface greys the control out before the user tries to change it.
Change-Log Row The platform’s own record of a field-level change to a tracked document: which fields, old and new values, who made it, when. Produced automatically for any type whose tracking property is on. This layer’s only direct effect on logging is forcing that property on — it generates no rows itself.
3.2 Algorithm — the one-way latch
There is deliberately no edge back to Disabled. The validator has no code path that succeeds when the incoming value is off and the stored value is on — it throws unconditionally, with no permission or role able to bypass it.
3.3 Algorithm — the enforcement lattice
The three save-time guards share one shape:
on save (or delete):
if part of installation or migration: allow
if the audit trail is not active: allow
if the record, as it will be saved, is "protected": reject
if the record, as it was before the save, was "protected": reject
otherwise: allow
“Protected” means something different per guard, but checking the new state and then the pre-save state, independently, closes the retargeting bypass: a change-log row cannot dodge the guard by flipping its referenced type to something unprotected, since the pre-save check alone rejects it. The schema-override guard applies the pre-save check to deletes too, and allows a brand-new override for a protected type only when its value is set to on — one left off is rejected like an edit would be. The customization-editor guard checks the same membership, but against whether the save would leave a protected type’s tracking off, not a “protected” state on the customization record itself.
3.4 The honest hole: what install and migration exempt
All three guards begin with the same short-circuit: during installation or migration, they do nothing, regardless of whether the trail is active. Deliberate — the cascade in § 2.2, and the routine re-run after every migration to recreate any dropped override, both need to create and modify exactly the records these guards otherwise protect; exempting the flow that maintains the guarded state is the only way to let that state be maintained. But it makes installation and migration a real, uncovered window: any code inside those flows, not only this layer’s own provisioning routine, can create, edit, or delete a schema-override record for a protected type with no re-check afterward.
3.5 The report — a query layer, not a re-derivation
The activity report’s three views — detailed, by record type, by user — are all built the same way: creation counts query each protected type’s own table filtered by date, owner, and (where present) legal entity; modification counts query the change-log table the same way, keyed by which type each row references. It sums and lists what those two tables already contain, recomputes nothing, and has no notion of “suspicious” activity — flagging anomalies is left to the reader.
One asymmetry is worth naming: the report can scope creation counts by legal entity because source documents carry that field, but not modification counts, since the change-log table carries no legal-entity column — a documented gap. The record-type picker also quietly excludes the settings record, and conditionally the deferred-recognition run when configured to skip manual review.
3.6 Error handling
Every guard fails the same way: a synchronous, user-facing error that stops the save or delete outright, naming the setting or type involved. There is no soft-warn mode and no permission that raises the guard — the only lever an administrator has is never turning the flag on. Once on, every rejection is a variant of “this cannot be changed because the audit trail requires it.”
4. Scale and Reliability
- Guard cost is negligible — a set-membership test against a short, cached list, run once per save/delete of a small, low-frequency set of record types.
- The report, not the guards, is where cost can grow. The detailed view queries the change-log table once per protected type in a loop, scaling linearly with type count as the registry or date range grows. No pagination or background-job mode was found — it runs synchronously on request.
- The activation cascade has a real race window. Being a queued job rather than an inline step, a gap bounded only by queue latency exists between the settings save and every type’s tracking property actually being forced on. A save against a protected type inside that gap produces no change-log row despite the trail being nominally “on.”
- Nothing here needs dedicated infrastructure — ordinary inline validation reusing the generic change-log and schema-override tables, scaling as the rest of the system does.
- If the cascade job never runs (queue backlog, outage), the flag stays permanently on — it cannot be turned off to retry — and some types may never get tracking forced on until the next migration re-runs the same routine, unpolled and unalerted.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Enforce through closed configuration paths, not cryptography | Cheap; reliably blocks every in-application bypass. No tamper evidence: a direct write against the underlying store bypasses all four guards, unnoticed. |
| Instance-wide, one-way flag, not a per-legal-entity toggle | Simple, matches “comply everywhere.” No pilot on one legal entity, and no way to exempt one later. |
| Reuse the platform’s generic change-log/override tables | Zero new storage cost; the report gets data for free. No legal-entity column on the change-log side — the § 3.5 filter gap. |
| Hook-contributed registry, not a hardcoded list | Extends coverage without touching this layer. Only as complete as every contributor remembering to register. |
| Guards check both new and pre-save state | Closes the retarget-to-bypass trick, at the cost of an extra lookup per save/delete of a low-frequency record. |
| Install/migration unconditionally exempt every guard | Necessary — the provisioning routine has to run somewhere. The guarantee genuinely does not hold in those windows. |
| Asynchronous activation cascade | Keeps the settings save fast with a large registry, at the cost of an “on” vs. “enforced” gap. |
6. What to Revisit as the System Grows
- Most important: there is no tamper-evidence mechanism at all today. Nothing computes a hash, signature, or checksum over a change-log row; nothing chains one row’s fingerprint into the next; nothing exports or anchors the log outside the database it lives in. A direct write against the underlying store — bypassing the application entirely — leaves zero trace and trips none of the four guards. If the real requirement is defensibility against a privileged insider with database access, not just an ordinary user through the interface, what exists today does not meet it. Real tamper-evidence would need, at minimum, a content hash per row incorporating the previous row’s hash, plus a periodic export of that chain to storage this application cannot write to, ideally with an externally verifiable timestamp.
- Close the activation-cascade race window by running the cascade inline, or blocking the settings save until it completes, so “on” and “enforced per type” become true at once.
- Give the report a legal-entity column for modification counts, backfilled onto the change-log table or joined through the referenced document at query time.
- Make registry membership self-auditing — a periodic check comparing it against every record type known to post to the general ledger, flagging any absent, would catch the silent-gap failure mode in § 5 before an auditor does.
- Reconsider the per-legal-entity granularity if the deployment ever needs compliance for some legal entities but not others — the current flag has no answer short of enabling logging everywhere at once.
- Monitor the exempted install/migration window — a log of what changed to schema-override records during those flows would make the exemption inspectable after the fact, not a silent blind spot.