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.
blog/change-audit-trailHere'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
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:
tasks:
apply:
steps:
# "remember to check the policy" — a comment, not a control
- name: execute
func: shell
do: echo "applying CHG-1042"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
| Concern | Screenshot-in-a-ticket | OrchStep |
|---|---|---|
| Policy enforcement | a comment to remember | assert blocks the run |
| Approval | a chat thread | prompt, defaults to deny |
| Record authorship | the person being audited | the run itself |
| Tamper resistance | none | hashed + chained |
| CI safety | manual | unattended 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
- The assert function — hard gates that stop a run
- The prompt function — approvals that default to deny in CI
- Conditionals — branch on the approval decision
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.
curl -fsSL https://orchstep.dev/install.sh | sh