DEV Community

Avery Lin
Avery Lin

Posted on

Sign the Sentences, Not the PR: An Approval Ledger for Model-Drafted Doc Blocks

Most documentation reviews approve a pull request, not a sentence. That is the failure mode worth fixing first, because a single approval granted in October can silently keep covering paragraphs a model rewrote in December. The workflow below binds every human signature to a hash of the exact text it approved, so re-approval becomes a mechanical consequence of editing rather than a matter of reviewer vigilance.

The artifact is a ~90-line Python checker that runs in pre-commit and CI. It does not gate generation and it does not judge prose quality; it answers one narrow question per block: has a named human signed this exact text?

The failure mode: approvals outlive their text

A PR approval is scoped to a diff, and a diff is scoped to a moment. Once merged, the approval record lives in review history while the file keeps moving under other PRs, refreshes, and routine edits. When a model later rewrites a command block inside an already-approved page, nothing in the toolchain notices that the signature no longer describes the content.

If you already pin blocks to a source hash to decide what may be refreshed, this is the complementary half. A source hash says "the input changed, so re-draft." An approval hash says "a human still vouches for this sentence." You want both, because either one alone produces a page where the provenance of the prose is unverifiable.

Who may draft what

Before any tooling, write down the boundary. The table below is a defensible starting contract; adapt the rows to your own risk surface rather than copying them wholesale.

Block type Model may draft Human must own
Install and upgrade commands Yes, with a pinned tool version That the command was executed on a clean machine
API signatures and parameter names Yes, from a pinned release artifact Reconciliation against the release being documented
Limits, quotas, prices, deprecation dates No Any figure, date, or contractual statement
Security warnings and data-handling notes Draft as a prompt only The final wording and its applicability
Migration and rollback steps Draft per step Ordering, reversibility, and blast radius
Troubleshooting symptoms Yes, from issue text That the symptom was reproduced

The rows marked "No" are not a comment on model quality; they are a statement about who is accountable when a number is wrong. Ownership is a liability question before it is a writing question.

Step 1: Mark ownership boundaries in the source

Wrap each unit you want to attribute in an HTML comment pair, and keep the body plain Markdown so the page still renders anywhere.

<!-- doc:block id="install-linux" owner="model" -->
Run `make install` from the repository root. The command writes binaries to `./bin`.
<!-- /doc:block -->

<!-- doc:block id="limits-note" owner="human" -->
Rate limits are documented in the pricing page and change without notice.
<!-- /doc:block -->
Enter fullscreen mode Exit fullscreen mode

Blocks marked owner="human" are exempt from checking, which keeps the ledger small and the escape hatch explicit. Blocks marked owner="model" need a signature, and the signature is invalid the moment the body changes.

Step 2: Draft under a restricted instruction

Give the model the marked file and the draft-rights table together, and require it to leave owner="human" blocks byte-identical. A workable instruction is: rewrite only blocks with owner="model", never introduce a number, date, or version not present in the supplied release artifact, and return the full file so the markers survive.

This is where free model access from MonkeyCode is genuinely useful in my setup, because the drafting pass is cheap, repetitive, and worth running several times while you tune the contract. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The drafting pass and the checker job are both small enough to run on the free server option, which keeps the whole workflow inside a single environment while you decide whether it earns a place in your pipeline. Availability is operator-supplied and can change, so keep the checker provider-agnostic and never let it depend on a specific model being reachable.

Step 3: Hash the block and record the signature

The checker below normalizes trailing whitespace, hashes each model-drafted body, and compares it against a JSON ledger stored beside the docs.

#!/usr/bin/env python3
"""doc_provenance.py — fail when a model-drafted block changed without a fresh approval."""
from __future__ import annotations

import argparse
import hashlib
import json
import pathlib
import re
import sys

BLOCK_RE = re.compile(
    r'<!--\s*doc:block\s+id="(?P<id>[a-z0-9][a-z0-9\-]*)"\s+owner="(?P<owner>model|human)"\s*-->'
    r'\n(?P<body>.*?)\n'
    r'<!--\s*/doc:block\s*-->',
    re.DOTALL,
)
LEDGER = pathlib.Path(".docprovenance")


def digest(body: str) -> str:
    normalized = "\n".join(line.rstrip() for line in body.strip().splitlines())
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()


def ledger_path(doc: pathlib.Path) -> pathlib.Path:
    return LEDGER / (doc.as_posix().replace("/", "__") + ".json")


def read_ledger(doc: pathlib.Path) -> dict:
    path = ledger_path(doc)
    return json.loads(path.read_text()) if path.exists() else {"blocks": {}}


def check(doc: pathlib.Path) -> list[str]:
    text, seen, problems = doc.read_text(), set(), []
    records = read_ledger(doc)["blocks"]
    for match in BLOCK_RE.finditer(text):
        block_id, owner, body = match.group("id"), match.group("owner"), match.group("body")
        seen.add(block_id)
        if owner != "model":
            continue
        record = records.get(block_id)
        if record is None:
            problems.append(f"{doc}:{block_id}: model-drafted block has no approval")
        elif record["sha256"] != digest(body):
            problems.append(f"{doc}:{block_id}: text changed since approval by {record['reviewer']}")
    for block_id in records:
        if block_id not in seen:
            problems.append(f"{doc}:{block_id}: approval record for a deleted block")
    return problems


def approve(doc: pathlib.Path, block_id: str, reviewer: str) -> None:
    text = doc.read_text()
    for match in BLOCK_RE.finditer(text):
        if match.group("id") == block_id:
            data = read_ledger(doc)
            data["blocks"][block_id] = {"sha256": digest(match.group("body")), "reviewer": reviewer}
            ledger_path(doc).parent.mkdir(parents=True, exist_ok=True)
            ledger_path(doc).write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
            return
    raise SystemExit(f"no block named {block_id} in {doc}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("command", choices=["check", "approve"])
    parser.add_argument("paths", nargs="+")
    parser.add_argument("--block")
    parser.add_argument("--reviewer", default="")
    args = parser.parse_args()
    docs = [pathlib.Path(p) for p in args.paths]
    if args.command == "approve":
        approve(docs[0], args.block, args.reviewer)
        sys.exit(0)
    failures = [problem for doc in docs for problem in check(doc)]
    print("\n".join(failures) or "doc provenance: ok")
    sys.exit(1 if failures else 0)
Enter fullscreen mode Exit fullscreen mode

The approval command writes a record you can review as a diff, which is the point: the signature becomes an artifact in the repository rather than a fact buried in a review thread.

python3 tools/doc_provenance.py approve docs/install.md --block install-linux --reviewer alin
python3 tools/doc_provenance.py check docs/
Enter fullscreen mode Exit fullscreen mode

Illustrative output when a model refreshes a command block without a re-sign:

docs/install.md:install-linux: text changed since approval by alin
doc provenance: exit 1
Enter fullscreen mode Exit fullscreen mode

Step 4: Enforce it in pre-commit and CI

Run the check locally so authors see the failure before pushing, and run the same command in CI so the hook can never be the only gate. A local hook costs about a second on a docs tree of a few hundred files.

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: doc-provenance
        name: doc provenance
        entry: python3 tools/doc_provenance.py check
        language: system
        files: ^docs/.*\.md$
        pass_filenames: true
Enter fullscreen mode Exit fullscreen mode
# CI step, provider-neutral
- run: python3 tools/doc_provenance.py check docs/
Enter fullscreen mode Exit fullscreen mode

Step 5: Review the ledger diff, not the prose diff

In review, the interesting question is which blocks changed and who signed them, so put .docprovenance/ in the same PR as the doc edit and read that diff first. Each added record names a person and a hash; each removed record marks a block that no longer exists.

Limitations and who should not use this

The checker verifies integrity of the binding, not the correctness of the text; a careless reviewer can approve a wrong command in one keystroke. It also adds friction proportional to block count, so start with the ten blocks that carry commands, numbers, or security claims rather than the whole tree.

Skip this approach if your docs are auto-generated from source, if you have no named human who can credibly sign, or if your team cannot enforce a hook in CI. In those cases the ledger becomes ceremony that duplicates whatever your generator already guarantees.

Measure two things before scaling: time from block change to re-approval, and the share of CI runs that fail on staleness. If the second number stays high, your blocks are too granular or your contract is assigned to the wrong owner.

Top comments (0)