Canonical writer lock + validation gates
Serialize writes to a canonical vault behind a file lock, validate before committing (stdin-fed audit), refresh and verify projections, and abort safely with SIGINT so the lock is always released.
bash# ─────────────────────────────────────────────────────────────
# Controlled-write contract for a canonical vault:
# lock → validate → commit → refresh → verify → unlock.
# Run the actual edit as a /tmp script UNDER the lock — never
# inline bash -c with nested quoting.
# ─────────────────────────────────────────────────────────────
# 1. Write the edit as a self-contained script (enumerated files only)
cat > /tmp/my-write.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd ~/wiki
# -- make the edit (example) --
$EDITOR runbooks/example.md
# -- VALIDATE before committing. The audit reads the file list from
# STDIN — pipe it in. Bare invocation blocks forever on stdin. --
git ls-files -s | node scripts/wiki-audit.mjs . # links · stale paths · frontmatter · secrets
# -- regenerate any generated artifacts and gate on drift --
node scripts/build-topic-map.mjs . --check \
|| { echo "topic maps stale — run --write first"; exit 1; }
# -- commit ENUMERATED files only. Never 'git add -A' on canonical. --
git add runbooks/example.md
git commit -m "Update example runbook"
# -- refresh derived consumers, then verify each reports the new HEAD --
# (run projection/index refresh on the host that owns them)
refresh-projection && verify-projection
build-index && verify-index
EOF
chmod +x /tmp/my-write.sh
# 2. Run the edit UNDER the single-writer lock (flock-based wrapper)
canonical-writer-lock.py -- bash /tmp/my-write.sh
# 3. If you must ABORT a running wrapped write, send SIGINT (not
# SIGTERM) so the wrapper's cleanup runs and releases the lock:
# kill -INT <canonical-writer-lock.py pid>
# 4. Confirm the lock is free afterwards
flock -n /wiki-data/.canonical-writer.lock -c true \
&& echo "lock is free" || echo "lock still held — investigate"
# ─────────────────────────────────────────────────────────────
# Reference: a minimal flock-based single-writer wrapper.
# Save as canonical-writer-lock.py. Releases the lock in finally
# so SIGINT (KeyboardInterrupt) always frees it.
# ─────────────────────────────────────────────────────────────
cat > canonical-writer-lock.py <<'PYEOF'
#!/usr/bin/env python3
import fcntl, subprocess, sys
LOCK = "/wiki-data/.canonical-writer.lock"
if "--" not in sys.argv:
sys.exit("usage: canonical-writer-lock.py -- <command> [args...]")
cmd = sys.argv[sys.argv.index("--") + 1:]
if not cmd:
sys.exit("error: no command after --")
lf = open(LOCK, "w")
try:
fcntl.flock(lf, fcntl.LOCK_EX) # block until we are the sole writer
sys.exit(subprocess.call(cmd))
finally:
fcntl.flock(lf, fcntl.LOCK_UN) # released on success, error, AND SIGINT
lf.close()
PYEOF
chmod +x canonical-writer-lock.py
What this does
Implements the controlled-write contract for a canonical vault: it serializes writers behind a flock lock, validates the staged change before committing, commits only enumerated files, refreshes the derived projections and verifies each reports the new commit, then releases the lock. The edit runs as a /tmp script under the lock so there is no fragile inline quoting, and aborting with SIGINT always frees the lock.
Why this approach
The intuitive way to write to a Git repo is to edit a file, git add ., and git commit. That works fine when one human is using one machine. It stops working the moment you have two writers (you on your laptop, an AI agent on the server), more than one consumer that needs to stay coherent (a published wiki, a search index, a backup mirror), or any validation you want to guarantee runs before every commit.
The write sequence in this playbook isn’t clever — it’s boring on purpose. Every write goes through the same path: acquire a file lock so only one writer runs at a time, run validation before touching the repo, stage only the specific files you changed (never git add -A), commit, refresh each consumer, verify each consumer reports the new commit, release the lock. That sequence sounds like overhead when you’re in a flow state. It sounds like the minimum viable safety net the first time a validation catches a broken link or a secret-shaped string before it goes into a public wiki.
The Python lock wrapper exists because a lock only helps if it always releases — even when the wrapped command crashes or gets interrupted. Python’s try/finally guarantees the release runs as long as you send the right signal. That signal is SIGINT (keyboard interrupt), not SIGTERM (the default for kill). SIGTERM bypasses the finally block and leaves the lock held with no owner, blocking every future write until someone manually deletes the lock file. I learned this by noticing that write attempts were blocking on startup and spending ten minutes confused before checking whether the lock file still existed from a crashed session.
The “run the edit as a /tmp script” rule came from the same hard-won experience. I tried wrapping the write logic as inline bash -c "..." nested inside the Python lock wrapper’s command argument. The quoting was fragile, the wrong stdin went to the wrong command, and two writes were mis-run before I stopped and wrote the edit steps into an explicit script file instead. A script that runs under the lock is boring to write and easy to audit. Nested quoting is neither.
Prerequisites
- A canonical Git vault and the derived consumers you want to keep coherent (a published wiki, a search/embeddings index).
- Node.js 18+ for the validation and generator scripts; Python 3 for the lock wrapper.
- The validation CLIs (
wiki-audit.mjs,build-topic-map.mjs) present in the vault’sscripts/.
Notes
- Substitute your own values before running any line — this playbook uses example stand-ins. Replace
~/wiki(your repo path),/wiki-data/.canonical-writer.lock(your lockfile path), and the/tmp/…script paths. - The audit reads the file list from stdin. Invoke it as
git ls-files -s | node scripts/wiki-audit.mjs .. A barenode scripts/wiki-audit.mjs .blocks forever waiting on stdin — a “hang” that is easy to misdiagnose as a slow network mount. The mount is fine; the tool is waiting for input. - Run the edit as a
/tmpscript under the lock. Never wrap the write as inlinebash -c "..."with nested quoting — that is how real writes got mis-run. Explicit and boring beats clever. - Never
git add -Aon the canonical vault. Stage only the files you changed, so an unrelated stray file (or a secret) can’t ride along. - Abort with SIGINT, not SIGTERM. The wrapper releases the lock in its
finallyblock, which runs onKeyboardInterrupt(SIGINT). SIGTERM can skip cleanup and leave the lock held, which blocks every future write. - Verify the lock is free after an abort with
flock -n <lockfile> -c true. - Mark
tested: trueonly after running a real edit end-to-end, including an intentional validation failure and a SIGINT abort, on your own vault.