BLOG/ENTERPRISE AUTOMATION
THEME
ENTERPRISE AUTOMATION

Change management with audit trails

Change management shouldn't mean a screenshot pasted into a ticket. Here's an OrchStep workflow that gates a change behind policy checks and approval, then writes a hashed, chained record an auditor can actually trust.

May 2, 2026 OrchStep Team 7 minROLE: Security EngineerSCALE: Enterprise
RUNNABLE DEMO
Full source for this post: blog/change-audit-trail
VIEW SOURCE

Here's how change management works at a lot of companies that passed their audit: an engineer makes a change, takes a screenshot of the approval Slack thread, pastes it into a ticket, and marks it done. The "audit trail" is a screenshot. It proves nothing — it could have been faked in thirty seconds, the approval could have come after the change, and nobody can tell.

The gap is that the control and the change live in different systems. The approval is in chat, the policy check is in someone's head, the change is in a terminal, and the record is a manual write-up assembled afterward by the person being audited. Of course it's untrustworthy.

Put the control, the change, and the record in one workflow. The change cannot happen unless policy passes and approval is granted, and the record is written by the same run that made the change — sealed so it can't be edited after the fact.

Policy, approval, change, record — one run

orchstep.yml
name: change-audit-trail
defaults:
  change: "CHG-1042"
  risk: low

tasks:
  # `orchstep run apply --var change=CHG-1099 --var risk=high`
  apply:
    steps:
      - name: policy
        func: shell
        do: |
          echo "policy check: peer-reviewed, has rollback, inside change window"
        outputs:
          result: "pass"

      - name: gate
        func: assert
        args:
          condition: 'steps.policy.result === "pass"'
          message: "change blocked: policy checks must pass"

      - name: approve
        func: prompt
        args:
          message: "Approve {{ vars.change }} (risk {{ vars.risk }})?"
          type: confirm
          default: false

      - name: decision
        if: '{{ eq steps.approve.value "true" }}'
        then:
          - name: execute
            func: shell
            do: echo "applying {{ vars.change }}"
        else:
          - name: hold
            func: shell
            do: echo "{{ vars.change }} held — no approval recorded"

      - name: record
        func: shell
        do: echo "RECORD={{ vars.change }}|risk={{ vars.risk }}|policy={{ steps.policy.result }}|approved={{ steps.approve.value }}"
        outputs:
          line: '{{ result.output | regexFind "RECORD=(.+)" }}'

      - name: seal
        func: shell
        do: echo "sha256({{ steps.record.line }}) chained to the prior entry — tamper-evident"

The assert step is a hard gate: if the policy check doesn't return pass, the run stops with your message and nothing downstream executes. The approve step uses a confirm prompt — in a pipeline it falls back to the default (false) and the change is held, so an unattended run can never silently self-approve.

The record can't be older than the change

The trustworthy part is that record is built from the run's own outputs — the change ID, the policy result, the approval decision — and then seal hashes that line and chains it to the previous entry:

- name: record
  func: shell
  do: echo "RECORD={{ vars.change }}|risk={{ vars.risk }}|policy={{ steps.policy.result }}|approved={{ steps.approve.value }}"
  outputs:
    line: '{{ result.output | regexFind "RECORD=(.+)" }}'

- name: seal
  func: shell
  do: echo "sha256({{ steps.record.line }}) chained to the prior entry — tamper-evident"

A hash chain is what makes the trail tamper-evident: edit any past record and every hash after it breaks. The screenshot-in-a-ticket approach has no such property. This one produces a record where altering history is detectable, because the record was written by the control, not next to it.

The gate is the control, not the comment

Compare the two postures. On the left, the policy check is a sentence in a PR description. On the right, it's an assert that physically stops the run:

CONTROL AS DOCUMENTATION
tasks:
  apply:
    steps:
      # "remember to check the policy" — a comment, not a control
      - name: execute
        func: shell
        do: echo "applying CHG-1042"
CONTROL AS CODE
tasks:
  apply:
    steps:
      - name: gate
        func: assert
        args:
          condition: 'steps.policy.result === "pass"'
          message: "change blocked: policy checks must pass"
      - name: execute
        func: shell
        do: echo "applying CHG-1042"

The left version passes an audit until someone actually checks whether the control runs. The right version is the control — there's no path to execute that skips the gate. See the assert function.

What you actually gained

ConcernScreenshot-in-a-ticketOrchStep
Policy enforcementa comment to rememberassert blocks the run
Approvala chat threadprompt, defaults to deny
Record authorshipthe person being auditedthe run itself
Tamper resistancenonehashed + chained
CI safetymanualunattended runs self-hold

This isn't a GRC platform, and it won't replace one if you're regulated enough to need formal evidence storage. But it closes the gap that makes most change records worthless: the control, the change, and the record finally live in the same place, and the record can't predate the change.

Where to go next

Have a change process held together by screenshots? Encode the gate as an assert and let one run produce the change and the record together.

#SECURITY#CHANGE-MANAGEMENT#AUDIT#COMPLIANCE
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — ENTERPRISE AUTOMATION