●COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devices●M365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint files●MCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first login●CODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hook●GOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environment●LIMITS — Claude Code weekly usage limits are increased by 50% through July 13●COWORK — Claude Cowork expands to web and mobile with remote sessions, synced files, and a shared Chat and Cowork home across devices●M365 — Claude Cowork adds Microsoft 365 write tools to draft and send email, manage calendars, and update OneDrive and SharePoint files●MCP — Admins can provision MCP connectors org-wide through their identity provider, starting with Okta, with access granted on first login●CODE — Claude Code fixes SessionStart hook streaming in headless sessions so remote workers are not idle-reaped mid-hook●GOV — Claude Code and Claude Cowork are in public beta in Claude for Government Desktop on a FedRAMP High authorized environment●LIMITS — Claude Code weekly usage limits are increased by 50% through July 13
Is the Draft That Passed the Gate the Same One You Published?
In unattended pipelines, the file your quality gate inspected and the file you actually publish can quietly diverge. Here is a digest-bound gate receipt design, with working code and measured results from 180 days of running it.
One morning, the unattended pipeline I run overnight published something it should have refused to publish.
The execution log showed the quality gate printing a clean pass. A few lines below, git push reported success. Nothing was red. And yet the published text contained exactly the phrasing the gate was written to reject.
I stared at the screen for a while before it landed. The file the gate had read and the file git add had picked up shared a path, but not contents. In the seconds between the gate finishing and the push starting, another process had rewritten it.
No errors. Every step "succeeded." That quiet is the hardest part of running things unattended.
What Slips In Between Checking and Publishing
This is a textbook TOCTOU problem — time-of-check to time-of-use. A filesystem path is a reference, not a value. It does not promise to hold the contents you inspected a moment ago.
In my own pipelines, four distinct paths caused it.
Entry point
What happens
Why it hides
Regeneration
The agent rewrites the draft to fix a gate violation, then proceeds to push without re-running the gate
The old passing result is still sitting in the log
Auto-fix tooling
An integrity checker's --fix mode rewrites files and leaves .bak siblings behind
git add -A sweeps up files nobody inspected
Stale temp files
A write to a fixed-name temp file fails, and last run's contents get read instead
The failed write never propagates a non-zero exit
Concurrent processes
Another scheduled run touches the same working directory
Each run looks successful in isolation
None of these are fixed by making the gate stricter. The problem is not inside the gate. It lives in the gap between the gate and the publish.
The Receipt Idea: Bind the Verdict to the Bytes
The fix I settled on was to stop letting the gate's verdict stand on its own, and instead bind it to a digest of the contents it inspected.
When every gate passes, the pipeline emits a small JSON document containing:
The relative path and sha256 digest of each inspected file
The name of each gate that ran, plus a digest of the gate script itself
The issue time, recorded on both a wall clock and a monotonic clock
The pipeline run ID
I call this a receipt. Immediately before publishing, the pipeline reads the receipt and recomputes every digest from disk. If a single byte has moved, nothing gets published.
The key property is that a receipt is issued against contents, not against paths. Paths get rewritten. Digests do not lie.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Get working code for issuing and verifying a gate receipt that binds pass/fail results to the sha256 digest of every inspected file
✦Understand why batching verification and publishing into one shell invocation erases the observation point, and learn to find the non-zero exits your pipeline is swallowing
✦Learn the four ways a file changes after passing a gate — regeneration, auto-fix, stale temp files, concurrent processes — and how to close each one
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Here is the issuing side. It runs the gates, and writes a receipt only when all of them pass.
#!/usr/bin/env python3"""gate_receipt.py — run quality gates and, on success, issue a digest-bound receipt."""from __future__ import annotationsimport hashlibimport jsonimport osimport subprocessimport sysimport timefrom pathlib import PathRECEIPT_NAME = ".gate-receipt.json"def digest(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest()def run_gate(script: Path, targets: list[Path]) -> tuple[bool, str]: """Run a single gate. Returns (passed, combined output).""" proc = subprocess.run( [sys.executable, str(script), *[str(t) for t in targets]], capture_output=True, text=True, timeout=300, ) output = (proc.stdout + proc.stderr).strip() return proc.returncode == 0, outputdef issue(repo: Path, gates: list[Path], targets: list[Path]) -> int: results = {} for gate in gates: passed, output = run_gate(gate, targets) results[gate.name] = { "passed": passed, "gate_digest": digest(gate), "tail": output.splitlines()[-3:], } print(f"{'PASS' if passed else 'FAIL'} {gate.name}") if not passed: print(output) if not all(r["passed"] for r in results.values()): # Issue nothing, and actively invalidate any receipt left behind. (repo / RECEIPT_NAME).unlink(missing_ok=True) print("No receipt issued. Do not publish.") return 1 receipt = { "version": 1, "run_id": os.environ.get("PIPELINE_RUN_ID", str(os.getpid())), "issued_at_wall": time.time(), "issued_at_mono": time.monotonic(), "gates": results, "artifacts": { str(t.relative_to(repo)): digest(t) for t in targets }, } (repo / RECEIPT_NAME).write_text(json.dumps(receipt, indent=2, sort_keys=True)) print(f"Receipt issued for {len(receipt['artifacts'])} files") return 0if __name__ == "__main__": repo_root = Path(sys.argv[1]).resolve() gate_dir = Path(sys.argv[2]).resolve() files = [Path(p).resolve() for p in sys.argv[3:]] sys.exit(issue(repo_root, sorted(gate_dir.glob("*_gate.py")), files))
Three details in there are deliberate.
First, a failing gate deletes any existing receipt. If last night's receipt survives, tonight's failure gets masked by yesterday's success. A receipt carries meaning simply by existing, so forgetting to invalidate it is the worst possible bug.
Second, the receipt records a digest of each gate script. Rewrite a gate and leave an old receipt valid, and you have lost the answer to "passed according to which standard?"
Third, both a wall clock and a monotonic clock are recorded. Wall clocks step backwards under NTP correction and timezone handling. Monotonic clocks do not. Freshness checks use the monotonic value.
Verifying Before You Publish
The verifying side runs immediately before git push, or before a deploy.
#!/usr/bin/env python3"""verify_receipt.py — verify a gate receipt. Exit non-zero on any mismatch."""from __future__ import annotationsimport jsonimport sysimport timefrom pathlib import Pathfrom gate_receipt import RECEIPT_NAME, digestMAX_AGE_SECONDS = 900 # a receipt is good for 15 minutesdef verify(repo: Path) -> int: receipt_path = repo / RECEIPT_NAME if not receipt_path.exists(): print("FAIL: no receipt. Run the gates first.") return 1 receipt = json.loads(receipt_path.read_text()) age = time.monotonic() - receipt["issued_at_mono"] if age < 0 or age > MAX_AGE_SECONDS: print(f"FAIL: receipt is stale ({age:.0f}s old). Re-run the gates.") return 1 failures = [] for rel, expected in receipt["artifacts"].items(): target = repo / rel if not target.exists(): failures.append(f"{rel}: missing") continue if digest(target) != expected: failures.append(f"{rel}: contents changed") for name, meta in receipt["gates"].items(): gate_path = repo / "_gates" / name if gate_path.exists() and digest(gate_path) != meta["gate_digest"]: failures.append(f"{name}: the gate itself was modified") if failures: print("FAIL: receipt does not match what is on disk:") for f in failures: print(f" - {f}") return 1 print(f"PASS: receipt verified ({len(receipt['artifacts'])} files, {age:.0f}s old)") return 0if __name__ == "__main__": sys.exit(verify(Path(sys.argv[1]).resolve()))
Treating age < 0 as a failure is not paranoia. time.monotonic() has a per-process origin, so a negative age means the receipt came from a different process than the one verifying it. The check makes "valid only within a single run" explicit rather than assumed.
I chose fifteen minutes because in my pipelines the median gate-to-push interval is 40 seconds and the longest observed is about 6 minutes. That distribution differs per pipeline. Measure yours for a week, and I recommend setting the window at roughly twice your observed maximum.
How One Big Shell Command Erases the Observation Point
There is a second, deeper problem worth naming.
When the gate and the publish live in the same shell invocation, nobody — human or agent — gets to observe what happened in between. The agent reads only the final exit code and concludes the run succeeded.
Laying out the shell operators makes the risk concrete.
Form
When the gate fails
Risk
gate; git push
The push runs anyway
High — the violation ships
gate && git push
Push is skipped, but the pipeline can still exit 0
Medium — the failure goes silent
gate | tee log
The exit status becomes tee's
High — always looks like success
Gate and push in separate invocations
The result is read before the decision
Low
set -euo pipefail mitigates rows two and three. I still separate the invocations, for a simple reason: separation keeps the act of reading the result structurally present. In any step delegated to an agent, whether an opportunity to read exists maps almost directly onto how often things go wrong.
The receipt is the adhesive that makes separation safe. A verdict bound to contents survives the boundary between invocations.
Measured Results
As an indie developer I run unattended publishing pipelines across four repositories. Comparing 90 days before receipts and 90 days after:
Metric
Before (90 days)
After (90 days)
Published with contents changed after passing the gate
3
0
Publishes blocked at verification
—
11
Blocked: forgot to re-run the gate
—
6
Blocked: rewritten by --fix
—
4
Blocked: stale temp file contamination
—
1
Added verification time (12 files, median)
—
0.41 s
False positives (legitimate publishes stopped)
—
0
All three pre-receipt incidents were discovered afterwards, by readers. The eleven blocked publishes would have followed the same route.
The cost is 0.41 seconds — under 0.3% of a single pipeline run. sha256 is fast relative to file I/O, and for tens-of-kilobytes text files it disappears into the noise. Seeing that number, I felt a pang about how long I had gone without it.
Rolling It Out in Four Stages
You do not need to switch everything on at once. This is the order that worked.
Measure. Log the gate's finish time and the publish's start time. That distribution tells you what freshness window to set. If you find a surprisingly long tail, suspect a concurrent process right there.
Issue only. Write receipts but do not block. Run for a week and watch how often mismatches appear. In my case, roughly one per week surfaced at this stage.
Warn. Print loudly on mismatch, still without blocking. This is where you find false positives. If CI regenerates derived artifacts, they will trip here — exclude them, or digest only the sources that precede generation.
Enforce. Let verify_receipt.py fail the publish, and split the gate and the push into separate invocations.
Do not skip stage three. Advancing to enforcement with generated artifacts still in the digest set will stop every legitimate publish. I wiped out a full night of runs learning that.
What Running It Taught Me
Modification times are not a substitute. My first attempt recorded mtimes instead of digests. It misses same-second rewrites, and granularity varies by filesystem. Digests leave no ambiguity.
Tools with a --fix mode leave more behind than the files they fixed. An integrity checker once left .bak siblings that git add -A happily committed. A receipt only protects the files it inspected — so bind the publish set itself into the receipt and reconcile it against git status --porcelain.
Keep the receipt out of the working tree. Otherwise it lands in a commit. Either .gitignore it or write it outside the repository, into a directory named for the run ID. I prefer the latter. A receipt is an artifact of the run, not of the product.
Decide Based on Reversibility
Not every pipeline needs this.
Situation
Recommendation
You update a single repository by hand, watching it happen
Skip it. Your eyes are the final verification
Unattended publishing, but trivially reversible (internal docs)
Issue and warn. Blocking is not worth the friction
Unattended publishing where reversal has external reach (search indexes, distribution, billing)
Enforce, and separate the gate and publish invocations
Multiple scheduled runs sharing one working directory
Enforce, and give each run its own working directory
The axis is reversibility. A URL you published and then removed lives on in search indexes and in the memory of whoever read it. Against that kind of irreversibility, 0.41 seconds is remarkably cheap insurance.
Where to Start
Start by measuring. Log the gate's exit time and the publish's start time in your own pipeline — nothing more. Watch that gap for a week, and you will see exactly how wide the window is that you have been leaving open.
It took me months to notice the window existed at all. If this spares even one person the same quiet kind of failure, that is enough. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.