How to record a durable approval a merge cannot fake?

Direct answer

A runnable approval gate should reject merge status, stale hashes, wrong approvers, expired decisions, silence, and tampered records.

A completed change folder sits beside a separate authorization record linked by matching amber tabs.

Problem: Why can a merged change still lack approval to act?

A merge records that one set of bytes entered a branch. It does not tell your release job whether a currently authorized person approved a particular action, against named criteria, for those exact bytes, before a deadline and within an expiry window.

That distinction matters when the next action affects production, billing, security, customer data, publication, or another boundary that deserves explicit authority. If the gate accepts merged=true, a green build, or an old chat message, it can move code while the authorization question remains unanswered.

I made this mistake in an earlier version of this walkthrough. I explained fingerprinting and then stopped before the hard parts: the criteria, reject path, silence behavior, expiry, revocation, target-specific authority, and executable negative controls. The result sounded plausible and could not operate a complete approval control. This version starts at true step one and includes a tested reference gate.

The companion business article, Why Isn't a Merged Pull Request the Same as an Approval?, explains the decision and risk boundary. This technical walkthrough implements the corresponding control without treating that accepted article as deployment proof.

Value: What does a durable approval protect after the conversation ends?

A durable approval lets the next operator answer five questions without reconstructing a meeting: What action was approved? Which exact artifact and scope did the decision cover? Which current policy made the approver authoritative? Which criteria and evidence passed? When does the decision stop applying?

That record protects more than audit convenience. It prevents a changed release candidate from inheriting an earlier decision. In this sample, the policy grants authority through an explicit allowed-approver registry for this action class. Authorship does not independently grant or remove authority; the current target policy decides who may approve. The record turns silence into a visible stop instead of accidental self-authorization and gives an incident responder a precise revocation and supersession trail.

The value comes from keeping two records separate. The public Evidence-Gated Action pattern defines evidence about an action: gate, action, scope, evidence, method, timestamp, actor, and result. The public Approval Workflow defines the authority boundary: unapproved work needs explicit approval before execution. Evidence can make an action eligible for consideration. Evidence does not grant authority by itself.

The reference implementation below uses raw SHA-256 for the release-candidate artifact, proposal, and authority policy. Raw-byte binding is strict and easy to audit. The trade-off is that a line-ending or formatting change produces a new hash even when the parsed JSON means the same thing. I prefer that strict behavior for a small approval artifact because it makes the signed object unambiguous. If your platform canonicalizes structured data, publish the exact canonicalization algorithm and test it across every producer before you rely on canonical hashes.

Need help turning this pattern into a dependable AI system?

Just In Time AI can help your team design and implement the smallest useful workflow with the security, compliance, and operational controls it needs, then prove it before expanding. Explore AI Systems Setup and Coaching.

Solution: How do you build the complete fail-closed approval control?

The same-release illustrative bundle uses Python 3.9 or newer and only the standard library. It binds an authority policy, release candidate, proposal, criteria evidence, and approval record. The reusable lesson is the structure: the protected enforcement path must verify all those bindings before the action occurs.

Prerequisites and assumptions

Confirm the runtime before you create any record:

python3 --version
git --version

Expected output begins with Python 3.9 or newer and a current Git version. If Python is older, stop and use a supported environment; do not weaken the validator to fit an obsolete runtime.

Step 1 -- Define who may approve which action and against what criteria

Create authority-policy.json. The policy is an input to the decision, not a setting hidden inside the validator:

{
  "policy_id": "production-release-approval",
  "version": "2026-09-13",
  "action_class": "production-release",
  "allowed_approvers": [
    "release-owner"
  ],
  "required_criteria": [
    "tests-current",
    "rollback-ready",
    "scope-reviewed"
  ],
  "max_approval_age_hours": 24,
  "silence_default": "BLOCKED",
  "protected_surfaces": [
    "authority-policy.json",
    "approval-gate",
    "approval-records/"
  ],
  "re_review_triggers": [
    "proposal-bytes",
    "scope",
    "criteria",
    "evidence",
    "authority",
    "policy",
    "risk",
    "gate-implementation"
  ],
  "authority_model": "allowed-approver-registry"
}

The policy answers what the approver evaluates instead of presenting an authority title as proof. This example requires current tests, a rehearsed rollback, and a reviewed scope. A security change would need different criteria. A low-risk internal preview might use a standing grant with a shorter list, while a production billing release justifies the explicit decision shown here.

Verify the exact policy bytes:

python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("authority-policy.json").read_bytes()).hexdigest())'

The included fixture prints e70f5175607dd02bbb3463d4c5f2e87aa008be47d3dad29d8914a691bc345417. Your digest will differ if you reformat the file. That is expected. Record the digest produced from the bytes your gate will read.

Step 2 -- Write the exact proposal before asking for approval

Create proposal.json with intent, a successful end state, scope, author, reversibility, and the SHA-256 of the release-candidate file that deployment will consume. The criteria describe the outcome the approver should see:

{
  "change_id": "BILLING-2026-09-13-01",
  "author": "change-author",
  "action_class": "production-release",
  "scope": "billing-service@release-candidate-7",
  "intent": "Authorize the bounded billing calculation change for the production release queue before deployment; no customer invoice is affected until a later deployment step consumes this decision.",
  "success_criteria": [
    "Current tests pass for release-candidate-7.",
    "Rollback command is tested in staging.",
    "The release diff stays inside the reviewed billing calculation scope."
  ],
  "irreversible": false,
  "candidate": {
    "path": "release-candidate-7.json",
    "sha256": "f7a94f62962a776d0568437008f8466b7b01e8630a37c4dac8badc92b969d793"
  }
}

Before the decision, the release owner compares credible options: queue this candidate, hold for more evidence, or reject the candidate. The guarded action only authorizes entry into the production release queue; it occurs before deployment and before any customer invoice can change. This sample remains consequential but reversible because the operator can remove the candidate from the queue and has rehearsed rollback before a later deployment step. The decision record also names the downstream failure scenario so the deployment owner can stop release before invoice delivery. When evidence cannot distinguish the options, the owner names a reversible staging probe instead of approving from opinion.

Verify the proposal hash:

python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("proposal.json").read_bytes()).hexdigest())'

The included fixture prints dfca346170be620cfd994ca62956fcee652c1eb383cfc8dee05ad1be9e7c89a6. The proposal also binds release-candidate-7.json as f7a94f62962a776d0568437008f8466b7b01e8630a37c4dac8badc92b969d793; changing those deployable bytes requires a new proposal and approval.

Step 3 -- Produce current evidence before requesting authority

Run the tests, rehearse rollback, and inspect the actual diff for the exact candidate named in the proposal. Record the method, actor, timestamp, and result for each check. A generic build badge does not satisfy tests-current unless it binds the same release candidate.

CriterionEvaluation questionExample evidencePass condition
tests-currentDid the required tests run against the exact proposal bytes?evidence/tests-current.json plus its SHA-256Current run passes for the bound candidate
rollback-readyCan the operator return to the prior safe state?evidence/rollback-ready.json plus its SHA-256Drill restores the prior calculation
scope-reviewedDid the change stay inside the approved action and file scope?evidence/scope-reviewed.json plus its SHA-256No unreviewed action or file enters scope

If a criterion fails, the approver records REJECTED or BLOCKED with the failed criterion. The gate never interprets a partial checklist as approval.

Step 4 -- Record the decision, authority, limits, and lifecycle

The complete approval record includes these fields:

{
  "schema_version": "durable-approval/1.0",
  "record_id": "APR-BILLING-2026-09-13-01",
  "status": "active",
  "decision": "APPROVED",
  "gate": "production-release-authority",
  "action": "authorize billing calculation change for the production release queue before deployment",
  "action_class": "production-release",
  "scope": "billing-service@release-candidate-7",
  "artifact": {
    "path": "proposal.json",
    "sha256": "dfca346170be620cfd994ca62956fcee652c1eb383cfc8dee05ad1be9e7c89a6"
  },
  "policy": {
    "id": "production-release-approval",
    "version": "2026-09-13",
    "sha256": "e70f5175607dd02bbb3463d4c5f2e87aa008be47d3dad29d8914a691bc345417"
  },
  "approver": "release-owner",
  "requested_at": "2026-09-13T16:00:00Z",
  "response_deadline": "2026-09-13T18:00:00Z",
  "decided_at": "2026-09-13T17:00:00Z",
  "expires_at": "2026-09-14T17:00:00Z",
  "criteria": [
    {
      "id": "tests-current",
      "result": "PASS",
      "evidence": {
        "path": "evidence/tests-current.json",
        "sha256": "ed60ea73cc68ac1738aeed3a0c666b9aabc9ad20eb1f8bcfc97294a1b7d90abd",
        "method": "Run the isolated CI test suite for the exact candidate.",
        "observed_at": "2026-09-13T16:20:00Z"
      }
    },
    {
      "id": "rollback-ready",
      "result": "PASS",
      "evidence": {
        "path": "evidence/rollback-ready.json",
        "sha256": "02f032418843ef16cc3355c6579e9362a3f739a3b6f47395774b6e10ba459c8f",
        "method": "Execute and verify the staging rollback drill.",
        "observed_at": "2026-09-13T16:30:00Z"
      }
    },
    {
      "id": "scope-reviewed",
      "result": "PASS",
      "evidence": {
        "path": "evidence/scope-reviewed.json",
        "sha256": "426ce52a08ea8cd39a819367b6d1231b01c57a062d1a56b292a64f2d2ad58985",
        "method": "Review the exact candidate diff against the allowed area.",
        "observed_at": "2026-09-13T16:40:00Z"
      }
    }
  ],
  "alternatives_considered": [
    "release-candidate-7",
    "hold-for-more-evidence",
    "roll-back-change"
  ],
  "selected_option": "release-candidate-7",
  "decision_reason": "Current tests, rollback evidence, and scope review satisfy the bound policy.",
  "supersedes": null,
  "revoked_at": null,
  "revocation_reason": null,
  "candidate": {
    "path": "release-candidate-7.json",
    "sha256": "f7a94f62962a776d0568437008f8466b7b01e8630a37c4dac8badc92b969d793"
  }
}

The runnable fixture adds the premortem and a two-event hash chain for request and decision. Each event binds the prior event digest, and the decision event binds a canonical digest of every authority-bearing field outside the event list. Anchor the latest event digest in append-only or separately protected storage and require review by the actor or group that the target policy authorizes to change the policy, gate, and record store. The local chain detects unexplained edits only while its trusted anchor remains protected; a writer who can replace both the record and its anchor can recompute the chain.

The two-hour response deadline is a proposed local choice for the example. Your accountable owner can set another deadline. The rule that matters is explicit: if the deadline passes without an explicit decision, the gate returns BLOCKED code=silence_deadline_elapsed. An unstated silence path forces either an indefinite stall or silent self-authorization.

Expiry and revocation solve different problems. Expiry limits how long unchanged approval can be reused. Revocation ends it early. A changed proposal, scope, criterion, evidence source, policy, risk, approver authority, or gate implementation requires re-review even before the clock expires. A replacement record names the prior record_id in supersedes; the old record becomes superseded and remains in history.

Step 5 -- Run the gate against the exact bytes about to act

Download the public release assets into one empty directory, then run the gate against its exact bound inputs:

base=https://itproguru.com/assets/durable-approval
for file in approval_gate.py test_approval_gate.py authority-policy.json proposal.json release-candidate-7.json approval-approved.json; do curl -fLO "$base/$file"; done
mkdir -p evidence
for file in tests-current.json rollback-ready.json scope-reviewed.json; do curl -fLo "evidence/$file" "$base/evidence/$file"; done
curl -fLO "$base/required-check.yml"
python3 approval_gate.py --policy authority-policy.json --proposal proposal.json --candidate release-candidate-7.json --approval approval-approved.json --test-mode --at 2026-09-13T20:00:00Z
python3 -m pytest -q test_approval_gate.py
python3 approval_gate.py \
  --policy authority-policy.json \
  --proposal proposal.json \
  --candidate release-candidate-7.json \
  --approval approval-approved.json \
  --test-mode \
  --at 2026-09-13T20:00:00Z

Expected output:

PASS approval_id=APR-BILLING-2026-09-13-01 decision=APPROVED candidate_sha256=f7a94f62962a776d0568437008f8466b7b01e8630a37c4dac8badc92b969d793 artifact_sha256=dfca346170be620cfd994ca62956fcee652c1eb383cfc8dee05ad1be9e7c89a6 policy_sha256=e70f5175607dd02bbb3463d4c5f2e87aa008be47d3dad29d8914a691bc345417

The process exits 0 only for that pass. Missing, malformed, rejected, blocked, pending, expired, revoked, superseded, wrongly scoped, wrongly authorized, or tampered input exits 2 and prints BLOCKED code=<reason>.

Caution: Enable the required branch check only after the passing fixture and every negative control succeed in a scratch branch. Enabling a broken required check first can prevent every legitimate merge, including the repair.

The hosted check has a second trust boundary: the pull request being judged must not supply its own validator, policy, proposal, evidence, approval, or workflow. Put the required-check workflow on the protected default branch before selecting it as required. The pattern uses pull_request_target, fixes the trusted checkout to the event's base SHA, and extracts only the release-candidate blob from the verified PR-head SHA. Nothing from the PR head is imported or executed.

name: durable-approval

on:
  pull_request_target:
    types: [opened, synchronize, reopened, ready_for_review]

permissions:
  contents: read

jobs:
  enforce:
    name: durable-approval / protected-base-enforce
    runs-on: ubuntu-latest
    steps:
      - name: Check out protected-base controls
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          path: trusted
          fetch-depth: 1
      - name: Select the untrusted candidate as data
        id: scope
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          CANDIDATE_PATH: <governed-candidate-path>
        run: |
          set -euo pipefail
          [[ "$BASE_SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]
          [[ "$PR_HEAD_SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]
          [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]
          git -C trusted fetch --no-tags --depth=1 origin "pull/${PR_NUMBER}/head"
          test "$(git -C trusted rev-parse FETCH_HEAD)" = "$PR_HEAD_SHA"
          if git -C trusted diff --quiet "$BASE_SHA" "$PR_HEAD_SHA" -- "$CANDIDATE_PATH"; then
            echo "governed=false" >> "$GITHUB_OUTPUT"
            echo "PASS out_of_scope candidate_path=$CANDIDATE_PATH"
            exit 0
          fi
          mkdir -p candidate
          git -C trusted show "${PR_HEAD_SHA}:${CANDIDATE_PATH}" > candidate/release-candidate-7.json
          echo "governed=true" >> "$GITHUB_OUTPUT"
      - name: Enforce approval with protected-base controls
        if: steps.scope.outputs.governed == 'true'
        run: |
          python3 trusted/<protected-control-root>/approval-gate \
            --policy trusted/<protected-control-root>/authority-policy.json \
            --proposal trusted/<protected-control-root>/proposal.json \
            --candidate candidate/release-candidate-7.json \
            --approval trusted/<protected-control-root>/approval-approved.json

The workflow has no path filter, so the required job always reports a conclusion. An out-of-scope pull request prints PASS out_of_scope; a governed candidate change runs the protected-base gate. A pull request may replace its own copy of the gate program with a program that prints PASS, but the protected workflow never executes that copy. A negative control changes both the candidate-side gate and candidate bytes: the untrusted program prints a forged pass while the protected-base gate returns a stale-candidate block.

Protect changes to .github/workflows/durable-approval.yml, the validator, policy, proposal, evidence, approval store, and ruleset through the target policy's server-side CODEOWNERS or equivalent authority path. This candidate check does not approve its own replacement. The sample deliberately omits merge_group; if you use a merge queue, require a separately trusted GitHub App or service to evaluate the merge-group SHA instead of executing control code from the proposed merge group.

Configure the server-side enforcement after the protected-base workflow has produced its check at least once. In Settings -> Rules -> Rulesets, create a branch ruleset that targets the production branch, set enforcement to Active, require a pull request, select Require status checks to pass, and add the exact unique job name durable-approval / protected-base-enforce. Choose strict updating when the candidate must be evaluated against the latest protected base. GitHub documents the ruleset procedure, required-check behavior, and the security boundary of pull_request_target. A local pre-commit hook remains only a convenience layer.

Verify the command's exit status in CI:

python3 approval_gate.py --policy authority-policy.json --proposal proposal.json --candidate release-candidate-7.json --approval approval-approved.json
test "$?" -eq 0

In production, omit both test-clock flags as shown so the gate reads current UTC. Use a newly issued, unexpired approval record for the live candidate. Open one pull request that does not alter the governed candidate and confirm the required control reports the explicit out-of-scope success. Then change the candidate without a new protected-base approval and confirm the same check fails and GitHub disables merge. Finally, change the pull-request copy of the gate program to print a forged pass alongside the candidate change and confirm the protected-base check still fails. If a required check never completes, use GitHub's required-status troubleshooting guide to inspect workflow triggers and exact check names.

Step 6 -- Prove a merge, green build, or look-alike cannot pass

Run the negative controls:

python3 -m pytest -q test_approval_gate.py

Expected output:

........................                                                 [100%]
24 passed

The tests cover a valid record, a protected-base workflow-structure check, and these blocked cases: candidate-byte drift, missing approval, a JSON object containing only merge metadata, proposal drift, wrong authority, failed criteria, expiry including the exact boundary, silence after the deadline, a late decision, use before decided_at, revocation, supersession, policy drift, free-text evidence, changed evidence bytes, unaudited changes to the action or criterion evidence, a production --at override, and test mode without an explicit clock.

The merge-only test supplies {"merged": true, "commit": "abc123"} where the approval record belongs and proves workflow state is not authority. The PR-head gate-mutation test writes a fake validator that prints PASS, changes the governed candidate bytes, and then invokes the protected-base validator; it proves candidate code cannot choose the control that judges it. The merge-only validator result is:

BLOCKED code=approval_schema detail=approval.schema_version must be durable-approval/1.0

That result demonstrates that this gate does not infer approval from the merge event. The separate negative control demonstrates that protected selection rejects changed candidate bytes even when a pull-request-supplied program prints a forged pass. Neither test proves that a privileged repository administrator cannot disable the required check. Monitor changes to branch rules, the CI workflow, policy, and approver registry, and treat an authorized emergency bypass as a separate, time-limited approval class with its own evidence, expiry, reason, and post-action review.

Step 7 -- Operate expiry, supersession, revocation, and rollback

How can you verify the control and its limits?

The reference fixture passes all twenty-four deterministic tests, including one valid approval, the protected-base workflow contract, and the PR-head gate-mutation refuter. The validator recomputes release-candidate, proposal, policy, and criterion-evidence hashes; checks action class and scope; enforces the target policy's explicit allowed-approver registry; checks the response deadline, decision time, exact expiry boundary, and maximum lifetime; rejects stale or inactive decisions; and validates the audit-event chain plus its full authority-payload digest.

Troubleshooting

Artifacts

Bottom Line: What should you enforce before the next consequential merge?

Require two current records before the action: evidence that the exact candidate meets named criteria, and authority from an approver whose current policy covers that action and scope. The target policy's allowed-approver registry supplies authority for this action class; authorship alone does not decide authority. Bind both the proposal and policy bytes. Block missing, silent, rejected, stale, expired, revoked, superseded, wrongly scoped, or tampered decisions.

Start with these operator-visible actions:

  1. Run your complete isolated example: The release engineer runs the valid case and the planned negative controls in an authorized scratch environment.
  2. Protect the enforcement surfaces: The repository administrator protects the server-side check, validator, policy, approval directory, CI workflow, and external audit anchor only after every negative control blocks as expected.
  3. Connect one bounded action: The authority owner connects the gate to one consequential but reversible action, records the first real criteria and evidence contract, and rehearses the governed emergency recovery path before production use.

That sequence gives you a working control and a tested recovery path. It protects engineering time from repeated approval reconstruction and lowers the chance that an unsupported release reaches customers.

Here is the shareable rule I use: A merge moves bytes; only current evidence plus current scoped authority permits the action. Share that line with the person who owns your protected branch, and document the first look-alike your negative tests rejected so the gate keeps covering it.

Subscribe for the next practical implementation guide. If you want help reviewing the authority boundary in your own pipeline, contact me.

Frequently Asked Questions

How do I bind an approval to the exact file or release candidate being approved?

Compute SHA-256 from the exact bytes the protected action will consume, and store that digest with the artifact path and scope in the approval record. Recompute it in the required server-side check immediately before merge or release. Any mismatch blocks the action and requires a fresh decision for the new bytes.

What should an approval checklist evaluate before a production release?

Define the criteria in the authority policy before the request. For the sample release, the authorized approver checks current tests for the exact candidate, a rehearsed rollback, and a diff confined to the reviewed scope. Record a PASS plus inspectable evidence for every criterion; any missing or failed criterion keeps the release blocked.

What should a CI approval gate do when the approver does not respond?

The request must name a deadline and an owner-visible decision queue. At the deadline, the gate records or returns BLOCKED because silence grants no authority. Choose a response window that fits your risk and operations, but never leave the silence behavior implicit.

When must a team request approval again after a change was already approved?

Request a new decision when the proposal bytes, action, scope, criteria, evidence source, approver authority, policy, risk, or gate implementation changes, and when the old record expires or is revoked. Preserve the old record, mark it superseded or revoked, and link the replacement so the audit trail remains inspectable.

How does the gate decide who may approve a proposal?

Put durable approver identities in the policy for the target action class, and make the gate require the current actor to appear in that registry. Run the candidate check from protected-base copies of the policy, validator, proposal, evidence, approval store, and CI workflow; never execute those files from the PR being judged. Protect changes to those surfaces through the authority model that governs them. Authorship alone does not grant authority, and this sample does not impose a universal rule that the approver must be a different person.

Can the code author also be an authorized approver?

Use the current target policy; authorship alone neither grants nor removes authority. Some low-risk actions may permit the same person under standing authority, while higher-risk actions may require separation of duties. Encode that decision in the protected policy and test both an allowed identity and a plausible unauthorized identity before relying on the gate.

Update History

Evidence and provenance: This publication-authorized technical article was revised on 2026-09-14 from the public Evidence-Gated Action and Approval Workflow sources, current internal approval and authority doctrine, and the tested reference fixture named in frontmatter. The sample identities, reversible pre-deployment queue decision, CI run, rollback drill, deadline, allowed-approver policy, and decision are illustrative. The tests prove the fixture's stated behavior; they do not prove a production deployment, customer outcome, or administrator-proof boundary. The canonical route is bound for release; deployment and anonymous readback remain pending.

Update history

  1. What changed: Reclassified the sample as consequential but reversible, aligned target-specific authority, moved the guarded decision before deployment, and bound hosted enforcement to protected-base controls with a PR-head mutation refuter and twenty-four tests.

    Why it matters: At this pre-authorization revision, the unpublished draft prevented the candidate from supplying the validator or approval configuration that judges itself while preserving the article's authority boundaries.

  2. What changed: Bound criterion evidence to inspectable artifacts and closed timing and audit-payload gaps found by adversarial technical review.

    Why it matters: The gate now blocks before decision time, at expiry, after the response deadline, on stale evidence files, and after unaudited authority-payload edits.

  3. What changed: Rebuilt the rejected conceptual HOW as a complete runnable approval control with exact bindings, lifecycle rules, and negative tests.

    Why it matters: A builder can now reproduce the gate and prove that merge status alone grants no authority.

Dan Stolts

Founder and Chief AI Officer of Just In Time AI, with an IT career that began in 1988 -- nearly four decades of experience. Over a decade of that career went into building ITProGuru into a 250,000-uniques/mo IT-community resource, and he now applies the same teach-first approach to AI systems that run real businesses. Full profile →