Skip to content
Updated Sep 26, 2026 by Barča Dvořáková · Owner: analysisactiveconceptgeneric Edit on GitHub

Audit Log ​

Concept layer — frozen. The Audit Log generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.

Business Context ​

Business-Level Definition ​

An audit log is one append-only record of what happened to a system's data: which record was affected, what happened to it, who or what caused it, when, and with what context. It spans every entity in the system rather than giving each feature a history of its own.

The reason it is shared is not economy of code. Per-feature histories cannot be checked for completeness against one another — each looks correct in isolation, and nothing reveals the entity that keeps no history at all. One trail makes "is anything missing?" a question with an answer.

Once written, an entry is never changed and never removed, so the trail can be relied on for review, troubleshooting and compliance. Reading it is permission-gated, and where the system scopes its data (per tenant, per workspace, per region) the trail is isolated the same way.

Requirements Definition ​

  • One mechanism any entity can record through, including entities that do not exist yet.
  • Each entry answers: what was affected, what happened, who did it, when, and with what context.
  • The trail is append-only, and an entry exists only where the change it records really happened.
  • The history is searchable and filterable — by record, by kind of change, by actor, by time period, and by the single action a group of changes belongs to.
  • Each reader sees only the scopes they are entitled to; events belonging to no scope are readable only by whoever administers the system as a whole.
  • History is readable both as a cross-entity investigation surface and in place on a record's own screen.

Technical Context ​

User Stories / Use Cases ​

  1. As a backend feature, I record an entry for an entity I changed, so that the entry exists exactly when the change does.
  2. As a user who can read a record, I read that record's own history without holding any audit-specific permission.
  3. As an investigator, I filter the whole trail by entity type, kind of change, actor, time range and the identifier that ties one action together.
  4. As an administrator of a scope, I browse that scope's history; a system-wide change is visible only to whoever administers the system as a whole.

Functional Requirements ​

Scope of the trail

  • What the trail records is a decision — a change that someone, or something, chose to make — rather than a row write for its own sake. A write that changes nothing produces no entry: several save paths rewrite rows identically, and enough entries asserting a change that did not happen teach readers that the log is noise.
  • The default is no per-entity exemption. An entity is not left out because it has no screen of its own, because it changes rarely, or because most of its writes originate in a background job.
  • Where a project does exempt an entity, the exemption is enumerated and closed — written down with its reason, countable, and never extended by analogy to a second case.
  • "It already has a history of its own" is not a reason. A version store holds content and answers "what did this say before"; the trail holds attribution and answers "what happened here, and who did it". They are different questions, and an entity with a version store is still invisible in its parent's history and in the investigation surface until the trail records it. Record it — carrying references and attribution, never the body the version store already keeps — and leave the version store as the product feature it is, with its own entitlement. The exemption list is for the genuinely different case: an entity whose changes the trail cannot represent at all.
  • A gap is silent. Nothing fails when an entity goes unrecorded, so the omission surfaces only when someone asks a question the trail cannot answer — which is exactly the moment it matters.

Entry shape

  • The audited record is referenced by type + id, deliberately without an enforced foreign key, so that any current or future entity can be referenced and an entry outlives the record it describes. The cost is accepted openly: references dangle, and every reader must tolerate an entry whose record no longer resolves.
  • The type names the row that changed, and it is derived from the data model. A screen name, a navigation label or a feature name is never used as a type: those describe how a change was made rather than what was changed, and they drift while the data model does not.
  • The entry carries what happened, as a value from a shared vocabulary that consuming features extend with their own values, and a structured context payload describing the change.
  • It carries the actor, in a form that tells a person from a machine. That distinction is load-bearing rather than decorative: machine activity outnumbers human activity by orders of magnitude, and every readable presentation of the trail depends on being able to separate the two.
  • It carries the time of the event and, where the system is scoped, the scope the event belongs to, with an explicit marker for a system-wide event that belongs to no scope.
  • It may carry a second reference declaring what the entry is about, where that differs from the row that changed. Many entries are filed against one entity yet concern another — a membership written against the group, asked about the member, as Users & Groups requires. The second reference makes that answerable with one indexed read instead of a scan, and it is where interface judgement belongs: choosing it wrongly costs a reading path rather than corrupting the record.
  • A type whose history must gather its children's entries names itself as its own subject on every entry. A person's own changes, their memberships and the grants made to them; a document's own changes, its links and its comments — "the record and everything about it" is then one indexed lookup on the subject pair. Without it the read has to match either of two column pairs, which no index serves. Which types self-subject is declared in one place and applied centrally by the write port, so a writer cannot forget it or name a different subject for such a type by mistake.

A project must choose the payload's shape, and each choice costs something different.

  • One free-form structured payload for every event type. Any feature can record anything and no schema changes when a field is added. The cost is that nothing validates it: readers defend against absent and renamed keys, and querying a field needs an index built for that field alone.
  • A payload typed per event type. Validated on write and queryable. The cost is a schema change per new event, and old entries written under the previous shape that must still render.
  • A small mandatory core plus a free-form remainder — typically the before/after of the fields that changed, and whatever else the writer wants. Buys uniform rendering and uniform diffing for the common case without freezing the uncommon one; costs a rule that writers must actually follow.

Two things do not vary with the choice. The payload never carries secrets or credentials — it is readable wherever the trail is. And it is written for a reader who does not have the code in front of them: a payload only its author can interpret records the change without explaining it.

The audited field set — one per entity, not one per write path

Choosing the payload's shape is not the same as deciding what goes in it, and the second question is the one that gets skipped. Where it is skipped, implementations do not fail — they diverge, and the divergence is invisible until someone reads the trail expecting an answer it cannot give.

This is worth stating flatly because it has been measured. In one adoption, three of eleven entities had drifted: one recorded five fields on creation and eight on deletion, another nine and four, a third excluded a column silently on both. The two that were unambiguously correct had arrived there by independently inventing the same mechanism — a single named constant every write path derives from. Nothing had asked for that. Two people happened to think of it, and five others did not.

  • The set is defined once per entity type and every write path derives from it. Not restated per use case. The failure this prevents is not a wrong value; it is created and deleted carrying different vocabularies, so a reader cannot diff the beginning of a record's life against its end.
  • created records the state that was established — the audited set in full, not a summary of it.
  • deleted records the state that was lost — the same set. A deletion entry must be self-contained: a reader must not have to replay the record's whole history to learn what was deleted. This is the rule most often broken, and the damage is silent, because a thin delete entry looks like a delete entry.
  • updated records which fields changed, with before and after for those fields. Unchanged fields are omitted. The set defines what may appear, not what must.
  • Every exclusion is named and justified where the set is defined. Excluding a credential, a secret reference or a large blob is legitimate. Excluding a field because nobody thought of it is the defect this rule exists to catch, and the two are indistinguishable after the fact unless the legitimate one is written down.
  • A column excluded by value is still named. Its name appears among the changed fields, so a reader learns it was set or changed even where the value is not carried. Otherwise the trail cannot distinguish "this was not touched" from "this was touched and we do not say how".
  • Credential-bearing values are redacted, not omitted. Where a structured column may contain credential material, sanitise it and record the sanitised form. For a pure secret, record that the field changed and never the value.

Enforcing the rule, not just stating it.

The rule above is easy to state and easy to let drift, because nothing fails when it is skipped — which is exactly the anecdote above. One project closed the gap by deriving the set mechanically instead of restating it: every entity already has some machine-readable definition of its own shape (a validation schema, an ORM model, a DTO) that the compiler or the framework already keeps current. Derive the audited set from that definition minus two small, explicit, named arrays per entity — the fields that are computed or joined and can never appear in a diff, and the fields deliberately excluded, each with its reason — and assert by test that the three partition the schema exactly, with nothing left uncategorised and nothing counted twice. A field added to the schema with no matching decision then fails that one entity's test at review time, rather than shipping unaudited and undiscovered. The default direction matters: an uncategorised field belongs in the audited set until someone excludes it, not the reverse — under-auditing a real change is worse than over-auditing a derived one, and the fail-safe direction should favour the trail. This is one way to satisfy the rule above, not the only one; a project with no machine-readable per-entity schema, or no test suite to hang the assertion on, has to enforce the rule some other way — but "a person reads the ticket at review time" is what produced the drift in the anecdote above, so a mechanical check is worth what it costs to build.

A decision the project takes: does an excluded structured column ever carry its value?

Naming it and never carrying it is cheap and uniform. But some structured columns are controls — a rule set deciding what the system accepts or rejects — where whether it was configured deliberately or left at its default is exactly the question an investigation asks, and both cases otherwise produce byte-identical entries. A layout blob and a validation rule set are the same shape and not the same risk. Decide per column, and record the reasoning where the exclusion is named.

A second decision: when is a child row's lifecycle an event of its own?

Adding or removing a child row can be filed as a creation and deletion of the child, or as an update to the parent carrying a discriminator saying which. Both are defensible and both are readable; what is not defensible is one entity doing it differently from the rest without saying so. State the rule once and apply it, because a reader who learns the convention from one entity will misread another.

Filing an entry that relates to more than one entity

  • Two questions decide the filing, in order. First — does the row carry data of its own that changes, beyond its references, its scope column and its audit columns? If it does, its own history is meaningful and the row takes its own entity type. If it carries only references, or only columns fixed at the moment it is created, it records a relationship rather than a thing: the change is filed against one of the entities it relates, with the other carried as the subject. A row whose only events are its creation and its deletion records a relationship however many descriptive columns it carries — those describe how the relationship was made, not a state that moves.
  • Second — count the entities a reader would look the entry up under, excluding scope. The scope is a filter carried on every entry, not a reading axis. This count does not change the filing; it tells you what the filing costs. With one such entity nothing is lost. With two, only one of them gets a reading path, and which one is a deliberate choice rather than an accident.
  • Why two references and not three. A pinned read is a single indexed equality on one reference pair. A third axis needs a third pair and a third index on the largest table in the system, and matching either of two pairs cannot be served by an index at all — it degrades to a sequential scan. The limit is structural, not a matter of taste.
  • One consequence to recognise if it arrives. A relationship row that later gains data of its own would, by the first question, want its own entity type. The reference on existing entries is append-only and cannot be re-filed, so the history would split at that point. Continuing to file such a row against the related entity is then the conservative choice, and the departure is recorded on the consuming feature's own pages rather than treated as a defect.

Write path

  • Entries are written only by the system's own code, as part of the operation that makes the audited change. There is no public write API: a trail an API client can write to records claims, not events.
  • The trail is append-only. Nothing updates or deletes an entry once written, and the only removal path that exists at all is a technical redaction for legally mandated erasure.

One decision, one action

  • A single decision routinely changes several rows across several entities — a screen save that writes a record and its children, a bulk synchronisation, a cascading delete. Every entry therefore carries the identifier of the request or job step that caused it, so that the entries written under one decision can be recognised as one action.
  • Without it the trail records the rows correctly and the action not at all: a reader sees a scatter of writes and has to guess which belonged together.
  • The consequence for reading is deliberate and worth stating before it surprises anyone: the unit of a page becomes the action rather than the row, so paging and totals follow actions and one large cascade does not fill a page on its own. Entries written outside any request context carry no such identifier and stand alone.

Read path

  • The history is read newest-first, filtered by record, entity type, kind of change, actor, time range and the action identifier, and always paginated. The read surface is described on the Audit Log — API Analysis (API-A) page.
  • Scoped reads return only their own scope. A read that spans scopes, or that reaches events belonging to no scope, is a distinct and more privileged capability.
  • The trail can also be read by subject, returning every entry about a given record regardless of which record each is filed against. Scope isolation holds underneath: a cross-scope per-subject history is the privileged capability, not the ordinary one.

Rendering: derive the description, or leave it to the client

  • A raw entry — type, id, event value, payload — is readable by someone investigating and unreadable by everyone else. Something has to turn it into a sentence.
  • The concept's position is that the description is derived server-side and returned with every entry: a translation key plus its interpolation parameters, a severity, and the actor's display name. Every surface then reads the same payload and differs only in what it shows, and there is one place where a new event type becomes readable.
  • The alternative — each client mapping event values to text — puts the mapping in as many places as there are clients, and guarantees they diverge.
  • Derivation is total: an event type nobody has described yet falls back to the translated name of the event, a missing payload path is omitted rather than emitted as null, and a malformed payload is logged rather than raised. A newly audited entity is never invisible while its descriptions are being written, and one bad row never fails a page.
  • Where the actor's display name comes from is a decision, and neither answer is free. The entry always carries the actor identifier; the question is the name a reader sees beside it.
    • Snapshotted onto the entry at write. History reads as it read at the time — a renamed person keeps last year's name on last year's entries. The cost is personal data copied once per action into the fastest-growing table in the system, so an erasure request now reaches every entry that person ever caused (see Legal Context).
    • Resolved at read, without a deleted-guard, with status flags. One copy of the name, in the directory; a rename relabels history; a deleted actor is still named, and the read returns whether the account is deactivated or deleted so the interface can mark it rather than the reader inferring it. The resolving join must also be in the count query, or the total and the page disagree. Display names inside the context payload — a renamed set, a renamed assignee — are a different matter and are always recorded as they read at the time: there the point-in-time value is the fact being recorded, and the payload is the only place it exists.

What a person sees is a translation or a name — never a raw identifier, code or column name

  • The derived description already covers the sentence. The same rule covers everything else a human-facing surface shows from an entry: field names in a before/after (the label a person knows the field by, not the column name), enumerated values inside a before/after (the translated status, not its code), references to other records (the record's human key or display name, resolved best-effort, beside the identifier rather than instead of it), and the actor (above).
  • Field-name and value translations are keys, owned and seeded by the feature that writes the entries, exactly as its event descriptions are. A value the registry cannot translate falls back to the raw form and is logged — never hidden, never an error.
  • The investigation surface is allowed to show the raw form as well: there the question is often "which id, exactly", and a person copying an identifier into another tool needs the identifier. The per-record feed is not — it is read as prose.

Visibility is a presentation choice, never a recording rule

  • Not every recorded event belongs in a human-facing feed: internal bookkeeping and high-frequency machine events bury the handful of entries a person came to see. A project therefore needs a way to mark which events are feed-visible, and to hold machine-attributed entries back until asked for.
  • This is how uniform recording stays readable, and it is the answer to a high-volume entity: record it in full and manage its noise when reading, rather than leaving the entity out of the trail.
  • Two limits keep it from becoming a second, quieter kind of exemption. Hidden entries remain in the trail and remain visible to the investigation surface — visibility never restricts what compliance can read. And the rule resolves inside the query, not after it, or pagination and totals describe a result set the reader never sees.

Presentation may reorder, label and nest. It may never make an entry unreachable.

  • The rule above governs filtering. The same principle governs grouping, and that half is easier to lose, because nothing is excluded — an entry is simply folded under another with no way to open it. For every entry a reader is authorised to see there must be a path in the interface that reaches it and shows its payload.
  • This is where correctness against the letter of a rule and correctness against its purpose come apart. "One action reads as one action" is a good rule; an implementation that honours it by collapsing several entries into a headline, and offers no way into the ones it collapsed, has satisfied the sentence and defeated the trail. The entries were written, stored and authorised — and unreachable, which for audit purposes is indistinguishable from never having been written.
  • The rule applies to correlation grouping, to per-record history, and to any summarisation added later. A presentation rule that hides an entry behind another owes the way in.

Authorisation — pinned versus unpinned reads

  • A read that pins a record — its type and id, or the subject type and id — is authorised against the read permission of that entity type: the same permission that governs the screen the history sits on. A caller who may read the record may read its history, and no audit-specific permission is involved.
  • A read that pins nothing — the investigation surface, any cross-entity search — is authorised against an audit read permission.
  • The distinction matters because an audit read permission gates the endpoint, not the row. Granting it so that a user could see one record's history would hand that user the entire trail. Conversely, a caller holding only an entity read permission cannot drop the pin to browse anything else: the request is rejected, never quietly served as a filtered result.
  • The endpoint therefore holds a mapping from entity type to the read permission that governs it, maintained alongside the event descriptions so the two cannot drift apart. Where a system exposes the same entity on surfaces with different permissions, the mapping is keyed on the pair (surface, entity type) rather than on the entity type alone.
  • Where the mapping holds no entry, the read falls closed. An entity type that no screen reads has no read permission to resolve, so pinning it requires the audit read permission exactly as an unpinned read does — the pin narrows the result, it does not lower the bar. Having no screen of its own is not the same as having no read surface: a child row edited as a panel of another record's screen maps to that screen's permission.

UI/UX Design ​

Two reading surfaces answer two different questions, and a project needs both. Collapsing them into one serves neither reader well.

Investigation surfacePer-record history
Questionwhat happened across the systemwhat happened to the record I am looking at
Readeralready searching, does not know which recordalready knows the record
Shapedense table: entity type, id, event, actor, raw contextactivity feed: one chronological line per action, read as a sentence
Filterseverything, including entity type and recordeverything except the record — that is pinned by the host screen
Backed bythe read surface with nothing pinnedthe same read surface with the record pinned

Both are read-only, because the trail is append-only: no surface offers a create, edit or delete affordance. Both present the entries sharing one action identifier as a single row that expands in place to the individual entries beneath it, so a cascade occupies one line. Both keep entries whose record no longer resolves.

Both surfaces are shared components, not per-feature builds. The per-record history is one component wherever it appears. The investigation surface is one component too — one column set, one filter set, one detail view — mounted once per scope it serves (a scope's administrators, the whole system's administrators), differing only in the scope parameters the privileged variant adds. Two browsers that drift apart in columns or filters are two answers to "what happened".

Where a surface is placed is a project decision; what it does is not. Whether the per-record history opens as a modal, a drawer, a tab or a section of the page, and what control opens it, is decided by the project's interface conventions and recorded once. The component's behaviour below, and its authorisation, do not vary with the placement.

The investigation surface has one "find" input that accepts any identifier a person has in hand. A record id, a subject id, an action id, or the record's human-readable key — pasted as-is, matched exactly across all of them, index-backed, and composable with every other filter. An investigator usually arrives holding one of those and nothing else; a surface that makes them first guess which filter the identifier belongs in has failed at the moment it was opened. This is exact matching on identifiers and keys, not full-text search over the payload — that would need an index over the largest table in the system and competes with the write path, and is a separate decision a project takes or declines explicitly.

A default date range on the investigation surface. Opened with no filter, the surface shows a bounded recent window rather than everything ever recorded, with the bound visible and removable. It is a better default for a reader, and it is what makes the retention shape under Non-Functional Requirements workable: a partitioned table prunes only when the query filters on the partition key, and an investigation that opens on all time never does.

Points that matter wherever the per-record history is used:

  • Timestamps are absolute and locale-formatted. An audit surface needs precision, so relative phrasing ("2 hours ago") is not used.
  • Severity shows only when it is not ordinary, so a normal history reads as plain text rather than a column of icons.
  • Every row expands to its raw context and technical identifiers. Nothing is hidden from the reader; it is simply not on the face of the list.
  • Filters are collapsed by default, so the feed opens as a feed rather than as a search form. Filter and page state belongs in the URL so a view can be shared or restored.
  • An error state is never rendered as an empty one. "Nothing happened" and "we could not find out" are different answers, and confusing them is how a reader concludes wrongly. Where the viewer cannot read the host record the section is omitted entirely rather than shown empty.
  • Accessibility: the feed is marked up as a list, each expander exposes its expanded state, and every timestamp carries a machine-readable value alongside its localized text.

Internationalization & Localization ​

  • Entity-type and event display names, and the descriptions the read surface derives, resolve through the project's translation mechanism and are seeded for every supported locale. Because a historical entry resolves through those keys, they are retired and replaced rather than edited when the wording changes; see Translations.
  • The descriptions are owned by the feature that writes the entries, not by the audit log itself.

Non-Functional Requirements ​

  • Scope isolation on every read; a cross-scope read is a separate, more privileged capability.
  • Pagination on every list read. There is no unbounded read of this table, ever.
  • Immutability: entries are never modified after being written.

Retention. A trail that receives every change to every entity grows without bound, so the retention rule is part of the design rather than an operational afterthought, and it is settled before the feature runs in production: either a defined horizon after which entries expire, or a deliberate decision to keep them indefinitely. Where a horizon applies, expiry must be distinguishable from the technical redaction path that exists for legally mandated erasure — conflating the two makes ordinary ageing indistinguishable from a legal removal, and destroys the one signal a reader needs to tell them apart. Where entries can age out, the read surfaces make the boundary visible: a history that silently stops invites the reader to conclude that nothing happened before it. The shape of the table follows from the horizon — partitioning by event time makes expiry a partition drop rather than a row-by-row delete, but a partitioned table prunes only when a query carries the partition key, and the per-record history and the correlation lookup carry none. The default date range on the investigation surface (UI/UX Design) is what bounds those reads; decide the horizon, the shape and that default together, before the table is large.

Performance ​

  • Writing an entry is a single indexed insert, cheap enough to run inline with the operation that triggers it.
  • The table grows monotonically and becomes the largest in the system. Whether it is partitioned, and on what key, belongs to its physical design and is decided alongside the retention rule rather than retrofitted once the table is large.

Transactional Operations ​

How the entry is written relative to the caller's transaction is the central design decision of this generic, and it is not a detail. It fixes what a reader may conclude from the trail.

  • Inside the caller's transaction. The entry and the audited change commit or roll back together, so an entry exists if and only if the change did. This is the strongest reading guarantee available and the reason most trails are built this way. It costs: the audit write joins the critical path of every business operation, the trail's write availability becomes the operation's availability, and a long business transaction holds its share of the trail's locks.
  • After commit, in the same process. The business operation never fails for an audit reason. It costs the guarantee: a crash between the commit and the write leaves a change with no entry, and "no entry" no longer means "no change" — which is precisely what a reader assumes it means.
  • Transactional outbox. The intent to record is written atomically with the change and delivered asynchronously. Keeps the guarantee and takes the write off the critical path, at the cost of another moving part and of entries that are eventually, rather than immediately, visible.

A project states which it chose. A trail whose write semantics are undocumented cannot be reasoned about at all, because every conclusion a reader draws from an absent entry depends on this answer.

Whether a failed audit write aborts the business operation follows from the choice above, but is decided separately.

  • Fail closed — the operation rolls back with the audit write, so an unaudited change cannot exist. The trail becomes a hard dependency of every write path in the system, which is a real operational commitment and not a free one.
  • Fail open — the failure is logged and the operation proceeds. Nothing stops working when the trail does, and the price is a silent gap, arriving exactly during the incident someone will later want to reconstruct.

Whichever is chosen, the failure is monitored, and the project says which it chose where its readers will see it. Fail-open with no alerting is the one combination with no defensible reading: it produces gaps nobody knows about.

Concurrency needs no special handling: entries are insert-only and never updated, so there is no activation-style race to guard against. Each writing feature documents its own transactional policy for its own change; this concept states only the audit side of the guarantee.

The choice may legitimately differ by class of entry, and a project says so. A change entry records that a row was mutated, and rides the transaction that mutated it. An event entry records that a step happened — a processing stage ran, a retry was scheduled — where there is no business row write to ride and the insert is the fact. Holding the two to one rule either forces artificial transactions around events or weakens the guarantee on changes. What a reader may infer from "no entry" differs between the two classes, which is why the split has to be written down.

Where the write port accepts an optional transaction, the guarantee lives at every call site, not in the port. A writer that omits the transaction has silently chosen after-commit semantics for that one entry, and nothing in the signature objects. The control is a test or a lint over the writers — every change entry passes its transaction — not the shape of the interface.

API Analysis (API-A) ​

See child page: Audit Log — API Analysis (API-A).

Domain Model ​

  • The trail entity — one row per recorded event, carrying: the reference to the audited record (type + id), the optional subject reference, what happened, the context payload, the actor, the event time, the scope, and the action identifier. Plus whatever id and audit columns the project's conventions give every entity.
  • A shared vocabulary of event types, extended by every feature that writes entries.
  • A shared vocabulary of entity types, which doubles as the roster of what the system audits — and therefore as the place an exemption is recorded and counted. Where that vocabulary lives is a decision. A closed enumeration in code keeps the roster where the writers are: completeness is checkable at build time, at the price of coupling the audit core to every consuming feature. An open string with a documented roster keeps the core generic — it owns no business entities — at the price that the roster is a claim until something compares it with the writers. Under the second option the roster carries, per value, the ref at which it was verified to be written; without that the coverage measurement under Auditing, Reporting & Measurement does not exist.

Logging ​

.info

  • an entry was written (entity type, record id, event, actor).

.error

  • an entry could not be written. What follows depends on the fail-closed / fail-open decision above, and the log line is the only trace of the failure under fail-open.

Monitoring ​

Repeated write failures deserve an alert. Under fail-closed they surface as elevated error rates on the writing features rather than as an audit-specific signal, so a cross-feature view is what pinpoints the trail itself as the common cause. Under fail-open nothing surfaces at all without one.

Caching ​

None. Reads are index-backed and paginated, writes are single inserts, and a cached view of an append-only trail would answer questions about a moment that has passed.

Indexing ​

Every filter the read surface offers is backed by an index, and the two-references limit under Filing an entry is a direct consequence of that requirement rather than an independent rule. Where the trail carries a redaction marker, the read-path indexes are partial over the rows that remain. A consuming feature that filters on a field inside the context payload adds its own index for that field.

Where a privileged cross-scope read exists, the scoped indexes do not serve it: an index that leads with the scope column cannot be used by a query that never narrows on the scope, and the cross-scope browser then degrades to a full scan and sort of the largest table in the system. That read needs its own index set, leading with the filter columns it actually uses and carrying the sort order — one index per filter, mirroring the scoped set — from the first release, not after the table is large.


An audit trail is often required by something outside the system — a regulation, a certification, a contract — and such a requirement usually dictates retention, access and evidential qualities. What binds a given project depends on jurisdiction, sector and contract, so these are questions to settle:

  • Which requirement is this trail meeting? "None, and it exists for our own operational reasons" is a complete answer and a materially different one from a blank.
  • Does that requirement fix a retention period? If it does, it and not the growth of the table sets the horizon under Non-Functional Requirements — and it may set a floor rather than a ceiling.
  • Does anything oblige the trail to be tamper-evident, rather than append-only by construction? Those are different controls, and only the second is in this design.
  • Does an erasure obligation reach the trail? A trail that must be erasable is not an append-only trail; the redaction path exists because those two requirements collide.

Cybersecurity Considerations ​

The trail is read-only over its API, so its attack surface is the read permission gates plus the internal write path used only by the system's own code.

Data Privacy: the context payload and the actor identifier may reference personal data, directly or by implication, so the same scope isolation and permission-gated access that protects the rest of the system's data applies here. Writers never place secrets or raw credentials in the payload. The append-only design also means personal data referenced by an entry cannot be corrected in place: only the technical redaction path removes an entry, and only where removal is legally mandated. Whether a redaction is itself recorded — an erasure in a trail that has no trail of its own — is a question a project answers explicitly rather than discovering later.


Risk Assessment ​

Business Risks ​

Small and indirect: the trail is a governance capability, not a revenue path. The exposure is that incomplete entries undermine investigation, dispute resolution and compliance reporting — and that readers, trusting the trail, act on what it appears to say. This is the risk the write-path decision above either mitigates or accepts.

Technical Risks ​

  • Unbounded growth — one table receives every entity's changes without exemption. Mitigations: pagination everywhere, indexes that keep reads fast regardless of size, the retention rule treated as a design requirement, and a partitioning decision taken before the table is large rather than after.
  • Dangling references — entries outlive the records they describe, by design. Readers tolerate an entry whose record no longer resolves; a surface that assumes resolution will fail on exactly the deletions the trail exists to explain.

Auditing, Reporting & Measurement ​

The trail has no trail. That is not a defect to fix so much as a regress to terminate deliberately, and there are three terminations:

  • Nothing. Stronger than it sounds, because the concept leaves the trail with no update path and no public write API: the only mutation that exists at all is redaction. A project choosing this is asserting that the absence of a mutation path is the control, which means the absence has to be real — enforced in storage, not in a service method.
  • Meta-entries in the trail itself. Cheap, and visible to the same readers as everything else. The problem is specific to redaction: an entry recording what was removed can reintroduce the data the removal was for, so it has to be able to say that an entry was redacted without saying what it said.
  • An out-of-band record — an operational log, a change ticket, a store the application cannot write to. Keeps the reintroduction problem out of the trail, and moves the record somewhere the trail's own readers cannot reach, so "has this trail been touched" stops being answerable from inside the system.

Whichever is chosen, redaction remains the one change to the system's data that the system's own recording mechanism does not cover: an append-only guarantee with a hole in it, and nothing that reports on the hole.

Whether reads are recorded is a separate decision from whether writes are. Reading a trail is itself a sensitive act, and in some settings the more sensitive one. Recording reads in the trail inflates the table that already grows fastest in the system, with entries nobody filters for; recording them elsewhere splits the history. Most projects record nothing and never say so, which leaves a reader unable to tell a decision from an oversight.

Coverage is the one measurement this concept makes possible and no other does. The vocabulary of entity types doubles as the roster of what the system audits, so coverage is computable: entity types present in the data model, less those on the roster, less the enumerated exemptions. That number is the answer to "is anything missing?", which is the reason the trail is shared rather than per-feature. Nothing computes it automatically, and until something does the exemption list is a claim about the system rather than a description of it.

Reporting is browsing, on both surfaces, and for most projects that is the whole story. Anything aggregate is a separate build, and where it is built matters: against the trail it competes with the write path and needs indexes no other reader wants; against a copy it costs a copy and a lag. "Browsing only" is a complete answer; saying nothing is not.

Signals worth watching, each of which the trail is the only source for: the rate of failed writes, which under fail-open is the only trace that entries are missing; growth against the retention horizon; the share of entries carrying no action identifier, since a rising share means decisions are moving into background paths where they are harder to attribute; and the ratio of machine- to human-attributed entries, which decides whether the feed-visibility rules still leave a readable feed.

The audit read permission is one of the highest-value grants in the system — it returns every entity's history at once. Whether granting it is itself audited is the permission capability's question, not this one's, and it is the question that closes the loop.

Known instances ​

  • Porsenna EM3 — Audit Log (EM3-346): investigation surface only; per-record history not yet built.