Post ·
A Dry Run Is Not Permission
Guarded writes to a production system: why a clean dry run proves nothing about write access, and the preview, intent, window, canary and read-back pattern that makes AI-assisted updates safe.


I built a small web console for querying and updating a ServiceNow CMDB. Architects use it in a browser, and AI assistants use it through an MCP server. Reads are the easy part. Writes to a production system of record are where a tool earns or loses trust, so the write path got more design attention than anything else — and it still taught me the lesson in this title the hard way.
Five questions before anything changes
The console is read-only by default. It only ever updates existing records: it never creates them, and deletes exist for exactly one relationship table, behind the same steps. Every write passes five checks, each answering a different question:
- Dry run: what would change? The console reads the record, computes a field-by-field diff against the request and sends nothing upstream. Dry runs work even when writes are switched off.
- Intent: did you mean this record? A commit must name its target record in a separate header. If it does not match the request, the console refuses before anything leaves.
- Window: are writes open at all? Writes are enabled by a timed window that closes itself, and closes on restart. The default state is shut.
- Binding: is this still the change you reviewed? On production, a commit must carry a confirmation code from its dry run. The code is bound to the instance, the record, the verb, the exact change set and the record's state before the change. If anyone edits the record in between, the code stops matching and the commit is refused.
- Read-back: did it land? After a commit, the console reads the record again and returns before, diff and after side by side.
What a dry run cannot tell you
Here is the trap. A dry run reads the record and computes a diff. That proves you can read the record. It proves nothing about whether you may write it. The platform enforces write permissions at commit time, not my console.
I learned that from a set of tables that dry-ran perfectly — clean diffs, valid confirmation codes — and refused every real commit with a 403. Notes written from those dry runs had already described the tables as writable. They were not.
A clean dry run proves you can read. Only a committed write proves you can write.
The canary rule
Every new write target now starts with one record:
- Commit one low-risk change to a single record, on purpose.
- Read it back and compare the stored value to the diff you approved.
- Only then batch. The canary answers the permission question before a hundred rows ask it at once.
- Record the evidence with a date: which table, which verb, which account, what happened. Permissions change; a dated fact can be re-checked, while a remembered one just gets repeated.
Three bugs the guards caught — or caused
- A dry run that wrote. The dry-run header was honored on updates but not on inserts, so one preview created a real record. Now the header is verb-independent: any mutating request that carries it is never forwarded, and a test covers every verb.
- A commit that drifted from its preview. The form captured the dry-run payload. Edit the form afterwards and the commit would still send the old snapshot. Now any edit invalidates the dry run and disarms the commit.
- A flag that was quietly ignored. Unknown arguments to a scheduled task used to be dropped. Silently ignoring an argument is how a --dry-run becomes a --commit. Unknown flags are now rejected, with the reason.
Order your refusals
One small design choice matters more than it looks: validate the request before checking the write window. A 400 means this will never work as written. A 409 means try again once something changes. Report the permanent fault first, or people will wait for a window that was never going to help. It is the same family of mistake as treating a push as a deploy: the signal you check has to be the one that answers the question.
Copy this: guarded write rules
The section I keep in the contract for any agent that can touch a system of record:
## Writes to a system of record
- Read-only by default. Writes open in a timed window that closes itself.
- Every write is dry-run first: read the record, show the diff, send nothing.
- A commit names its target record explicitly. Mismatch = refuse.
- Production commits carry a confirmation code from their dry run, bound
to the record's before-state. If the record changed, dry-run again.
- A clean dry run proves READ access only. For any new table or field:
commit ONE record, read it back, compare to the diff, then batch.
Record the result with a date.
- Update-only. No inserts or deletes unless explicitly approved.
- A 200 is not proof. Read back the field you wrote.
- Unknown flags and parameters are rejected, never ignored.The pattern costs a few headers and a round trip. In exchange, every production change has a preview, a named target, a bounded window and a receipt.