Binding a human owner to each documentation section fixes accountability at draft time, but it does nothing about the day the claim stops being true. The workflow below attaches an expiry date and a re-verification command to every claim a model drafts, then runs a ledger on a schedule so stale sentences surface before a reader trips over them. It is one YAML block convention, roughly 120 lines of Python, and one scheduled CI job — nothing that requires a docs platform migration.
The failure mode is temporal, not structural
Most ownership schemes answer "who wrote this section?" and stop there. The interesting question is "when was this sentence last provably true, and what command proves it now?" A retry default, a rate-limit header, or a default timeout can be correct on the day it is drafted and wrong three weeks later, while the section owner field still reads @platform-team and looks perfectly healthy.
An earlier post in this series pinned writable regions per page, which stops a model from editing reference material it should not touch. That gate is necessary but time-blind: a claim that passed review in March can contradict the service in June, and no manifest will notice. The ledger here adds the missing dimension by making expiry and re-verification first-class fields on the claim itself.
1. Write the claim block before the prose
The rule that makes this work is an ordering rule: a human writes the claim block, and the model writes the body underneath it. Inverting that order — drafting prose first and back-filling metadata — is exactly how unverified numbers reach production, because the model's confident sentence becomes the source for its own header.
Each drafted claim lives in a fenced block in the Markdown source that never renders. The verify command must be non-interactive, idempotent, and safe to run in CI.
```yaml claim
id: retry-budget-defaults
class: numeric # interface | behavior | numeric
source: docs-spec/retries-openapi.yaml#/components/schemas/RetryPolicy
owner: platform-oncall # a team or a rotation, never "tbd"
expires: 2026-11-01
verify: ./scripts/verify/retry-default.sh
```
The default retry budget is three attempts with exponential backoff, after which the client
returns the final error to the caller without further delay.
Three fields carry the weight. source points at the artifact the claim was derived from, so a reviewer can diff the prose against an owned file instead of re-reading the whole page. expires is a date, not a duration, so it survives copy-paste and review latency. verify is the machine-checkable half of the claim, and if you cannot write one, the claim belongs in the class that a human must own outright.
2. Run the ledger as a script, not a dashboard
The ledger classifies every claim into one of four states: ok, reverify-soon (seven days or fewer), blocked (expired or missing a required field), and contradicted (the verify command returned non-zero). Only the last two fail the build, which keeps the noisy middle band visible without turning it into an outage.
#!/usr/bin/env python3
"""doc_expiry.py — scan Markdown sources for drafted claim blocks and report staleness."""
from __future__ import annotations
import argparse
import datetime as dt
import pathlib
import re
import shlex
import subprocess
import sys
BLOCK = re.compile(r"^```
yaml claim\n(?P<body>.*?)^
```$", re.DOTALL | re.MULTILINE)
REQUIRED = ("id", "class", "owner", "expires", "source")
CLASS_PRIORITY = {"interface": 1, "behavior": 2, "numeric": 3}
def parse_fields(body: str) -> dict[str, str]:
fields: dict[str, str] = {}
for line in body.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
key, _, value = line.partition(":")
fields[key.strip()] = value.strip().strip('"').strip("'")
return fields
def collect(root: pathlib.Path) -> list[dict]:
claims: list[dict] = []
for path in sorted(root.rglob("*.md")):
text = path.read_text(encoding="utf-8")
for match in BLOCK.finditer(text):
fields = parse_fields(match.group("body"))
fields["_file"] = str(path.relative_to(root))
claims.append(fields)
return claims
def validate(claim: dict) -> list[str]:
problems = [f"missing field: {key}" for key in REQUIRED if not claim.get(key)]
if claim.get("drafted_by") == "model" and not claim.get("owner"):
problems.append("model-drafted claim without a named human owner")
return problems
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", default="docs")
parser.add_argument("--today", default=dt.date.today().isoformat())
parser.add_argument("--run-verify", action="store_true")
parser.add_argument("--timeout", type=int, default=30)
args = parser.parse_args()
today = dt.date.fromisoformat(args.today)
rows: list[dict] = []
failures = 0
for claim in collect(pathlib.Path(args.root)):
problems = validate(claim)
try:
days_left = (dt.date.fromisoformat(claim["expires"]) - today).days
except (KeyError, ValueError):
days_left = -1
problems.append("unparsable expires date")
status = "ok"
if problems or days_left < 0:
status = "blocked"
elif days_left <= 7:
status = "reverify-soon"
if args.run_verify and claim.get("verify"):
proc = subprocess.run(
shlex.split(claim["verify"]),
capture_output=True, text=True, timeout=args.timeout,
)
if proc.returncode != 0:
status = "contradicted"
rows.append({**claim, "_days_left": days_left, "_status": status,
"_problems": problems})
failures += status in {"blocked", "contradicted"}
rows.sort(key=lambda r: (r["_days_left"], CLASS_PRIORITY.get(r["class"], 9)))
for row in rows:
print(f"{row['_status']:>14} {row['_days_left']:>5}d "
f"{row['_file']} {row.get('id', '?')}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
Running it with --run-verify executes each recorded command and downgrades any claim whose check fails. The output shape looks like this, and the exit code is what CI reads:
$ python3 tools/doc_expiry.py --root docs --today 2026-10-15 --run-verify
reverify-soon 6d docs/api/rate-limits.md rate-limit-headers
contradicted -12d docs/guides/retries.md retry-budget-defaults
ok 41d docs/api/auth.md token-lifetime
3. Schedule the job where a scheduled job belongs
A pull-request gate catches claims that a model just drafted, but it cannot catch claims that expired while nobody opened a PR. That second class needs a recurring run, so the ledger belongs on a schedule as well as on the docs/** path filter.
name: doc-claims
on:
schedule:
- cron: "17 3 * * *"
pull_request:
paths: ["docs/**"]
jobs:
ledger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 tools/doc_expiry.py --root docs --run-verify
For the drafting half of this loop I used MonkeyCode's free model access, with the model producing prose inside claim blocks that a human had already written. The recurring ledger run lives on the free server option, which matters because a scheduled verifier with no budget is a scheduled verifier nobody keeps. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
4. Decide what the model may draft at all
The ledger only reports; the decision table is what keeps the report short. Three of the five classes below are draggable by a model, and two are not, regardless of how good the draft looks.
| Claim class | Model may draft | Human owner | Default expiry |
|---|---|---|---|
interface (endpoints, flags, CLI args) |
Yes, from a spec file | Service owner | 90 days |
behavior (ordering, retries, limits) |
Yes, with a verify command | Service owner | 30 days |
numeric (defaults, timeouts, tiers) |
Only with a linked source | Product ops | 14 days |
| Security, auth, or data-handling claims | No | Security reviewer | n/a |
| Pricing, legal, or contractual claims | No | Legal or marketing | n/a |
Two rules in the table are load-bearing. A numeric claim without a source field is blocked even if the number is correct, because correctness you cannot re-derive is a coincidence. The two "No" rows exist because their failure modes are not documentation bugs; they are incidents with external consequences.
5. Roll it out in four steps
- Pick three pages that generated support tickets in the last quarter — not the three largest pages.
- Hand-write claim blocks for the ten highest-traffic claims on each page, adding
verifycommands only where a check already exists. - Run the ledger in report-only mode for two weeks and record the queue length; an unmanaged queue above roughly twenty items means the expiry windows are too aggressive.
- Flip the gate to blocking for
interfaceandnumericonly, leavingbehavioradvisory until its verify commands have survived a month.
Limitations and who should skip this
The parser is line-oriented and brittle: nested code fences inside a section will confuse it, and a real Markdown AST parser is the right choice past a few hundred pages. The verify command encodes only the part of a claim you thought to test, so tone, prerequisites, and framing still drift past it silently at expiry time. Expiry dates are chores, and a rotation that ignores a long queue will train reviewers to ignore the gate too, so measure the queue instead of trusting it.
Skip this approach if your pages are generated entirely from a source of truth, since regenerating beats re-verifying every time. Skip it if you have fewer than about twenty total claims, where a monthly manual read is cheaper than the tooling. Skip it for legal, pricing, and security pages, where the owner class in the table above should be enforced by review rather than by a cron job.
Start with the three pages your support queue complains about most, and let the ledger's queue length tell you whether the loop is worth keeping.
Top comments (0)