A solo founder can keep a cheap coding model useful and a hosting bill at zero by rejecting any patch that crosses declared module lines before a model is asked to talk. Path ownership is a blunt rule. It is also fast, deterministic, and cheap enough to run on a free box between git add and git push.
AI-assisted diffs fail in a boring way for indie codebases. The model does not only edit the file that was named. It “helpfully” adds a helper under another package, retouches a shared util, and leaves a test in the wrong tree. The change may even run. The repo still becomes a junk drawer.
This workflow does not grade architecture taste. It answers one question: does this unified diff stay inside one declared module plus that module’s allowed extras? If the answer is no, the fence fails closed. A free model is optional and runs only after the reject, to emit a one-line reason the founder can paste into a commit note.
What the fence actually checks
The fence reads a committed BOUNDARIES.yml, parses a unified diff, and maps every changed path to a module root. Shared files are allowed from any module. Two module roots in one patch are not.
The rule is path-based. Rename tricks, generated files, and semantic coupling are out of scope. That is the point for a zero-bill solo stack: a check that finishes in milliseconds and does not spend tokens on green diffs.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Indie founders who already ship on a free server can keep the fence next to a hook and spend free-model calls only on rejected hunks. MonkeyCode currently offers free model access and a free server option for that loop. No model name, quota, or hardware claim is required to run the scripts below.
1. Declare ownership in one file
Commit this next to the code. Treat it as a product file, not a comment in a prompt.
# BOUNDARIES.yml
version: 1
fail_closed: true
max_files: 24
modules:
- name: billing
roots:
- src/billing/
may_touch:
- tests/billing/
- name: web
roots:
- src/web/
may_touch:
- tests/web/
- templates/
- name: jobs
roots:
- src/jobs/
may_touch:
- tests/jobs/
shared:
- README.md
- pyproject.toml
- BOUNDARIES.yml
- scripts/fence_diff.py
A patch that touches src/billing/invoice.py and tests/billing/test_invoice.py passes. A patch that also edits src/web/routes.py fails. Shared files do not count as a second module.
2. Parse the diff without calling a model
Save as scripts/fence_diff.py. The script is the artifact. It must work with no network.
#!/usr/bin/env python3
"""Fail closed when a unified diff crosses module fences."""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
def load_spec(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
if yaml is not None:
return yaml.safe_load(text)
# Minimal fallback: refuse to guess YAML if PyYAML is missing.
raise SystemExit("install pyyaml or pass JSON via --spec-json")
def changed_paths(diff: str) -> list[str]:
paths: list[str] = []
for line in diff.splitlines():
if line.startswith("+++ b/"):
p = line[6:].strip()
if p != "/dev/null":
paths.append(p)
elif line.startswith("diff --git "):
parts = line.split(" b/", 1)
if len(parts) == 2:
paths.append(parts[1].strip())
# Preserve order, drop duplicates from combined headers.
seen: set[str] = set()
out: list[str] = []
for p in paths:
if p not in seen:
seen.add(p)
out.append(p)
return out
def owner_of(path: str, spec: dict) -> str:
for item in spec.get("shared", []):
if path == item or path.startswith(str(item).rstrip("/") + "/"):
return "shared"
for mod in spec.get("modules", []):
prefixes = list(mod.get("roots", [])) + list(mod.get("may_touch", []))
for pre in prefixes:
if path == pre.rstrip("/") or path.startswith(pre):
return mod["name"]
return "unknown"
def evaluate(diff: str, spec: dict) -> dict:
files = changed_paths(diff)
owners = {p: owner_of(p, spec) for p in files}
modules = sorted({o for o in owners.values() if o not in {"shared"}})
reasons: list[str] = []
max_files = int(spec.get("max_files", 50))
if not files:
reasons.append("empty diff")
if len(files) > max_files:
reasons.append(f"too many files: {len(files)} > {max_files}")
if "unknown" in owners.values():
bad = [p for p, o in owners.items() if o == "unknown"]
reasons.append("unfenced paths: " + ", ".join(bad))
if len(modules) > 1:
reasons.append("cross-module: " + ", ".join(modules))
ok = not reasons
if spec.get("fail_closed", True) is False and not ok:
ok = True # never enable this on a shipping branch
return {
"ok": ok,
"files": files,
"owners": owners,
"modules": modules,
"reasons": reasons,
}
def maybe_explain(result: dict, model_url: str, token: str) -> str:
if result["ok"] or not model_url:
return ""
payload = json.dumps(
{
"prompt": (
"One sentence, no rewrite, no extra files. "
"Why this diff is out of bounds: "
+ json.dumps(result["reasons"])
+ " owners="
+ json.dumps(result["owners"])
)
}
).encode("utf-8")
req = urllib.request.Request(
model_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.read().decode("utf-8", errors="replace")[:500]
except (urllib.error.URLError, TimeoutError) as exc:
return f"explain skipped: {exc}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--spec", default="BOUNDARIES.yml")
parser.add_argument("--stdin", action="store_true")
parser.add_argument("--explain", action="store_true")
args = parser.parse_args()
spec = load_spec(Path(args.spec))
diff = sys.stdin.read() if args.stdin else sys.stdin.read()
result = evaluate(diff, spec)
print(json.dumps(result, indent=2))
if args.explain:
note = maybe_explain(
result,
os.environ.get("MODEL_URL", ""),
os.environ.get("MODEL_TOKEN", ""),
)
if note:
print(note)
return 0 if result["ok"] else 2
if __name__ == "__main__":
raise SystemExit(main())
Install the one runtime extra on the laptop or the free server:
python3 -m pip install --user pyyaml
chmod +x scripts/fence_diff.py
git diff --cached | python3 scripts/fence_diff.py --stdin
echo $?
Exit 2 means do not commit. Exit 0 means the path set is legal. No tokens moved.
3. Wire it as a hook, then as a tiny HTTP check
Local first. Shipping today means the founder feels the fail before a push.
# .git/hooks/pre-commit
#!/bin/sh
set -e
git diff --cached --unified=0 | python3 scripts/fence_diff.py --stdin
chmod +x .git/hooks/pre-commit
A free server can expose the same function for a laptop that is not the source of truth. The handler below is labeled as a sketch. It is not production auth.
# scripts/fence_http.py — proposal for a single-user box
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import json
from fence_diff import evaluate, load_spec
SPEC = load_spec(Path("BOUNDARIES.yml"))
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/check":
self.send_error(404)
return
n = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(n).decode("utf-8", errors="replace")
result = evaluate(raw, SPEC)
body = json.dumps(result).encode("utf-8")
self.send_response(200 if result["ok"] else 422)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8077), Handler).serve_forever()
Bind to localhost. Put a reverse proxy and a secret header in front if the box is reachable. The fence is not an auth product.
4. Spend free-model tokens only on rejects
Green diffs stay silent. Rejects may call MODEL_URL with the JSON reasons only. The prompt forbids a rewrite on purpose. A solo founder needs a sentence for the commit log, not a second patch that will fail the same fence.
export MODEL_URL="https://example.invalid/v1/explain" # replace with the operator endpoint
export MODEL_TOKEN="" # leave empty until the box is private
git diff --cached | python3 scripts/fence_diff.py --stdin --explain
Decision table for when the model is allowed to run:
| Diff result | Files | Model call | Founder action |
|---|---|---|---|
| one module + tests | under max_files
|
none | commit |
| shared files only | any | none | commit |
| two module roots | any | optional one-liner | split the patch |
| unfenced path | any | optional one-liner | extend YAML or drop file |
| empty / binary mess | 0 or huge | none | stop, inspect git diff
|
The table is the policy. Prompts are not the policy.
5. Reproduce with a three-file fixture
Do not wait for a real feature branch. Drop three tiny files and run the script twice.
mkdir -p src/billing src/web tests/billing
echo 'def total(n): return n' > src/billing/invoice.py
echo 'def ping(): return "ok"' > src/web/health.py
echo 'from src.billing.invoice import total\ndef test_total(): assert total(1) == 1' > tests/billing/test_invoice.py
git init -q
git add BOUNDARIES.yml src tests scripts
git commit -m "fence baseline" >/dev/null
Legal staged patch:
echo 'def total(n): return n * 2' > src/billing/invoice.py
git add src/billing/invoice.py tests/billing/test_invoice.py
git diff --cached | python3 scripts/fence_diff.py --stdin; echo exit:$?
Illegal staged patch:
git add src/web/health.py
git diff --cached | python3 scripts/fence_diff.py --stdin; echo exit:$?
Expected: first command prints one module billing and exits 0. Second command lists billing and web, exits 2. If both exit 0, the YAML roots are wrong.
Limits the fence will not hide
Path prefixes are not architecture. A billing file can still import a web helper. The fence will not see that. It will also miss copies that avoid the prefix, generated trees that were never listed, and binary assets.
--explain can fail open on network errors by design: the reject already happened. The model note is commentary. It is not a second gate. Do not point MODEL_URL at an untrusted host. Do not put tokens in the repo.
This approach is a poor fit for monorepos with generated Bazel graphs, for teams that already enforce CODEOWNERS in paid CI, and for anyone who needs semantic review, license scanning, or secret detection. Those are different tools. Stacking a free model on top of an unbounded diff still wastes the allowance and still ships the junk drawer.
A solo founder who needs to ship today can live with the blunt rule. Keep the YAML smaller than the prompt history. Keep the model off the happy path. Keep the bill at zero by refusing to pay for a green check the filesystem already knew.
Top comments (0)