A same-day indie API stays at a zero invoice when pyproject.toml is frozen before any model is allowed to edit application code. Paid SDKs almost never arrive as architecture decisions. They arrive as new lines in a dependency file. The freeze is a hash, a read-only bit, and a boot checker that refuses to start.
Solo founders now let models write handlers for an evening slice. The model cannot see a card statement. It can see a missing package. Cache clients, object storage, mail kits, and error vendors show up as ordinary imports. The tree still looks small. The bill does not.
The workflow below is for a single operator who must ship today and accept limits. It stays useful if the generation tool is swapped. SQLite, one process, no mailer, and no worker are the intended ceiling.
The failure the freeze targets
AI-assisted patches optimize for a green import. They do not optimize for next month's invoice. A session store "should be shared" pulls a hosted cache client. A confirm flow "should be real" pulls a mail SDK. Each step is locally reasonable. The stack that results is not the stack the founder chose.
Grep after the fact is late. The cheaper move is to stop the dependency file from changing, then stop imports that sit outside the frozen wheel list. The two checks belong on the boot path, not in a weekly review.
Artifact: seal, allowlist, boot wrapper
Four files sit beside the app. They are small on purpose.
-
pyproject.toml— the only declared dependencies. -
lock.sha256— the committed digest of that file. -
allowed_modules.txt— top-level import names recorded from a known-good boot. -
tools/check_seal.py— hash check plus an AST walk.
The dependency list is a proposal for a Friday slice, not a measured benchmark.
# pyproject.toml — proposal for a zero-invoice notes API
[project]
name = "weekend-notes"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn",
]
sqlite3 stays in the stdlib. No driver package. No cloud kit. The application may import framework code and the standard library. Everything else waits for a human to unseal the file.
Numbered workflow
1. Install once, then seal the file
The founder installs the tiny list on a clean interpreter. The digest is recorded next. The file is then marked read-only in the workspace.
python -m pip install -e .
sha256sum pyproject.toml > lock.sha256
chmod a-w pyproject.toml
git add pyproject.toml lock.sha256
A model can still propose handler patches. It cannot quietly restack the product if the editor honors the write bit. The checker does not trust the write bit. It trusts the hash.
2. Record the real import surface from a cold boot
An allowlist written from memory will be wrong. Frameworks import transitive modules. The honest list is sys.modules after importing the application once on the sealed environment.
# tools/record_allowlist.py — run only on a known-good tree
from __future__ import annotations
import sys
from pathlib import Path
import weekend_notes.main # noqa: F401
ROOT = Path(__file__).resolve().parents[1]
STDLIB = set(sys.stdlib_module_names)
LOCAL = {"weekend_notes", "tools"}
names = sorted(
{
mod.split(".")[0]
for mod in sys.modules
if mod.split(".")[0] not in STDLIB
and mod.split(".")[0] not in LOCAL
and not mod.split(".")[0].startswith("_")
}
)
(ROOT / "allowed_modules.txt").write_text("\n".join(names) + "\n", encoding="utf-8")
print("\n".join(names))
python tools/record_allowlist.py
git add allowed_modules.txt
Re-run this script only when a human upgrades the framework. Do not let a model regenerate it during the same session that writes features. An allowlist that updates itself is not a freeze.
3. Fail boot when the seal or the imports drift
AST beats grep. Comments and string literals do not count as imports. The checker walks every .py file outside virtualenv directories, then compares top-level names to stdlib plus the recorded allowlist.
# tools/check_seal.py
from __future__ import annotations
import ast
import hashlib
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SEAL = ROOT / "lock.sha256"
PYPROJECT = ROOT / "pyproject.toml"
ALLOW = {
line.strip()
for line in (ROOT / "allowed_modules.txt").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#")
}
STDLIB = set(sys.stdlib_module_names)
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "tools"}
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def check_hash() -> list[str]:
expected = SEAL.read_text(encoding="utf-8").split()[0].strip()
actual = digest(PYPROJECT)
if actual != expected:
return [f"pyproject.toml hash {actual} != sealed {expected}"]
return []
def top_module(name: str | None) -> str | None:
if not name:
return None
return name.split(".")[0]
def imports_in(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
top = top_module(alias.name)
if top:
found.add(top)
elif isinstance(node, ast.ImportFrom):
top = top_module(node.module)
if top:
found.add(top)
return found
def check_imports() -> list[str]:
errors: list[str] = []
for path in ROOT.rglob("*.py"):
if any(part in SKIP_DIRS for part in path.parts):
continue
for mod in sorted(imports_in(path)):
if mod in STDLIB or mod in ALLOW:
continue
errors.append(f"{path.relative_to(ROOT)}: disallowed import {mod}")
return errors
def main() -> int:
problems = check_hash() + check_imports()
if problems:
print("seal check failed:")
print("\n".join(problems))
return 1
print("seal check passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Wire it in front of the process. A checker that is optional will be skipped on the night it is needed.
# Makefile
.PHONY: run check
check:
python tools/check_seal.py
run: check
uvicorn weekend_notes.main:app --host 127.0.0.1 --port 8000
make run
4. Keep the coding loop off a billed account
The freeze is wasted when the generation environment already holds cloud keys. Models copy whatever credentials they can see. Empty env is part of the seal.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. Those two availability claims are operator-supplied. No model names, quotas, hardware, or duration are stated here. For this workflow the value is placement: the model and tools/check_seal.py can run on the same machine with vendor keys unset. A founder who wants that loop without attaching a cloud invoice can run the session there.
env -i HOME="$HOME" PATH="$PATH" PYTHONPATH=. \
python tools/check_seal.py
If a patch requires AWS_SECRET_ACCESS_KEY at import time, the API has already left the zero-invoice path. Do not export the key to make the import succeed. Delete the import.
5. Ship a handler that cannot grow a vendor
Notes live in SQLite. Bodies are capped. There is no object store and no mailer. The handler is a proposal for a same-day demo, not a production design.
# weekend_notes/main.py
from __future__ import annotations
import sqlite3
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
DB = Path("notes.db")
app = FastAPI()
MAX_BODY = 500
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB)
conn.execute(
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)"
)
return conn
class NoteIn(BaseModel):
body: str = Field(min_length=1, max_length=MAX_BODY)
@app.post("/notes")
def create_note(payload: NoteIn) -> dict[str, int | str]:
with connect() as conn:
cur = conn.execute("INSERT INTO notes(body) VALUES (?)", (payload.body,))
note_id = int(cur.lastrowid)
return {"id": note_id, "body": payload.body}
@app.get("/notes/{note_id}")
def read_note(note_id: int) -> dict[str, int | str]:
with connect() as conn:
row = conn.execute("SELECT id, body FROM notes WHERE id = ?", (note_id,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="missing")
return {"id": int(row[0]), "body": str(row[1])}
Bind to 127.0.0.1. A public bind is a different product. Attachments, background thumbnails, and "just email me the note" are out of scope for the freeze.
6. Wire a test so the seal is not decoration
One passing test on the current tree is not enough. A second test should prove the checker fails on a banned import.
# tests/test_seal.py
import subprocess
import sys
from pathlib import Path
from tools.check_seal import imports_in
ROOT = Path(__file__).resolve().parents[1]
def test_seal_passes_on_this_tree() -> None:
proc = subprocess.run(
[sys.executable, str(ROOT / "tools" / "check_seal.py")],
check=False,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
def test_seal_flags_stripe(tmp_path: Path) -> None:
sample = tmp_path / "evil.py"
sample.write_text("import stripe\n", encoding="utf-8")
assert "stripe" in imports_in(sample)
python -m pytest tests/test_seal.py
Label both tests as unexecuted examples until they are run on the operator's tree. The point is the hook, not a published coverage number.
Decision table
| Signal in the patch | Action | Bill risk if ignored |
|---|---|---|
pyproject.toml hash changes |
Fail boot; human unseals only | High |
Import of stripe, boto3, redis, celery
|
Fail boot | High |
New stdlib module (hmac, pathlib) |
Allow | None |
| New transitive framework module | Human re-runs record_allowlist.py
|
Low |
| Extra worker or queue process | Reject; stay request-scoped | Medium |
| Env var for a cloud key | Do not export; fail the session | High |
Dynamic importlib of a vendor name |
Out of scope; see limitations | High |
Limitations
The AST walk does not catch dynamic imports built from strings. importlib.import_module("st" + "ripe") bypasses it. A patch can also shell out to pip. The seal assumes the model is sloppy about vendors, not adversarial.
sys.stdlib_module_names is a convenience set, not a security boundary. Some stdlib modules still open sockets. The freeze does not replace a network policy. It only blocks the common path where a paid SDK is added as a normal dependency.
Transitive allowlists drift when the framework is upgraded. Upgrades are a human event. They require a new hash and a new allowed_modules.txt. Do not let the model bump versions during a Friday session.
The notes API stores plaintext on a local file. That is unacceptable for secrets, identity documents, or multi-tenant data. Same-day indie scope only.
Free model access and a free server option are availability claims with unspecified capacity and duration. They can change. Do not plan payroll, quotas, or an SLA around them.
Who should not use this
Teams with a real billing account, a shared cache, and a compliance mailer should not freeze the lockfile this hard. Multi-service products that already pay for vendors gain little from a weekend seal.
Founders who need guaranteed uptime, signed production artifacts, or isolated CI runners need a stronger pipeline than a hash file in git. If the model must install packages to finish the task, the task is not a same-day zero-invoice slice. Split the task.
The seal does not make the model a senior engineer. It does not prove the API is correct. It proves the tree did not grow a vendor between the last human decision and the next local boot. For a solo founder who wants to ship today and read an empty invoice tomorrow, that proof is the whole point.
Top comments (0)