Auditing Who Changed a Flag and When

This how-to is part of RBAC and Flag Change Governance. A flag flipped at 3 a.m. and an incident followed — and the first question in the retro is “who changed it, and why?” This how-to builds the change record that answers it: every toggle captures actor, before and after value, reason, approvers, and environment, in a form you can query in seconds and hand to an audit trail.

The distinction that matters is between a log and a record. A log is a stream of events your systems happen to emit — verbose, best-effort, and often thrown away after a fortnight. A record is a deliberate, structured artifact you commit as part of the change itself, with a schema you control and a guarantee it cannot be quietly edited. When the pressure is on during an incident and later in front of an auditor, only the record holds up. The rest of this guide treats the change record as a first-class output of every flag write — not a side effect you scrape back together afterward from application logs, chat history, and half-remembered Slack threads.

Anatomy of a change record Each flag change captures the actor, the before and after value, the reason, the approvers, the environment, and a timestamp — the six fields that answer who, what, when, and why. ops.payments.new-processor : false → true actor a.rivera reason GA after canary approver s.okafor env + time prod · 14:03Z who · what · when · why · approved-by — in one record
The six fields together answer every question a retro or an auditor asks — which is why the record captures them at change time, not reconstructs them later.

Prerequisites

Change-record prerequisites Authenticated identities, append-only storage, the previous value for a diff, and a query interface. Identities every path Append-only store immutable Previous value for the diff Query interface retros + audits
Append-only storage is what separates an audit record from an editable log — a record someone can quietly rewrite proves nothing.

Step-by-Step Procedure

Step 1 — Capture the record at the moment of change

Write the full record synchronously with the change, so the write and its provenance are inseparable. Include the previous value read just before applying the new one. The reason you capture at change time rather than reconstruct later is that context evaporates fast — the actor knows exactly why they are flipping the flag in the second they do it, and knows almost nothing about it a week later when someone asks. Capturing the source matters just as much as the actor: the same person acting through the console, a scripted API call, and a CI pipeline are three different provenance stories, and an auditor will care which one it was. Timestamp in UTC with a timezone designator (the trailing Z) so records from services in different regions sort into a single, unambiguous order — a naive local timestamp turns a cross-region incident timeline into a guessing game.

# record.py — build the record from the change context
def record_change(flag, old, new, actor, reason, approvers, env):
    return {
        "flag": flag, "from": old, "to": new,
        "actor": actor, "reason": reason, "approvers": approvers,
        "env": env, "ts": current_iso8601(), "source": current_change_source(),
    }

Step 2 — Persist it append-only alongside the change

Commit the record to append-only storage in the same transaction as the flag write so a change can never exist without its record. The ordering inside the transaction is deliberate: read the old value first, apply the write, then append the record built from both — if any step throws, the whole unit rolls back and neither the flag nor the record moves. When your flag store and your audit store are genuinely separate systems and a single transaction is impossible, fall back to a write-ahead pattern: record the intent first with a pending status, apply the change, then mark the record committed, and treat any record stuck in pending as a reconciliation alarm rather than a silent gap. Append-only does not mean append-once-and-forget — pick storage that rejects updates and deletes at the engine level (an object-lock bucket, an immutable ledger table, or a WORM-configured store) so immutability survives a compromised application credential, not just well-behaved code.

# persist.py — change and record are one atomic unit
def apply_and_audit(change):
    with transaction():
        old = read_flag(change.flag)
        write_flag(change)
        append_audit(record_change(change.flag, old, change.to,
                     change.actor, change.reason, change.approvers, change.env))

Step 3 — Make it queryable by flag, actor, and time

Index the records so “every change to this flag,” “everything this actor did,” and “all changes in this window” are fast queries — the three questions a retro actually asks. In practice that means a composite index on (flag, ts) for the per-flag timeline, one on (actor, ts) for the per-actor sweep, and a plain index on ts for the window scan; without them, the query you run under incident pressure does a full table scan across months of history at exactly the moment you need it in under a second. Match the flag LIKE 'ops.payments.%' prefix pattern to your key namespace convention so a single query pulls every flag owned by a service, not just the one you already suspected — the flag that actually caused the incident is often a sibling of the one you went looking for.

-- who changed ops.payments.* in the hour before the incident?
SELECT ts, flag, actor, "from", "to", reason, approvers
FROM flag_change_audit
WHERE flag LIKE 'ops.payments.%'
  AND ts BETWEEN '2026-07-22T13:00:00Z' AND '2026-07-22T14:10:00Z'
ORDER BY ts;

Step 4 — Surface the record in the incident flow

Link the change record into your incident tooling so the on-call sees “recent flag changes” without leaving the incident channel. The value here is measured in minutes of mean-time-to-resolution: a responder who can see that ops.payments.new-processor flipped four minutes before error rates climbed has a prime suspect immediately, instead of paging three teams to ask “did anyone change anything?” Post the changes automatically when an incident opens rather than making someone remember to run the query — the moments you most need the change history are exactly the moments no one has spare attention to go fetch it. Scope the automatic post to the services the incident touches so the channel shows the two relevant changes, not forty unrelated ones that bury the signal.

# recent-changes.sh — post recent flag changes into the incident channel
flagctl audit query --namespace "ops.payments.*" --since 1h --json \
  | jq -r '.[] | "\(.ts) \(.actor) \(.flag): \(.from)->\(.to) (\(.reason))"'
The four audit steps Capture the record at change time, persist it append-only in the same transaction, make it queryable by flag actor and time, then surface it in the incident flow. 1 Capture at change time 2 Persist append-only 3 Queryable flag/actor/time 4 Surface in incidents
Capturing at change time (step 1) is what makes the record trustworthy; surfacing it in incidents (step 4) is what makes it useful under pressure.

Verification

Assert that a change produces a complete record and that the record cannot be altered after the fact. Two properties are worth testing explicitly and neither is obvious from a happy-path check. First, completeness: every one of the six fields is present and non-empty, because a record with a null actor or a blank reason passes a naive “row exists” assertion while failing the only question it needs to answer. Second, immutability at the store level, not the application level — the test below tries to rewrite a committed record and asserts the store itself rejects it, which is the guarantee an auditor actually trusts. Add a third test for the atomicity of failure: inject a fault into the record write and assert the flag write rolls back too, so you have proof the two never drift apart.

# audit.test.py
def test_change_is_recorded_and_immutable():
    apply_and_audit(change("web.checkout.x", to=True, actor="alice", reason="GA"))
    rec = latest_audit("web.checkout.x")
    assert rec["actor"] == "alice" and rec["from"] is False and rec["to"] is True
    with raises(ImmutableStore):
        rec["actor"] = "bob"; persist(rec)      # append-only rejects the rewrite
Complete and immutable A change writes a complete record with all six fields, and an attempt to rewrite the record is rejected by the append-only store. record complete six fields present rewrite rejected append-only holds
Both properties are the point: a complete record answers the questions, and immutability is what makes the answer trustworthy in an audit.

Gotchas & Edge Cases

Three change-record edge cases A change that succeeds while its record write fails, automated changes with no human actor, and reasons that are empty or meaningless. Record write fails change without record make it atomic Automated actor record the system and its trigger Empty reasons require and validate on high-risk flags
The atomic-write case is foundational — if a change can succeed while its record fails, you have gaps in exactly the history you will need most.

Troubleshooting & FAQ

The record shows who but the reason is useless. How do we get better reasons?

Reasons improve when they are required at the point of change on the flags that matter and when they are read in retros. Link the reason field to a change ticket or incident ID so it points at context rather than restating the obvious. Reserve the requirement for high-risk flags so it does not become friction people route around.

How long should we retain flag change records?

At least as long as your compliance regime requires, and long enough to cover a realistic retro window — a change that caused a slow-burn issue may not surface for weeks. The audit trail guide covers retention policy; align flag change records with it.

Can I reconstruct history from the flag system’s built-in log instead?

Sometimes, but vendor logs vary in completeness and retention and may not capture reason or approver. Owning your own change record decouples your audit posture from the vendor’s log format and lets you enforce the fields you need, including the reason and approvers your governance requires.

Should the change record store the full flag config or just the toggled value?

Store enough of the before and after state to reconstruct what actually changed, which for a simple boolean is just the two values but for a targeting rule or rollout percentage is the relevant slice of config. Recording only “changed” without the diff means the retro can see that someone touched the flag but not what they did — and for a rule change, the difference between 5% and 50% rollout is the whole story. Keep the diff scoped to what changed rather than snapshotting the entire config on every write, so records stay small and the meaningful delta is not buried.

How do we audit flag changes that happen through infrastructure-as-code or GitOps?

Treat the merge as the change and the commit as the record’s backbone: the author, reviewers, and commit message already answer who, who-approved, and why, and the deploy that applies the manifest supplies when and to which environment. The gap to close is joining the Git provenance to the runtime apply, so link the record to both the commit SHA and the deploy run — otherwise you can prove the config was authored but not that it actually reached production, or when.

What if the same actor makes many rapid changes during an incident?

Record every one of them individually — rapid changes during firefighting are exactly the history a retro most needs, and deduplicating or rate-limiting the records to save space throws away the sequence that explains what happened. A shared incident correlation ID on each record lets you pull the whole burst back as one story while keeping each step’s before/after value intact, so you can see the order in which mitigations were tried and which one actually moved the metric.