Designing Approval Workflows for Flag Changes

This how-to is part of RBAC and Flag Change Governance. You want a second pair of eyes on the flag that gates payments, but you do not want every cosmetic toggle stuck behind a review queue. This how-to designs a risk-based approval workflow: high-risk changes require a second approver enforced server-side, low-risk changes flow freely, and a logged break-glass path exists for genuine emergencies.

The failure mode you are designing against is not malice — it is the confident, fast-moving engineer who flips ops.checkout.enable-new-tax-engine to 100% because it looked fine in staging, and takes the checkout page down for twenty minutes. A single-actor mistake on a flag that touches money, auth, or data integrity is the exact class of change where a second reader pays for itself. The trick is scoping that second reader so narrowly that people forget it exists until the moment it matters, rather than letting it metastasize into a queue that gates every string tweak. Get the scope right and approval feels like a seatbelt; get it wrong and it feels like a turnstile, and turnstiles get vaulted.

Risk-based approval routing A change is routed by flag risk: low-risk applies immediately, high-risk waits for a second approver, and an emergency break-glass path applies immediately but generates a loud record. change + risk low risk apply now high risk wait for approver break-glass apply + loud record
Risk decides the path: routine changes flow, dangerous changes wait for review, and emergencies proceed loudly rather than being blocked.

Prerequisites

Approval prerequisites Risk-classified flags, server-side enforcement, distinct actor and approver identities, and a notification channel. Risk classes gate the right flags Server enforcement not UI-only Distinct identities actor ≠ approver
Distinct actor and approver identities are the non-negotiable — an approval where the requester can approve themselves is no approval at all.

Step-by-Step Procedure

Step 1 — Gate only the flags that warrant it

Read the required approval count from the flag’s risk class so low-risk toggles need zero approvals and only high-risk flags queue for review. The risk class is metadata that lives with the flag definition, not a judgment the requester makes at change time — otherwise the person in a hurry simply declares their own change low-risk. Classify flags once, when they are created, and treat the class as an attribute the workflow reads rather than an input it accepts.

# gate.py — approvals required scale with risk
APPROVALS_BY_RISK = {"low": 0, "medium": 1, "high": 2}

def approvals_required(flag_meta: dict) -> int:
    return APPROVALS_BY_RISK[flag_meta["risk"]]

A reasonable default is to reserve high for flags that gate payments, authentication, personally identifiable data, or anything that writes to a system of record, and to leave everything cosmetic or purely additive at low. Aim to keep the high-risk set small — a good target is under one flag in ten. If a large fraction of your flags land in high, the classification has drifted and the gate will start feeling like a tax on everything, which is precisely the condition that breeds rubber-stamping. When a flag’s blast radius changes — say a UI toggle grows into a kill switch for a payment provider — reclassify it deliberately, and treat that reclassification as itself a governed change.

Step 2 — Enforce distinct approver identity

Reject an approval from the person who requested the change so a single account cannot satisfy both roles. This is the core of four-eyes review — everything else is plumbing around this one check.

# approve.py — the requester cannot approve their own change
def add_approval(change, approver_id):
    if approver_id == change.actor_id:
        raise Denied("the requester cannot approve their own change")
    if approver_id in change.approvers:
        raise Denied("duplicate approval from the same person")
    change.approvers.append(approver_id)

Two subtleties bite here. First, “distinct” must mean distinct human, not distinct session — if one engineer holds two accounts, or a service account is shared, the identity check passes while four-eyes quietly fails. Bind approver identity to the same SSO subject you use elsewhere, and forbid service accounts from ever counting as approvers. Second, decide up front whether the approver must also be someone who could not have colluded trivially: for the highest-risk flags, some teams require the second approver to sit outside the requester’s immediate team, so the review is a genuine outside read rather than a favor between deskmates. That is a policy dial, not a code change — keep it as configuration so you can tighten it for a handful of flags without rewriting the enforcement path.

Step 3 — Enforce the gate where the change is applied

Check the approval count at the moment the change is written, not merely when it is proposed, so an API call cannot skip the queue. This placement is the whole ballgame. If the gate lives in the console UI — a disabled “Apply” button until two approvals land — then anyone with an API token or a curl command walks straight around it. Enforcement has to sit at the same chokepoint every write passes through: the service that persists flag state, or the admission layer in front of it. A UI check is a convenience for humans, never the control.

# apply.py — final gate at write time
def apply_change(change, flag_meta):
    need = approvals_required(flag_meta)
    if len(change.approvers) < need:
        raise Denied(f"{change.flag}: {len(change.approvers)}/{need} approvals")
    write_flag(change)   # only reached when the gate passes

Re-reading the risk class and re-counting approvals at write time also closes a subtle race: the flag might have been reclassified from medium to high between proposal and apply, or an approver might have withdrawn. Evaluating approvals_required against the current flag_meta rather than a value cached at proposal time means the change is always judged by the rules in force at the instant it takes effect. If your flag store is eventually consistent, do this check inside the same transaction or compare-and-swap that writes the flag, so two approvals racing two writes cannot both slip through.

Step 4 — Provide a logged break-glass path

Allow one authorized actor to bypass the wait in an emergency, but require a reason and emit a high-visibility record so the bypass is reviewed after the fact. The design goal is asymmetry: bypassing must be easy — one command, no waiting for a sleeping approver at 3 a.m. — but never quiet. A break-glass event that does not page anyone or post to the incident channel is indistinguishable from having no gate at all, because the only thing keeping it exceptional is the certainty of being seen.

# breakglass.py — fast path with a loud audit signal
def emergency_apply(change, actor):
    require_role(actor, "incident-responder")
    if not change.reason:
        raise Denied("break-glass requires a reason")
    write_flag(change)
    emit_high_priority_audit("BREAK_GLASS", change, actor)   # paged review afterward

Keep the break-glass role separate from the everyday approver role and grant it narrowly — the on-call rotation, not the whole engineering org. That separation means using the fast path is a conscious act with a named holder, and it keeps your audit trail able to answer “who can bypass” as cleanly as “who did.” Some teams add a time bound: a break-glass write auto-expires or auto-reverts after a set window unless a normal two-approval change ratifies it, so the emergency toggle cannot silently become permanent config. That ratification step turns every bypass into a forcing function for the after-the-fact review rather than relying on someone remembering to look.

The four approval steps Gate only the flags that warrant it, enforce distinct approver identity, enforce the gate at write time, and provide a logged break-glass path. 1 Gate by risk not everything 2 Distinct approver four-eyes 3 Gate at write no skip 4 Break-glass logged loud
Steps 2 and 3 make the approval real; step 4 keeps it from becoming an obstacle during the one incident where waiting would cost more than reviewing.

Verification

Assert the four-eyes rule directly: the requester cannot self-approve, a high-risk change with one approval is blocked, and a second distinct approval unblocks it. Write this as an executable test rather than a runbook step, because approval logic is exactly the kind of code that gets “simplified” during a refactor by someone who does not realize the self-approval check is load-bearing. A red test is a much louder alarm than a comment.

# approval.test.py
def test_four_eyes():
    c = change("ops.payments.kill", actor="alice", risk="high")
    with raises(Denied): add_approval(c, "alice")     # self-approval blocked
    add_approval(c, "bob")
    with raises(Denied): apply_change(c)              # 1/2 approvals, still blocked
    add_approval(c, "carol"); apply_change(c)         # 2/2 -> applied

Cover the negative cases too, since those are where enforcement quietly rots: a low-risk change should apply with zero approvals, a duplicate approval from bob should not count as the second signature, and a break-glass call from someone without the incident-responder role should be denied. The most valuable assertion of all is the one that proves the gate lives at write time — construct a change with insufficient approvals and confirm apply_change raises even when the proposal step was skipped entirely, so a future API path that forgets to call the proposal flow still cannot write.

Four-eyes enforced in test Self-approval is rejected, one approval leaves a high-risk change blocked, and a second distinct approval applies it. self → denied 1/2 → blocked 2/2 → applied
The self-approval denial is the assertion that proves the workflow is four-eyes and not just a checkbox.

Gotchas & Edge Cases

Three approval edge cases Approval fatigue from over-gating, stale approvals applied long after review, and a break-glass path that is never reviewed. Approval fatigue over-gating → rubber-stamping Stale approval approved, applied days later Unreviewed glass break-glass never looked at after
Over-gating quietly defeats the whole scheme — when everything needs approval, approvers stop reading and start clicking.

Troubleshooting & FAQ

Approvers are a bottleneck during business hours. What can we do?

Widen the approver pool (any two of a team’s engineers, not one designated lead) and scope approvals tightly to high-risk flags. If a specific flag needs approval constantly, that is a signal it is being toggled too often — investigate the underlying churn rather than adding approvers.

Can the same person be a valid approver for different changes?

Yes — the constraint is per change, not per person. Anyone with the approver role can approve any change they did not request. The rule only forbids self-approval and duplicate approvals on the same change.

How is this different from a code review on the flag config in Git?

A GitOps flow gives you review through pull requests, which is excellent for planned changes. Runtime approval workflows cover the changes that happen in the console during operations — including urgent ones — where a Git round-trip is too slow. Many teams use both: GitOps for planned config, runtime approval for live toggles.

Should turning a flag off — a rollback — also require approval?

Usually no. The whole point of a flag is to make disabling a bad change instant, so gating the off switch reintroduces the delay you built flags to remove. Treat “return to the last known-safe value” as a low-risk operation even on a high-risk flag, and reserve the two-approval requirement for enabling or widening exposure. If you must record who rolled back, capture it in the audit trail rather than blocking it behind a second signature — the record is cheap, the delay is expensive.

How do we handle approval for a scheduled or automated change?

Attribute the change to the human who scheduled it and capture the approvals at schedule time, then re-validate them at execution time. If the approvals have expired or the flag was reclassified in the interval, the automated apply should fail closed and page rather than proceed on stale authority. A machine actor firing the change does not dissolve the four-eyes requirement — it just moves the human sign-off earlier, so the enforcement at write time still has to see valid approvals or refuse.

What is the right number of approvers — is two always enough?

Two distinct people is the standard for four-eyes and covers the overwhelming majority of high-risk flags; going higher trades safety for latency fast, and each extra required approver roughly multiplies the odds of a stall waiting on availability. Reserve three-approver gates for a tiny set of catastrophic-blast-radius flags — a global payments kill switch, a data-deletion toggle — where the cost of a wrong change dwarfs the cost of waiting. For everything else, one requester plus one distinct approver is the sweet spot.

Where should approval state be stored so it survives a flag-service restart?

In the same durable store that holds the change record, not in the memory of the process handling the request. Approval is a fact about a pending change, and it must outlive deploys, restarts, and failovers — otherwise a rolling deploy mid-review silently drops accumulated approvals and forces re-review, which trains people to rush. Persist approvals alongside the change and read them back at write time; that is also what lets the audit trail reconstruct exactly who signed off on any historical change.