ADR-0001: Audit Logging System
Accepted KendoDate: 2026-02-11 | Last Revised: 2026-05-01
Compliance: ISO 27001 (A.8.15 Logging, A.5.33 Protection of Records, A.8.5 Secure Authentication)
Context
The Kendo operates under ISO 27001 certification. Before this decision, no audit trail existed for data mutations. An auditor couldn't answer:
- Who modified or deleted a record
- What the previous state was before a change
- When and from where the action was performed
- Whether the actor's role at the time justified the operation
The existing package owen-it/laravel-auditing was evaluated and found insufficient for strict compliance requirements — it doesn't support point-in-time user snapshots or hash chaining for tamper detection.
Decision
We built an independent, append-only audit logging system with these properties:
Per-Entity Audit Tables
Each auditable model gets its own dedicated audit table (e.g., issue_audit_logs). This replaces a polymorphic single-table design.
Why per-entity tables?
- Writes to different entity types don't contend with each other
- No polymorphic columns — cleaner indexing and simpler queries
- Sensitive field exclusion is per-entity by nature
- Retention policies can differ per entity type
- Hash chains are strongest when scoped to a single table
Trade-off: Cross-entity queries ("show me everything user X did today") require UNIONs across audit tables. Entity-specific queries are the 90% case for compliance; cross-entity aggregation can be built as a read-only view if needed.
Hash Chains for Tamper Detection
Each audit table maintains a sequential hash chain — scoped per-table, not per-record and not global. Every new record's hash includes the previous record's hash, creating an unbroken chain. If any record is modified or deleted, the chain breaks and the tampering is detectable.
- The first record in each table uses a configurable seed value as its "previous hash"
- All writes to an entity's audit table serialize against each other (via locking on the last record)
- Concurrent writes to different entity types (Issue vs Project) don't contend at all
- Verification: iterate chronologically, recompute each hash, compare against stored hash — any mismatch indicates tampering from that point onward
record.hash = SHA-256(
previous_record.hash + "|" +
record.actor_type + "|" +
record.user_id + "|" +
record.action + "|" +
record.entity_id + "|" +
record.old_values + "|" +
record.new_values + "|" +
record.created_at
)Action-Level Logging (Explicit)
Audit logging is triggered explicitly in Actions — not via model observers or traits. The Action is the authority for mutations, so it's the authority for logging those mutations.
This is consistent with our Explicit Over Implicit principle. Architecture tests enforce that every Action touching an auditable model calls the audit logger.
Point-in-Time User Snapshots
The audit record captures the actor's identity at the moment of the action. User name, email, and role are stored directly on the audit record — not resolved via foreign key join at query time.
This ensures the audit trail remains accurate even if the user's profile changes after the fact.
Explicit Actor Passing
Every audited Action receives the actor explicitly as a parameter — no ambient context, no global state. Controllers build the actor and request context and pass them to the Action.
Actor Types
Actors are identified by an ActorType enum stored as an integer:
| Value | Type | Description |
|---|---|---|
| 0 | User | Human user performing an action |
| 1 | Scheduler | Automated scheduled task |
| 2 | Cli | Command-line operation |
| 3 | GitHubWebhook | GitHub webhook trigger |
When actor_type = User, snapshot fields (name, email, role) are required. When it's a system actor, user_id must be null. This is enforced at two layers:
- Code-level — the audit logger service validates before writing
- Database-level — MySQL CHECK constraint as safety net:
CONSTRAINT chk_actor_integrity CHECK (
(actor_type = 0 AND user_id IS NOT NULL AND user_name IS NOT NULL
AND user_email IS NOT NULL AND user_role IS NOT NULL)
OR
(actor_type != 0 AND user_id IS NULL)
)Schema
-- Example: issue_audit_logs (pattern repeats per auditable entity)
id BIGINT UNSIGNED -- Auto-increment primary key
actor_type TINYINT UNSIGNED -- ActorType enum
user_id BIGINT UNSIGNED -- FK to users (nullable for system actors)
user_name VARCHAR -- Snapshot: actor name at time of action
user_email VARCHAR -- Snapshot: actor email at time of action
user_role VARCHAR -- Snapshot: actor role at time of action
action TINYINT UNSIGNED -- AuditAction enum (Created/Updated/Deleted/Restored)
issue_id BIGINT UNSIGNED -- The entity this audit belongs to (NO FK)
old_values JSON -- State before mutation (NULL on create)
new_values JSON -- State after mutation (NULL on delete)
ip_address VARCHAR -- Request origin IP
user_agent VARCHAR -- Client identification
url VARCHAR -- Request method + path
hash CHAR(64) -- SHA-256 hash for tamper detection
created_at TIMESTAMP -- Immutable (no updated_at column)Key design choices:
issue_idhas no FK constraint — the audit log must survive deletion of the entity it tracksuser_idhas an FK constraint — users are soft-deleted (per ADR-0002), so the referenced row always exists- Records are append-only — no
UPDATE, noDELETE, noSoftDeletes, noupdated_at - Sensitive field exclusion — passwords, tokens, API keys, and secrets are stripped from
old_values/new_valuesbefore storage. The Issue entity has no sensitive fields, but the pattern is established for future entities
Request Context
A RequestContext DTO captures IP address, user agent, and request URL. Built by the controller from the HTTP request and passed explicitly to the Action.
- HTTP requests:
RequestContext::fromRequest($request) - Queue workers / CLI:
RequestContext::system()— all fields null - The audit logger receives request context as a parameter, never pulls from global state
Integration with Cascade Deletion (ADR-0002)
When an auditable entity is deleted:
- Transaction opens
- Child records are hard-deleted explicitly by the Action
- Entity is hard-deleted
- Audit log records the deletion with full state snapshot in
old_values - Transaction commits
The issue_id in the audit log remains queryable even after the entity row is gone (no FK constraint).
Snapshot-on-Retry Safety
The audit-chain lockForUpdate() is the deadlock-prone surface — concurrent writers against the same per-entity table entangle InnoDB gap locks and surface as SQLSTATE 40001. Audit-writing Actions wrap their outer transaction with $attempts >= 2 to retry the closure, which preserves hash-chain integrity (the retry re-reads previousHash from the winning transaction's committed row).
Retry alone is insufficient. The closure also reads model state to build old_values / new_values snapshots. Without explicit defense, the second attempt corrupts those snapshots:
- Attempt 1: snapshot reads correct DB state. Mutations applied.
Model::save()flushes to DB and runssyncOriginal()—$attributesand$originalboth now hold the post-mutation values. The auditlockForUpdate()deadlocks → outer transaction rolls back → DB reverts, but the PHP model retains the post-mutation state in both arrays. - Attempt 2: snapshot reads
$attributes— the mutated state, not the rolled-back DB state. Mutations re-apply (no diff since$datais stable).save()short-circuits becauseisDirty()returns false — no UPDATE fires. The audit row writesold=mutated, new=mutatedfor what was actually a no-change transition; the DB stays at its pre-attempt state. Audit log diverges from DB.
For delete-shape Actions the failure mode is worse: attempt 1's delete() flips $exists = false. On retry, Model::delete() short-circuits but logDeleted() still fires — the DB row survives, the audit log claims deletion.
The doctrine:
- Update Actions: the first statement of every audited transaction closure is
$model->refresh()— resets both$attributesand$originalto current DB values, making retry'sisDirty()accurate andsave()fire real SQL. - Delete Actions:
refresh()short-circuits when$exists = false, so it does not work for the delete shape. Inject the model class as a constructor factory and re-fetch inside the closure:$model = $this->modelFactory->newQuery()->findOrFail($modelId). Capture the key outside the transaction, the closure's first statement is the fresh fetch. - Create Actions: instantiate the new model inside the closure body —
$model = $this->modelFactory->newInstance()as the first statement, then mutate, then save, thenlogCreated(). Each retry gets a fresh$exists=falseinstance. Ifnew Modellives in the outerexecute()scope and is captured into the closure viause($model), attempt 1'ssave()will set$exists=trueand assign an auto-increment ID; on retry,save()would attempt a vacuous UPDATE on the rolled-back row, thenlogCreated()would fire with the wrong shape. - Audit-first-order on deletes is fragile, not canonical. Calling
logDeleted()BEFOREdelete()inside a single transaction closure is plausibly safe — if the audit chain'slockForUpdate()deadlocks before the audit row inserts, both the audit and the (not-yet-executed) delete roll back together; on retry$existsis still true and the closure proceeds cleanly. But the safety is conditional: there must be zero mutation of the audited model betweenlogDeleted()anddelete(). Cascade work (relationdetach(), child-recordupdate()) on the path between them moves the model's effective state out from under the snapshot, and the resulting audit row no longer matches the post-retry DB. This subclause is not statically verifiable, so the canonical fix shape for deletes is fresh-fetch-inside; audit-first order is an acceptable shape only on Actions where the General has manually verified the no-intermediate-mutation property and CI cannot enforce it. - Counter-example exemption (canonical for nested or branched fetches): Actions that already fetch the audited model fresh inside the closure via a parent relation (e.g.,
$child = $parent->newQuery()->whereBelongsTo($document)->first()) are structurally immune by the same mechanism as the explicitfindOrFailshape. Mark with an@audit-snapshot-retry-safetyinline comment so the architecture test recognises the precedent.
Enforcement: an architecture test inspects every Action that injects an *AuditLogger and calls connection->transaction(...). The closure's first non-trivial statement must match one of three compliant shapes:
- Shape (a):
$model->refresh()— update Actions. - Shape (b):
$model = $this->{factory}->newQuery()->find*(...)— delete Actions (and any other site where the audited model needs DB-fresh state, including pre-mutation reads). - Shape (c):
$model = new SomeClass[(...)];— create-shape Actions that instantiate the audited model literally inside the closure body. Retry-safe by construction (the closure-bound variable is reassigned on every attempt, no leak from attempt 1).
Sites whose closure shape genuinely requires a different first statement may opt out via an @audit-snapshot-retry-safety: <rationale> inline comment on the line immediately preceding the transaction call. Recognised exemption rationales:
- Precondition guards — e.g.
PairKiosk(entreezuil) — closure first statement is a guard (if (...) throw) before the audited entity is fetched/instantiated; the audited entity is acquired on the line immediately after the guard. - Audit-first-order deletes — e.g.
DeleteUser,DeleteBranch,RevokeKiosk(entreezuil) —logDeleted()is called BEFORE$model->delete()and the audited model is neither read nor mutated between the two calls. Deadlock-first reasoning: any deadlock duringlogDeleted()'slockForUpdate()rolls back beforedelete()runs; on retry the closure proceeds cleanly. Doctrinally fragile (the no-intermediate-mutation property is not statically verifiable) — canonical fix shape for new code is fresh-fetch-inside. - Fixed-action writers — e.g.
RepostInvoice(ublgenie) — Action calls a logger method that writes a fixed-action audit row witholdValues = newValues = null(no entity snapshot is captured from$modelat all). The corruption shape does not apply because there is no in-memory state to corrupt.
AST traversal via nikic/php-parser — same harness as EnforceActionTransactionsRule. The test must reject "snapshot-before-tx" as an immunity claim — pre-transaction snapshot capture leaves the no-op-save trap intact and produces audit rows that record transitions the DB never made. Promotion candidate for phpstan-warroom-rules Phase 2.
Cross-territory precedents: emmie PR #187 (canonical AST harness, KNOWN_RETRY_SAFETY_GAPS const for tracked-not-fixed sites). Entreezuil port (2026-05-01) introduced shape (c) for create-shape compliance — promote across all consuming territories on next port wave so the canonical pattern stays unified.
Reconnaissance Findings
Reconnaissance identified 13 Actions that directly mutate Issue records. Only the direct CRUD trio (CreateIssueAction, UpdateIssueAction, DeleteIssueAction) is in scope for the proof of concept.
Key finding: 10 of 12 indirect Issue-mutating Actions lack actor context. The calling controllers have the authenticated user but don't pass it to the Action. As indirect mutations are instrumented, each Action will need refactoring to accept explicit actor context.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
owen-it/laravel-auditing package | Rejected | No point-in-time snapshots, no hash chaining, insufficient for strict compliance |
| Model-level observers/traits | Rejected | Implicit — logging happens behind the scenes. Query-level operations bypass model events silently. Conflicts with explicit-over-implicit principle |
| Action-level explicit logging | Accepted | Consistent with existing architecture. Full control. Captures user context from parameters, not global state |
| Single polymorphic audit table | Rejected | Hash chaining requires serialized writes; single table becomes bottleneck under concurrent mutations |
| Per-record hash chains | Rejected | If all audit records for a single entity are deleted, no chain exists to detect the gap |
| Request-scoped ambient context | Rejected | More pragmatic (no Action refactoring), but conflicts with explicit-over-implicit doctrine |
Consequences
Positive
- ISO 27001 compliant audit trail for high-value entities
- Tamper-detectable via hash chaining
- Point-in-time actor snapshots eliminate "who was this person then?" ambiguity
- Per-entity tables provide concurrency isolation
- Full request context captured for forensic analysis
- System actors distinguished by type — not a generic "system"
- Pattern extends to other models as data classification is applied
Negative
- Every Action touching an auditable model must explicitly log (maintenance burden)
- Actions must accept actor context explicitly — refactoring required
- Each auditable entity requires its own audit table, model, and logger service
- Audit tables grow indefinitely (no deletion allowed) — requires retention strategy in the future
Risks
- If an Action forgets to log, there's a gap until the architecture test catches it at CI time
- Hash chain seed value must be stored securely — if lost, chain cannot be verified from genesis
- Large JSON blobs in
old_values/new_valuescould impact storage for frequently-updated entities
Scope & Progress
An information classification matrix identified 14 models requiring audit logging, totaling 13 new audit tables.
Tier 1 (completed): Issue CRUD trio (PoC, PR #321) + User, AppSetting, GithubConnection, TimeLog (PRs #357-360). AuditLogWriter + AuditLogger interface consolidated (PR #373).
Tier 2 (scoped, not started): 10 remaining entities from the classification matrix.
Implementation
| Territory | State | Notes |
|---|---|---|
| kendo | Partial | Tier 1 complete (Issue CRUD + User, AppSetting, GithubConnection, TimeLog — PRs #321, #357-360, #373). Tier 2 (10 remaining entities) scoped but not started. Next targets: 2FA operations. |
| entreezuil | Complete | 4 audit tables (user, company, auth events, SMS events). All 6 entity CRUD Actions, 6 auth event types, SMS sends audited. Hash chains, actor snapshots, RequestContext, architecture tests. PRs #17-#19. |
| emmie | Partial | Phase 1 + 1.5 + 1.6 deployed (PR #184 / EMMIE-0239) — client_audit_logs infrastructure + ClientAuditLogger + 10 architecture tests (1.5 added StoreCalendlyIntroductoryMeetingAction to roster, 9 → 10) + 4 int-backed enums (ActorType, ActorSource emmie-specific, AuditAction, AuthEventType). Single-entity owen-it retirement on Client. Calendly system-actor mutation now writes client_audit_logs rows with actor_type=System. Phase 1.6: AuditLogWriter::assertWithinTransaction() couples to $model->getConnection()->transactionLevel() (not the DB facade default-connection) — defensive correctness in central+tenant splits. Cross-territory port to kendo/ublgenie/entreezuil queued (latent-risk-only; emmie diff is the template). Emmie-specific divergences: actor_source column (3 JWT actor sources across 2 DB tiers — User=0/Admin=1/Client=2), no FK on user_id (cross-DB asymmetry to central admins table; tamper resistance via hash chain + snapshot only), ?Authenticatable writer parameter (not ?User), per-source snapshot extractor. Phase 2 (9 remaining Tier 1 entities + Contact::createAudit re-implementation via relation-shaped logUpdated + ClientAuditLogResource for frontend audit-history UI), Phase 3 (system-actor hooks + AuthEventLogger surface), Phase δ (refinements + ClientAccessLog/ExportLog folding), Retirement (owen-it composer dep removal) outstanding. ~21 Engineer-days remain in Surveyor envelope. |