DEV Community

Avery Lin
Avery Lin

Posted on

Hash-Bound Docs: Redraft Stale Sections Without Touching Human Narrative

Generated documentation fails when prose still looks current after the described source files have already changed. The useful ownership split is not draft versus review, but hash-bound sections versus unbound human-owned narrative. Hash-bound sections may be redrafted only when their recorded git blob hashes no longer match HEAD. Unbound sections stay human-owned, never enter a regeneration queue, and must fail a check if a model rewrites them.

This article proposes a regeneration map, a small checker script, and a four-state decision table. The workflow is labeled as a proposal and does not report production metrics or private customer results. Teams that already keep docs in git can adopt the map without changing their publishing toolchain.

Silent staleness is the cheap-redraft failure

Model-assisted drafting makes it inexpensive to regenerate an entire markdown file after every interface tweak. That cheapness hides a second failure: human-owned rationale is overwritten while unchanged code paths receive unnecessary new prose. Reviewers then cannot tell which paragraphs were meant to stay frozen and which paragraphs are allowed to move with the tree.

A git blob hash is a practical freshness signal because the repository already computes it for every tracked file. When a bound source file changes, only the sections that list that path should become stale. When a human-owned section changes, the checker should demand a human reviewer rather than another model rewrite.

Cheap generation increases documentation debt in a specific way that token counts do not capture. The debt that matters here is a heading that looks current while its sources moved, or a heading that moved while its sources did not. A regeneration map makes that debt visible as states instead of as an editorial feeling during review.

Two section classes, plus one failure class

Treat every heading in a long document as one of two written classes before any model is invoked. Classification belongs in a reviewed YAML file, not in a prompt that can be edited casually during a drafting session.

  1. Hash-bound, model-draftable. These sections describe mechanical facts recoverable from source: endpoint tables, flag names, error codes, CLI flags, and schema fields. Each section lists one or more source paths and stores the blob hash last accepted with the drafted prose.
  2. Unbound, human-owned. These sections hold intent that source files cannot prove: threat models, SLAs, deprecation policy, support boundaries, and rejected designs. They store no source pin and set regenerate: never.

A third class appears only at runtime and should always fail the build. Unknown headings exist in the markdown file but not in the map, which is how silent rewrites enter a document. Unmapped text is not a draftable backlog; it is a policy violation until a human classifies it.

If a section would remain true after a rename-only refactor, it is probably model-draftable. If a section would remain true after deleting the implementation, it is probably human-owned. Write that rule in the pull-request template so classification does not drift by author, and reject maps that mark incident history or legal notices as ownership: model.

Artifact: a regeneration map beside the document

The proposed map lives next to the markdown file, not inside a chat transcript. Keep it small, explicit, and reviewed like any other config that gates merge.

# docs/api.regen.yaml
version: 1
document: docs/api.md
states:
  allowed: [fresh, stale, human_locked, unmapped]
sections:
  - id: why-we-ship-this
    heading: "## Why this API exists"
    ownership: human
    regenerate: never
    body_sha256: "PENDING_HUMAN_SLICE"
  - id: auth-rules
    heading: "## Authentication rules"
    ownership: human
    regenerate: never
    body_sha256: "PENDING_HUMAN_SLICE"
  - id: endpoint-index
    heading: "## Endpoint index"
    ownership: model
    regenerate: when_source_stale
    sources:
      - path: src/http/routes.ts
        blob_sha: "e3b0c44298fc1c149afbf4c8996fb924"
      - path: src/http/errors.ts
        blob_sha: "2c26b46b68ffc68ff99b453c1d304134"
  - id: cli-flags
    heading: "## CLI flags"
    ownership: model
    regenerate: when_source_stale
    sources:
      - path: src/cli/flags.ts
        blob_sha: "fcde2b2edba56bf408601fb721fe9b5c"
Enter fullscreen mode Exit fullscreen mode

The blob_sha and body_sha256 values above are placeholders for a proposed checkout, not measurements from a private repository. Replace them with git hash-object and a slice digest before the map is merged. Do not accept a map that labels a model section fresh while its pins still say PENDING.

Numbered workflow

Step 1 — Inventory headings before any model call

Parse the markdown for ATX headings at the level you treat as section roots, usually h2. Record each heading text, start offset, and end offset as the next heading or the file end. Do not ask a model to invent this inventory, because heading detection is deterministic and cheap.

python3 tools/list_headings.py docs/api.md
Enter fullscreen mode Exit fullscreen mode

A proposed lister should refuse files that mix Setext headings with ATX headings in the same document. Inconsistent heading syntax makes section bounds ambiguous, which defeats both hash binding and byte locks. Fix the heading syntax first, then classify, then pin.

Step 2 — Classify each heading in the map, not in a prompt

Walk the inventory and assign human or model using the written rule from the previous section. Mechanical indexes, generated flag tables, and error-code lists are the usual model-draftable set. Purpose, policy, and support boundaries stay human-owned even when a model could imitate the tone.

Record the classification in docs/api.regen.yaml in the same change that introduces the heading. A heading that ships without a map row is unmapped on the next check, which is the desired failure. Do not bulk-classify an old manual in one sitting if the document mixes policy and reference; split the file first.

Step 3 — Pin blob hashes for every model-draftable section

Compute the git blob hash of each listed source path from the worktree file that git would hash, not from an unsaved editor buffer. Store those hashes in the map in the same commit that last accepted the drafted section.

git hash-object src/http/routes.ts
git hash-object src/http/errors.ts
git hash-object src/cli/flags.ts
git ls-files -s src/http/routes.ts src/http/errors.ts src/cli/flags.ts
Enter fullscreen mode Exit fullscreen mode

If the hashes and the prose disagree, the section is already stale and must not be labeled fresh. Pinning an old hash against new prose is how teams launder a skipped review. The checker below treats any mismatch as stale, which blocks merge until a bounded redraft runs.

Step 4 — Run the four-state checker as a merge gate

The checker emits one state per mapped section plus one state for leftover headings. Failed states are stale and unmapped; human_locked and fresh may merge when their pins match.

  1. human_locked — ownership is human; the heading slice must match the stored body_sha256 unless a reviewer checkbox is present.
  2. fresh — ownership is model; every listed blob hash still matches git hash-object.
  3. stale — ownership is model; at least one listed blob hash differs from HEAD.
  4. unmapped — a heading exists in the file but not in the map, so the build fails.
# tools/regen_check.py
# Proposed checker. Label: unexecuted example, not a production report.

from __future__ import annotations

import hashlib
import pathlib
import subprocess
import sys
from typing import Any

try:
    import yaml
except ImportError:
    print("Install PyYAML before running this proposed checker.", file=sys.stderr)
    sys.exit(2)


def git_blob(path: str) -> str:
    result = subprocess.run(
        ["git", "hash-object", path],
        check=True,
        capture_output=True,
        text=True,
    )
    return result.stdout.strip()


def heading_ranges(markdown: str) -> list[tuple[str, int, int]]:
    lines = markdown.splitlines(keepends=True)
    starts: list[tuple[str, int]] = []
    offset = 0
    for line in lines:
        if line.startswith("## "):
            starts.append((line[3:].strip(), offset))
        offset += len(line)
    ranges: list[tuple[str, int, int]] = []
    for index, (heading, start) in enumerate(starts):
        end = starts[index + 1][1] if index + 1 < len(starts) else len(markdown)
        ranges.append((heading, start, end))
    return ranges


def slice_hash(markdown: str, start: int, end: int) -> str:
    body = markdown[start:end].rstrip().encode("utf-8")
    return hashlib.sha256(body).hexdigest()


def main(map_path: str) -> int:
    payload: dict[str, Any] = yaml.safe_load(pathlib.Path(map_path).read_text())
    doc = pathlib.Path(payload["document"]).read_text()
    ranges = {heading: (start, end) for heading, start, end in heading_ranges(doc)}
    mapped: set[str] = set()
    failed = 0

    for section in payload["sections"]:
        heading = section["heading"].lstrip("# ").strip()
        mapped.add(heading)
        if heading not in ranges:
            print(f"MISSING\t{section['id']}\t{heading}")
            failed += 1
            continue

        start, end = ranges[heading]
        if section["ownership"] == "human":
            expected = section.get("body_sha256")
            actual = slice_hash(doc, start, end)
            if expected in (None, "PENDING_HUMAN_SLICE") or expected != actual:
                print(f"HUMAN_LOCK_DRIFT\t{section['id']}\t{actual}")
                failed += 1
            else:
                print(f"HUMAN_LOCKED\t{section['id']}")
            continue

        stale_sources = []
        for source in section.get("sources", []):
            current = git_blob(source["path"])
            if current != source["blob_sha"]:
                stale_sources.append((source["path"], source["blob_sha"], current))
        if stale_sources:
            print(f"STALE\t{section['id']}")
            for path, old, new in stale_sources:
                print(f"  {path}: pinned={old} head={new}")
            failed += 1
        else:
            print(f"FRESH\t{section['id']}")

    for heading in ranges:
        if heading not in mapped:
            print(f"UNMAPPED\t{heading}")
            failed += 1

    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "docs/api.regen.yaml"))
Enter fullscreen mode Exit fullscreen mode

Wire the script as a required check so stale sections cannot merge under a fresh label. Pin whatever checkout action your organization already trusts; the command below is a proposed shape, not a claim about a hosted runner image.

# .github/workflows/docs-regen.yml
name: docs-regeneration-map
on:
  pull_request:
    paths:
      - "docs/**"
      - "src/http/**"
      - "src/cli/**"
      - "tools/regen_check.py"
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout the pull request
        uses: actions/checkout@v4
      - name: Install YAML parser
        run: pip install pyyaml
      - name: Fail on stale or unmapped doc sections
        run: python3 tools/regen_check.py docs/api.regen.yaml
Enter fullscreen mode Exit fullscreen mode

The workflow file is a proposed shape, not a claim that a specific organization already runs it. If your CI catalog rejects unpinned actions, substitute the checkout unit your security baseline already allows.

Step 5 — Redraft only the stale heading range

When the checker prints STALE, extract that heading range and send only that range plus the listed source files to a drafting model. Do not paste human-owned sections into the prompt, even as nearby context, because models rewrite adjacent prose. After the redraft, recompute git hash-object for the listed sources and update blob_sha in the same change.

# Proposed extraction only. Label: unexecuted example.
python3 - <<'PY'
from pathlib import Path
text = Path("docs/api.md").read_text()
start = text.index("## Endpoint index")
end = text.index("## CLI flags")
Path("/tmp/endpoint-index.stale.md").write_text(text[start:end])
print("wrote /tmp/endpoint-index.stale.md")
PY
Enter fullscreen mode Exit fullscreen mode

The unit of work is one stale heading, not the whole manual. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option are relevant here because the job is one heading plus its pinned source files, which keeps the redraft queue small enough to run without a dedicated docs cluster.

After the model returns text, a human still reviews the stale section for semantic errors that a matching hash cannot catch. Hash equality is a freshness signal, not a completeness proof, and it does not replace runnable examples.

Step 6 — Relock human sections by bytes, not by tone

Human-owned ranges should be compared as a normalized hash of the heading slice. Trailing whitespace is stripped so editor noise does not look like a policy edit. If ownership: human and body_sha256 drift without a reviewer checkbox on the pull request, fail the build.

That byte lock is the counterpart to hash-bound staleness. Humans may edit those sections; models may not, including through a “make this flow better” prompt that happens to include the whole file. If a human edit is intended, recompute body_sha256 in the same commit and record the reviewer in the pull request body.

Decision table

State Ownership Hash signal Model may redraft? Merge allowed?
human_locked human body_sha256 matches no yes
human_locked with drift human slice digest changed no only with a human checkbox and a new digest
fresh model all source pins match no yes
stale model one source pin differs yes, that section only no, until redraft, pin update, and review
unmapped unknown n/a no no

The table is the policy, and prompts are not the policy. If a prompt and the table disagree, the table wins and the prompt is wrong. Export the checker’s tab-separated states if you need a drift report in review comments.

python3 tools/regen_check.py docs/api.regen.yaml | tee docs/drift-report.txt
Enter fullscreen mode Exit fullscreen mode

A non-zero exit status should remain the merge blocker. The text file is only an aid for reviewers who want to see which pins moved without reading the full log.

Limitations, and who should not use this

A matching blob hash does not prove that a drafted table is complete. It only proves that the pinned files have not moved since the last accepted draft. Moved files, generated code, and vendor documents behind a URL will break the pin or go stale without a path unless you add those inputs to sources.

The proposed checker also assumes ATX h2 roots. Deeper heading trees need an explicit heading_level field before offsets are trustworthy. Binary docs, generated HTML, and wiki pages outside git are out of scope until they have a stable, diffable source.

Do not use this workflow if the document is a narrative design history with no mechanical sections to bind. Do not use it if the team does not store docs in git, or if counsel must rewrite legal text on a cadence unrelated to blob hashes. Do not use it as a substitute for running examples, because freshness of prose is not executability of commands.

Cheap generation makes overwriting easy, which is why the map records who may touch each heading after merge. The original artifact is the four-state checker bound to git blobs, not a new slogan for ownership. If you already queue free-model drafts on a free server, bind those drafts to source hashes before the next redraft runs.

Top comments (0)