DEV Community

Avery Lin
Avery Lin

Posted on

Human Sign-Off Expires: Bind Doc Attestations to Fact Hashes

A documentation approval should never be permanent. It should be a short lease on one specific, machine-checkable fact, and it should expire the moment that fact moves. The workflow below turns every human-owned sentence in a generated doc into a hashed attestation, so a stale approval fails CI instead of quietly shipping.

This is the part of model-assisted documentation that most teams skip. They argue about what the model may draft, then hand the result to a reviewer whose approval outlives the code it described. Six weeks later the version floor, the default port, or the required permission has changed, and the page still reads as reviewed.

The failure is not hallucination, it is approval drift

Hallucination gets caught in review because it looks wrong. Approval drift survives review because it looked right when someone checked it. The reviewer was correct on the day of review, and nothing in the repo records which fact that correctness depended on.

Attestation expiry fixes the shape of the problem rather than the wording. Each human-owned span stores a hash of the fact value plus the claim text, and the check re-resolves the fact on every pull request. If the value changed, the signature is void and a human has to re-read one paragraph, not the whole page.

Decide drafting rights before you write a prompt

Write the split down once, in the repository, so the model's job and the reviewer's job are not renegotiated per page.

Span type Model may draft Human must sign Lease breaks when
Prose that restates verified code or output Yes No Code block or fact selector changes
Version floors, ports, env var names, limits Yes, if a fact selector exists Yes Selector value changes
Rollback, recovery, and migration steps No Yes Any cited fact changes
Permissions, security posture, data handling No Yes Any cited fact changes
Changelog and release summaries Yes No Next release

Two rules make this table usable. A span with no machine-checkable fact cannot be signed, because the signature would be a vibe. A span marked draft: model cannot be signed at all, because signing model prose adds paperwork without adding a guarantee.

Step 1: Write the span manifest

Keep the manifest beside the docs it governs. Each entry names a span, the owner who accepts responsibility, the exact claim being attested, and how to resolve the fact that claim depends on.

# docs/ownership.yaml
version: 1
spans:
  - id: readme.install.node-floor
    owner: "@avery"
    draft: human
    claim: "Requires Node 20 or newer."
    fact:
      kind: regex
      file: package.json
      pattern: '"node":\s*">=([0-9]+)'
      group: 1
    anchor: "## Requirements"

  - id: readme.install.default-port
    owner: "@avery"
    draft: human
    claim: "The server listens on port 8080 by default."
    fact:
      kind: regex
      file: internal/config/defaults.go
      pattern: 'DefaultPort\s*=\s*([0-9]+)'
      group: 1
    anchor: "## Run it"

  - id: readme.install.intro
    owner: "@avery"
    draft: model
    claim: "Painless local setup for the sync daemon."
    anchor: "## Requirements"
Enter fullscreen mode Exit fullscreen mode

Fact selectors should be boring. Prefer a regex over a config file or a small command whose stdout is stable, and never point a selector at a generated artifact that changes on every build.

Step 2: Hash the fact and the claim together

Hashing the fact alone is not enough. If someone edits the claim text, the attested sentence no longer exists, so the signature must fall away too.

# tools/attest.py
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path

import yaml

LEDGER = Path(".attest/ledger.json")
MANIFEST = Path("docs/ownership.yaml")


def resolve(spec: dict) -> str:
    if spec["kind"] == "regex":
        text = Path(spec["file"]).read_text(encoding="utf-8")
        match = re.search(spec["pattern"], text, re.MULTILINE)
        if match is None:
            raise ValueError(f"selector matched nothing in {spec['file']}")
        return match.group(spec.get("group", 0)).strip()
    if spec["kind"] == "command":
        proc = subprocess.run(spec["run"], shell=True, capture_output=True, text=True)
        if proc.returncode != 0:
            raise ValueError(f"fact command failed: {spec['run']}")
        return proc.stdout.strip()
    raise ValueError(f"unknown fact kind: {spec['kind']!r}")


def digest(value: str, claim: str) -> str:
    h = hashlib.sha256()
    h.update(value.encode("utf-8"))
    h.update(b"\x00")
    h.update(" ".join(claim.split()).encode("utf-8"))
    return h.hexdigest()[:16]
Enter fullscreen mode Exit fullscreen mode

Normalising whitespace before hashing keeps the check honest about content and indifferent to reformatting. A reviewer who reflows a paragraph should not have to re-sign it.

Step 3: Keep a ledger of signatures, not permissions

The ledger is a flat JSON file in the repository, so signatures travel with the branch and appear in review diffs. Every entry records the owner, the digest, the resolved value, and the UTC timestamp.

def load_ledger() -> dict:
    return json.loads(LEDGER.read_text()) if LEDGER.exists() else {}


def save_ledger(ledger: dict) -> None:
    LEDGER.parent.mkdir(parents=True, exist_ok=True)
    LEDGER.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
Enter fullscreen mode Exit fullscreen mode

Storing fact_value next to the digest is deliberate. When the check fails, the error message can print both the signed value and the current one, which turns a red build into a one-line diagnosis.

Step 4: Gate on freshness, sign on purpose

Two subcommands cover the whole lifecycle: check runs in CI and never writes, sign runs locally after a human read the span.

# reviewer signs one span after reading it against the code
python3 tools/attest.py sign readme.install.node-floor --owner "@avery"

# CI: fail if any human-owned span cites a fact that has moved
python3 tools/attest.py check
# FRESH readme.install.node-floor (human)
# STALE readme.install.default-port: signed 2026-10-21T09:14:03+00:00 by @avery, fact has moved
# 3 spans, 1 stale
Enter fullscreen mode Exit fullscreen mode

Exit codes matter more than the log: 0 means every signature still matches, 1 means at least one lease expired, and 2 means a selector is broken and the gate cannot judge at all. Treat 2 as a build failure, never as a warning.

# .github/workflows/docs.yml
- name: Human attestations must be fresh
  run: python3 tools/attest.py check
Enter fullscreen mode Exit fullscreen mode

The signing command refuses spans marked draft: model, which keeps the queue of human-owned work visible and small. In the example manifest above, three spans produce two signatures and one irreducibly human decision per release.

Step 5: Prove the gate can fail

A green check on an untouched repo proves nothing, so mutate the facts on a scratch branch and confirm each exit code.

  1. Bump the Node floor in package.json to 22, run check, expect exit 1 with the version span named.
  2. Reword the attested claim without signing, run check, expect exit 1 because the claim hash changed.
  3. Run sign for that span, run check, expect exit 0 and a new ledger diff in review.
  4. Rename the config field that the port selector reads, run check, expect exit 2 and a selector matched nothing message.
  5. Let a model rewrite the prose around a human-owned span while its fact is unchanged, run check, expect exit 0 — and then read the diff, because the checker guards facts, not sentences.

Step 5 is the honest limitation, and it should live in the repository rather than in someone's memory. Fact hashes catch drift in numbers, names, and paths; they cannot catch a model that keeps every fact and still gets the tone, ordering, or preconditions wrong.

Where free model access fits, and where it does not

Drafting prose around verified code is the cheapest part of this pipeline to delegate, because the facts enter the page from selectors rather than from the model. I use MonkeyCode's free model access for those draft: model spans and review them like any other generated paragraph.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The nightly check job is a good fit for the free server option as well, since the check is a short Python process that reads YAML and JSON and exits. Anything that needs long-lived storage or heavy compute should stay on infrastructure you already control, and I would not move the ledger anywhere that hides it from code review.

If you want to try the loop on one README, start with a single human-owned span and let the check fail once on purpose before wiring it into CI.

Who should not use this

Single-maintainer docs with no reviewers do not need a signature ledger, because the author and the owner are the same person and the diff already shows the change. Teams whose claims have no machine-checkable source should skip the hash layer and invest in fact injection instead.

Regulated documentation that requires formal approval records, wet signatures, or audit trails outside the repository should keep its existing process; this ledger is a drift detector, not a compliance artifact. And if your fact selectors change weekly because the underlying config is unstable, fix the instability first, or the gate will cry wolf until people ignore it.

The goal is modest: make the human promise small enough that re-making it costs a minute.

Top comments (0)