{"slug": "hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor", "title": "Hash the Side-Effect Ledger Before You Accept a Cleanup Refactor", "summary": "A developer proposes a workflow to prevent coding agents from breaking hidden couplings during cleanup refactors. The method involves freezing a ledger of side effects and storing a SHA-256 hash before any structural changes, ensuring the hash remains identical to validate behavior preservation. The approach addresses the failure mode where return-value tests pass but implicit file layouts or environment dependencies shift.", "body_md": "Messy modules rarely break because a pure helper returns the wrong integer on a tidy fixture. They break because three functions share a temporary CSV path, an environment flag, and a cache nobody named. A coding agent then proposes a cleanup that deletes dead branches, renames locals, and still satisfies every existing assertion. The next production export fails because the implicit file layout moved while the return payload stayed identical.\n\nThat failure mode is the reason this workflow exists, and it is not a style problem. The first commit should freeze a ledger of hidden couplings and store a hash beside it. Only after that hash is in source control should you allow one structural change. The cleanup is legitimate only when the recorded hash remains identical.\n\nFeature work usually changes an observable on purpose, so reviewers know which assertions must move. Cleanup work is sold as behavior-preserving, which trains people to trust deletions and rename-only hunks. Coding agents amplify that bias because they optimize for shorter files, conventional names, and green unit tests. Reviewers then accept large deletions that would look suspicious inside a feature pull request.\n\nReturn-value tests are the wrong gate for that class of change. The public function can still return `{\"ok\": true, \"rows\": 12}` while the working directory quietly shifts. Downstream jobs that glob files or catch a named exception will fail after merge. Those hidden couplings remain part of the contract even when no unit test mentions them.\n\nTreat the messy module as a black box that emits more than a return value. A ledger is a canonical JSONL file with one record per fixture and fully sorted keys. Side-effect entries need stable ordering so the serialized bytes stay deterministic across reruns. The SHA-256 digest of that file is the only number that must remain constant.\n\nEach record should capture the following fields and omit anything that varies by machine:\n\n`return`, `raise`, or `timeout`\nCanonicalize every path against the sandbox root before you serialize the record. Wall-clock timestamps and absolute home-directory prefixes make the hash flaky on contact. A flaky gate teaches the team to skip the protocol, which is worse than having no gate.\n\n`ledger.jsonl` plus `ledger.sha256` with no production code changes.\nIf the hash changes, the cleanup is not a cleanup and should not keep that label. Treat the diff as a behavior change, add an intentional test, and restart the protocol. Agents that continue after a mismatch are doing product work under a refactor heading.\n\nThe script below is an unexecuted example you can adapt to one entrypoint. It does not claim production coverage numbers, and it will miss native writes outside the sandbox. Read it as a starting template rather than as a library you vendor unchanged.\n\n```\n# ledger_recorder.py — proposal: pin hidden couplings for one entrypoint\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport os\nimport sys\nimport traceback\nfrom pathlib import Path\nfrom typing import Any, Callable\n\nSANDBOX = Path(os.environ[\"LEDGER_SANDBOX\"]).resolve()\nLEDGER_PATH = Path(os.environ.get(\"LEDGER_PATH\", \"ledger.jsonl\"))\n\ndef canonicalize(path: Path) -> str:\n    try:\n        return str(path.resolve().relative_to(SANDBOX))\n    except ValueError:\n        return f\"<outside>/{path.name}\"\n\ndef digest_args(args: tuple[Any, ...]) -> str:\n    blob = json.dumps(args, default=str, sort_keys=True).encode()\n    return hashlib.sha256(blob).hexdigest()[:12]\n\ndef record_call(name: str, args: tuple[Any, ...], fn: Callable[..., Any]) -> dict[str, Any]:\n    env_reads: set[str] = set()\n    before = {\n        canonicalize(p): p.stat().st_mtime_ns\n        for p in SANDBOX.rglob(\"*\")\n        if p.is_file()\n    }\n    real_getenv = os.getenv\n\n    def wrapped_getenv(key: str, default: Any = None) -> Any:\n        env_reads.add(key)\n        return real_getenv(key, default)\n\n    os.getenv = wrapped_getenv  # type: ignore[assignment]\n    try:\n        value = fn(*args)\n        result = {\"kind\": \"return\", \"value\": value, \"error\": None}\n    except Exception as exc:\n        result = {\n            \"kind\": \"raise\",\n            \"value\": None,\n            \"error\": type(exc).__name__,\n            \"trace_tail\": traceback.format_exc().splitlines()[-1],\n        }\n    finally:\n        os.getenv = real_getenv  # type: ignore[assignment]\n\n    after = {\n        canonicalize(p): p.stat().st_mtime_ns\n        for p in SANDBOX.rglob(\"*\")\n        if p.is_file()\n    }\n    files: set[tuple[str, str]] = set()\n    for rel in sorted(set(before) | set(after)):\n        if rel not in before:\n            files.add((\"create\", rel))\n        elif rel not in after:\n            files.add((\"remove\", rel))\n        elif before[rel] != after[rel]:\n            files.add((\"append\", rel))\n\n    return {\n        \"callable\": name,\n        \"args_digest\": digest_args(args),\n        \"kind\": result[\"kind\"],\n        \"error\": result[\"error\"],\n        \"value\": result[\"value\"],\n        \"env_reads\": sorted(env_reads),\n        \"files\": sorted(files),\n        \"cwd\": canonicalize(Path.cwd()),\n        \"sys_path_heads\": [\n            canonicalize(Path(p)) if Path(p).exists() else p for p in sys.path[:3]\n        ],\n    }\n\ndef write_ledger(rows: list[dict[str, Any]]) -> str:\n    canonical = [json.dumps(row, sort_keys=True, default=str) for row in rows]\n    canonical.sort()\n    LEDGER_PATH.write_text(\"\\n\".join(canonical) + \"\\n\", encoding=\"utf-8\")\n    digest = hashlib.sha256(LEDGER_PATH.read_bytes()).hexdigest()\n    Path(\"ledger.sha256\").write_text(digest + \"\\n\", encoding=\"utf-8\")\n    return digest\n\ndef main() -> None:\n    # Proposal: replace with the real entrypoint and replayable fixtures.\n    from app.export import run_export  # labeled example import\n\n    os.chdir(SANDBOX)\n    fixtures = [\n        (\"run_export\", (\"2026-09-08\",)),\n        (\"run_export\", (\"2026-09-09\",)),\n    ]\n    rows = [record_call(name, args, run_export) for name, args in fixtures]\n    print(write_ledger(rows))\n\nif __name__ == \"__main__\":\n    main()\n```\n\nA matching check belongs in continuous integration as a command, not as a comment on the pull request. The commands assume a clean sandbox and a hash file committed on the characterization branch.\n\n```\nexport LEDGER_SANDBOX=\"$(pwd)/.ledger-sandbox\"\nrm -rf \"$LEDGER_SANDBOX\"\nmkdir -p \"$LEDGER_SANDBOX/fixtures\"\ncp -R tests/fixtures/. \"$LEDGER_SANDBOX/fixtures/\"\npython ledger_recorder.py\ntest \"$(tr -d '[:space:]' < ledger.sha256)\" = \"$(sha256sum ledger.jsonl | awk '{print $1}')\"\ngit diff --name-only origin/main...HEAD | awk 'END { if (NR > 2) { print \"touch budget exceeded\"; exit 1 } }'\n```\n\nThe final command is the file-touch budget for the cleanup commit itself. Diffs that rewrite six files while claiming no behavior change should fail even when the hash matches. Reviewers cannot audit accidental protocol shifts across that much surface in one sitting.\n\nUse the table as the only negotiation surface with the agent session. Anything outside the selected row is a new task, not a continuation of the cleanup. Put the selected row in the prompt and omit future style goals that would invite extra edits.\n\n| Observed coupling | Safe first change | Hash must stay | Touch budget | \n|---|---|---|---|\n| Shared temp CSV path | Extract `export_path(sandbox, date)` and call it from one site | Yes | 1 impl + 1 test | \n| Process-global cache dict | Pass the cache into the entrypoint; do not rename keys | Yes | 1 impl + 1 test | \n| `os.getenv(\"EXPORT_MODE\")` | Read the flag once at the edge; keep the default identical | Yes | 1 impl + 1 test | \n| Catches `ValueError` by name | Leave the type; do not switch to a custom hierarchy yet | Yes | 0 impl if the type would change | \n| Absolute `/tmp` writes | Relocate writes under the sandbox root only | Yes | 1 impl + 1 test | \n\nIf two rows look tempting, pick the coupling that already causes production incidents and defer the rest. Parallel cleanups destroy the meaning of the hash because a mismatch cannot be attributed to one seam. The agent should see the selected row and the current ledger hash, then stop.\n\nLocal laptops pollute characterization hashes through leftover files, extra environment variables, and home-directory prefixes. A dedicated runner with a known sandbox root removes that noise from the digest. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can draft the single-seam patch from the decision table, and the free server option can execute the recorder so the hash is not a function of your laptop.\n\nThat split is more useful than another chat transcript about naming style. The model proposes one change inside the touch budget, and the server replays the fixtures. You accept the diff only when both gates pass on the same job log. Skip any run that cannot pin the Python version, working directory, and fixture tree.\n\nThe ledger does not see network I/O you forgot to stub or threads that flush after the recorder returns. Native extensions that write outside `Path.rglob` will also slip past the file list. Hash stability still depends on canonical JSON and on redacting values that embed timestamps.\n\nThis protocol is slower than asking an agent to clean the module in one pass. It will reject useful renames that change an exception type or a filename pattern on purpose. Those edits are product changes and need an explicit test update, not a cleanup label.\n\nDo not use a side-effect ledger on greenfield code where the public contract still moves every day. Do not use it as a substitute for a typed interface when you already have a stable API module. Do not point an agent at the whole repository and then widen the touch budget until the gate becomes theater.\n\nIf the module's only consumers are humans clicking a button, return-value checks may already be enough. The ledger earns its keep when hidden couplings are the real API for downstream jobs. Matching hashes plus a two-file budget is a boring gate, and boring gates are how messy modules survive a first structural change.", "url": "https://wpnews.pro/news/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor", "canonical_source": "https://dev.to/webx_2736/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor-1je8", "published_at": "2026-09-08 03:31:41+00:00", "updated_at": "2026-09-08 04:00:59.647150+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "mlops"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor", "markdown": "https://wpnews.pro/news/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor.md", "text": "https://wpnews.pro/news/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor.txt", "jsonld": "https://wpnews.pro/news/hash-the-side-effect-ledger-before-you-accept-a-cleanup-refactor.jsonld"}}