{"slug": "five-silent-assumptions-that-turn-ai-code-into-debt", "title": "Five Silent Assumptions That Turn AI Code Into Debt", "summary": "A developer has cataloged five silent assumptions that turn AI-generated code into technical debt, treating them as merge blockers. The anti-patterns include invented environment variables, unvetted imports, missing auth, incorrect schema references, and hidden side effects. The developer proposes a constraints file and gate script to enforce rules on staged diffs.", "body_md": "Generated code looks cheap until the assumptions land. You own every invented config, schema, and side effect. I now treat silent model assumptions as merge blockers.\n\nDid the model ask about your auth scheme? Did it confirm the table names you actually have? If not, you did not get a patch. You got a guess.\n\nThis is a catalog, not a pep talk. Each anti-pattern has a symptom, a root cause, and a replacement. Then I give you a constraint file and a gate script you can run on a staged diff.\n\nAgents fill gaps because that is their job. Gap filling helps during a short spike. It becomes poison inside a brownfield service.\n\nCheap tokens make the guesses faster. They do not make the guesses true. Technical debt arrives as confident Python.\n\nI keep a constraints file next to the repo. The model must read it before it writes. A small gate fails the patch when it invents facts.\n\n**Symptom**\n\nThe patch reads `PAYMENT_WEBHOOK_SECRET`\n\non boot. Your secrets manager has never heard of it. Staging dies inside `os.environ[...]`\n\n.\n\n**Root cause**\n\nThe model completed a typical tutorial shape. Tutorials always hide one missing env var. Nobody listed the real allowlist.\n\n**Replacement**\n\nPublish an env allowlist in the repo. Reject new keys unless a human adds them.\n\n```\n# constraints.yml\nenv_allowlist:\n  - DATABASE_URL\n  - REDIS_URL\n  - APP_ENV\nforbidden_env_prefixes:\n  - AWS_\n  - STRIPE_\n  - OPENAI_\n```\n\nAsk yourself: who named that variable? If the answer is \"the model,\" delete it.\n\n**Symptom**\n\n`requirements.txt`\n\ngains `httpx`\n\nor `orjson`\n\n. Sometimes the name does not even exist. CI installs it, or CI cannot install it.\n\n**Root cause**\n\nThe model optimized for a blog-post stack. It did not read your lockfile. Convenience beat your supply-chain rules.\n\n**Replacement**\n\nDiff imports against the lockfile. Unknown import roots fail the gate. Humans add libraries on purpose.\n\n```\nALLOWED_IMPORT_ROOTS = {\"flask\", \"sqlalchemy\", \"redis\", \"pydantic\"}\n```\n\nWould you merge a mystery wheel from the internet? Then do not merge a mystery import either.\n\n**Symptom**\n\nThe new endpoint has no auth decorator. Or it checks a header the gateway never sets. Or it trusts `user_id`\n\nfrom the JSON body.\n\n**Root cause**\n\nDemos skip auth to keep the snippet small. The model learned those demos. Your threat model never traveled in the prompt.\n\n**Replacement**\n\nState the auth contract in constraints. Every HTTP handler must match one pattern. No pattern, no merge.\n\n```\nhttp:\n  must_use_decorator: \"require_session\"\n  forbid_body_fields_as_identity:\n    - user_id\n    - account_id\n    - is_admin\n```\n\nCan an anonymous caller hit this route? If you cannot answer, the patch is incomplete.\n\n**Symptom**\n\nThe query selects `users.uuid`\n\n. Your table has `users.id`\n\n. Or the patch adds `metadata`\n\nJSON nobody migrated.\n\n**Root cause**\n\nLanguage models remember popular schemas. They do not remember yours. A plausible column is still a lie.\n\n**Replacement**\n\nCheck identifiers against a schema dump. I keep `schema/tables.txt`\n\ngenerated from migrations. Unknown columns fail the same way unknown env fails.\n\n```\n# schema/tables.txt (generated, not hand-waved)\nusers.id\nusers.email\nusers.created_at\norders.id\norders.user_id\norders.total_cents\n```\n\nDid you run the migration, or did the model imagine it? Imagination is not a migration.\n\n**Symptom**\n\nThe helper writes `/tmp/cache.json`\n\n. It shells out to `curl`\n\n. It logs access tokens at INFO.\n\n**Root cause**\n\nThe model \"finished\" the function. Finishing is not the same as isolating. Side effects feel like completeness.\n\n**Replacement**\n\nBan whole families of calls in generated diffs. Allow them only in named modules. Keep the blast radius tiny.\n\n```\nside_effects:\n  forbid_substrings:\n    - \"subprocess.\"\n    - \"os.system(\"\n    - \"pathlib.Path('/tmp\"\n    - \"open('/tmp\"\n  forbid_log_names:\n    - password\n    - token\n    - authorization\n```\n\nIf a spike needs `/tmp`\n\n, put it in `scratch/`\n\n. Do not let it ride into `app/`\n\n.\n\nHere is a compact patch I would reject on sight. It looks helpful. It is five anti-patterns in one function.\n\n``` python\n+ @app.route(\"/refunds\", methods=[\"POST\"])\n+ def refunds():\n+     key = os.environ[\"STRIPE_KEY\"]\n+     import requests\n+     user_id = request.json[\"user_id\"]\n+     row = db.execute(\"SELECT uuid FROM users WHERE id=%s\", user_id)\n+     open(\"/tmp/refunds.log\", \"a\").write(str(request.json))\n+     return {\"ok\": True, \"user_id\": user_id}\n```\n\nWhat did the model invent? A secret name. A new HTTP client. Identity from the body. A column you do not have. A world-readable temp log.\n\nThe gate below should print failures, not a green check. If it passes this diff, your allowlists are too wide.\n\nHere is a small checker you can copy. It is a heuristic, not a full program analysis. Label it as a merge gate, not a proof.\n\nSave `constraints.yml`\n\nat the repo root. Save this script as `tools/assumption_gate.py`\n\n. Feed it a unified diff from the model.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Fail a generated diff that invents facts.\n\nThis is a proposed gate. Run it on your own diffs.\nIt does not execute the patch. It only scans added text.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport re\nimport sys\nfrom pathlib import Path\n\nimport yaml\n\nENV_RE = re.compile(r\"os\\.environ(?:\\[|\\.get\\()['\\\"]([A-Z0-9_]+)\")\nIMPORT_RE = re.compile(r\"^(?:from|import)\\s+([a-zA-Z0-9_\\.]+)\", re.M)\nIDENT_RE = re.compile(r\"\\b([a-z_][a-z0-9_]*)\\.([a-z_][a-z0-9_]*)\\b\")\nROUTE_RE = re.compile(r\"@app\\.route|@router\\.\")\n\ndef load_constraints(path: Path) -> dict:\n    data = yaml.safe_load(path.read_text())\n    if not isinstance(data, dict):\n        raise ValueError(\"constraints.yml must be a mapping\")\n    return data\n\ndef added_lines(diff_text: str) -> str:\n    lines = []\n    for line in diff_text.splitlines():\n        if line.startswith(\"+\") and not line.startswith(\"+++\"):\n            lines.append(line[1:])\n    return \"\\n\".join(lines)\n\ndef main() -> int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--diff\", required=True)\n    parser.add_argument(\"--constraints\", default=\"constraints.yml\")\n    parser.add_argument(\"--schema\", default=\"schema/tables.txt\")\n    args = parser.parse_args()\n\n    constraints = load_constraints(Path(args.constraints))\n    diff = Path(args.diff).read_text(encoding=\"utf-8\")\n    added = added_lines(diff)\n    failures: list[str] = []\n\n    allow_env = set(constraints.get(\"env_allowlist\", []))\n    for key in ENV_RE.findall(added):\n        if key not in allow_env:\n            failures.append(f\"phantom env: {key}\")\n\n    allowed_imports = set(constraints.get(\"allowed_import_roots\", []))\n    for raw in IMPORT_RE.findall(added):\n        root = raw.split(\".\")[0]\n        if allowed_imports and root not in allowed_imports:\n            failures.append(f\"unapproved import: {root}\")\n\n    schema_path = Path(args.schema)\n    if schema_path.exists():\n        allowed_cols = {\n            tuple(line.strip().split(\".\", 1))\n            for line in schema_path.read_text().splitlines()\n            if \".\" in line\n        }\n        known_tables = {table for table, _ in allowed_cols}\n        for table, col in IDENT_RE.findall(added):\n            if table in known_tables and (table, col) not in allowed_cols:\n                failures.append(f\"invented column: {table}.{col}\")\n\n    for blob in constraints.get(\"side_effects\", {}).get(\"forbid_substrings\", []):\n        if blob in added:\n            failures.append(f\"side effect: {blob!r}\")\n\n    decorator = constraints.get(\"http\", {}).get(\"must_use_decorator\")\n    if decorator and ROUTE_RE.search(added) and decorator not in added:\n        failures.append(\"route without auth decorator\")\n\n    identity_fields = constraints.get(\"http\", {}).get(\n        \"forbid_body_fields_as_identity\", []\n    )\n    for field in identity_fields:\n        if re.search(rf\"json\\[['\"]{field}['\"]\\]\", added):\n            failures.append(f\"body used as identity: {field}\")\n\n    if not failures:\n        print(\"assumption gate: pass\")\n        return 0\n\n    print(\"assumption gate: fail\")\n    for item in failures:\n        print(f\" - {item}\")\n    return 1\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nRun it like this:\n\n```\ngit diff --staged > /tmp/staged.diff\npython tools/assumption_gate.py --diff /tmp/staged.diff\n```\n\nNo staged diff? Pipe the model output through `diff -u /dev/null`\n\n. The gate still sees every added line. That is enough to catch the five patterns above.\n\nExpected output on the refunds example:\n\n```\nassumption gate: fail\n - phantom env: STRIPE_KEY\n - unapproved import: requests\n - invented column: users.uuid\n - side effect: \"open('/tmp\"\n - route without auth decorator\n - body used as identity: user_id\n```\n\nIf that list is empty, the gate is not wired. Fix the constraints before you blame the model.\n\n| If the diff... | Treat it as | Human action |\n|---|---|---|\n| Adds an env key | Phantom config | Add to allowlist or delete |\n| Adds an import root | Unapproved dependency | Lockfile first, then code |\n| Adds a route, no decorator | Happy-path auth | Wrap or reject |\nUses unknown `table.col`\n|\nInvented schema | Dump schema, then rewrite |\nTouches `/tmp` or `subprocess`\n|\nInvisible side effect | Move to `scratch/` or drop |\n\nPrint this table in the PR template. Reviewers stop arguing taste. They argue facts.\n\nDo not ask for \"a refunds endpoint.\" Ask for a diff that obeys the file. Keep the prompt boring and strict.\n\n```\nRead constraints.yml and schema/tables.txt.\nReturn a unified diff only.\nDo not add env keys outside env_allowlist.\nDo not add import roots outside allowed_import_roots.\nDo not invent columns.\nEvery new route must use require_session.\nIf a fact is missing, ask a question. Do not guess.\n```\n\nThen paste the gate failures back. The second turn should shrink, not sprawl. If it sprawls, the model is still filling gaps. Stop and edit by hand.\n\nI want the model to propose code. I do not want it to propose reality. Those are different jobs.\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach. I run the generate-then-gate loop on MonkeyCode's free models and free server. The server is a lab for the checker, not a factory for production traffic.\n\nThe loop is boring on purpose:\n\n`assumption_gate.py`\n\n.Free model access makes the retries cheap. The gate makes the retries honest. Without the gate, cheap retries just multiply debt.\n\nThis gate reads text. It does not run tests. It will miss a renamed import alias. It will miss SQL built with f-strings.\n\nObject attributes look like columns. `user.email`\n\ncan false-positive when `users.email`\n\nis real. Keep the schema file on table names, not on instance names. Review those hits instead of auto-fixing them.\n\nIt can also nag on legitimate new columns. That is the point of a human allowlist. A noisy fail is better than a silent schema lie.\n\nDo not call this a security audit. Do not skip unit tests because the gate passed. Do not point the lab server at production databases.\n\nRegex will rot as your framework changes. Budget an hour when you upgrade the web layer. Update the decorator name. Update the import roots.\n\nSkip this if you have no lockfile. Skip this if you cannot dump schema. Skip this if the repo is a throwaway spike.\n\nAlso skip it for generated front-end CSS churn. The patterns above target service code. A linter war on class names helps nobody.\n\nIf you cannot review the allowlists, stop. An outdated allowlist becomes a rubber stamp. Rubber stamps are how assumptions sneak back.\n\nI want candidate diffs. I want them small. I want every new fact to be named.\n\nAsk the model: which constraints did you use? Ask it: which facts did you invent anyway? If it cannot list them, distrust the patch.\n\nCheap code is a throughput trick. Assumption control is the actual engineering. Keep the catalog next to the gate, not in a wiki.", "url": "https://wpnews.pro/news/five-silent-assumptions-that-turn-ai-code-into-debt", "canonical_source": "https://dev.to/codex_1135/five-silent-assumptions-that-turn-ai-code-into-debt-3jk5", "published_at": "2026-09-04 06:36:34+00:00", "updated_at": "2026-09-04 06:53:45.956334+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/five-silent-assumptions-that-turn-ai-code-into-debt", "markdown": "https://wpnews.pro/news/five-silent-assumptions-that-turn-ai-code-into-debt.md", "text": "https://wpnews.pro/news/five-silent-assumptions-that-turn-ai-code-into-debt.txt", "jsonld": "https://wpnews.pro/news/five-silent-assumptions-that-turn-ai-code-into-debt.jsonld"}}