DEV Community

Avery Lin
Avery Lin

Posted on

An Ownership Ledger That Stops Model Drafts at the Judgment Line

Generated documentation stays useful only when extractable facts and human judgment never share the same write path. Models may draft inventories, signatures, and fixture-backed examples, because those claims can be recovered from files. Humans must own SLAs, threat models, deprecation dates, and recommendations, because those claims create obligations. This workflow introduces an ownership ledger and a merge gate that reject model text on human-owned heading paths.

Mixed pages look complete in review, yet nobody can say which sentences a person would still defend. The usual failure is not a missing section; it is a generated promise sitting beside a generated parameter list. Reviewers then treat the whole page as either fully trusted or fully discarded, which wastes both kinds of work. Splitting write rights by heading path keeps the cheap drafts cheap and keeps the expensive judgments attributable.

The judgment line is the real documentation boundary

A heading path is extractable when a later reader can rebuild its claims from checked-in sources without asking an author. Typical extractable surfaces include endpoint tables, error-code lists, flag names, and examples that execute against fixtures. A heading path is judgment when publishing it creates a duty, a timeline, a preference, or a risk acceptance that a team must later defend. Those surfaces include support commitments, threat narratives, migration deadlines, and “use this instead” guidance.

The ledger does not grade prose quality and does not score model fluency. It records who may write, which sources may feed a draft, and which verbs are forbidden on that path. Treat the sample files below as a proposed contract, not as a production incident report. Teams should adapt owners and paths to their own tree before enabling a blocking gate.

Why mixed ownership produces unreviewable pages

Review cost grows with ambiguity of authorship, not with word count on the page. When a model fills a judgment heading, the human reviewer inherits a claim they did not choose and cannot cheaply disprove. When a human rewrites an extractable table by hand, the next schema change silently diverges from the published list. Both failure modes look like “docs drift,” but they need opposite fixes: freeze judgment, regenerate inventory.

A practical test is recoverability under file deletion. If deleting the prose would still leave OpenAPI, tests, or ADRs that reconstruct the section, the path can be model-drafted. If deleting the prose would erase the only record of a promise, the path must stay human-owned. That test is organizational, because it names an owner who will answer for the remaining promise.

Define two surfaces, then freeze the mapping

Keep the vocabulary small so the gate can be mechanical. Use extractable for model-drafted paths and judgment for human-owned paths, and refuse a third “mixed” value. Mixed headings are how obligations leak into generated lists. If a section needs both a table and a promise, split it into two heading paths before any draft runs.

1. Inventory heading paths from the published tree

Walk the docs tree and emit one path per heading, including parents, so nested judgment cannot hide under an extractable title. Store the inventory as data, not as a slide, because the gate must compare drafts against the same list. Re-run the inventory when headings move, or the ledger will protect the wrong paths.

python3 tools/inventory_headings.py --root docs --out ledger/headings.json
Enter fullscreen mode Exit fullscreen mode
# tools/inventory_headings.py — proposed helper, not a measured production run
from pathlib import Path
import json, re, sys

def headings(md: str):
    stack = []
    for line in md.splitlines():
        m = re.match(r"^(#{1,6})\s+(.*)$", line.strip())
        if not m:
            continue
        depth, title = len(m.group(1)), m.group(2).strip()
        stack = stack[: depth - 1] + [title]
        yield "/" + "/".join(stack)

def main(root, out):
    found = []
    for p in Path(root).rglob("*.md"):
        text = p.read_text(encoding="utf-8")
        for path in headings(text):
            found.append({"file": str(p), "heading": path})
    Path(out).write_text(json.dumps(found, indent=2), encoding="utf-8")

if __name__ == "__main__":
    main(sys.argv[sys.argv.index("--root") + 1], sys.argv[sys.argv.index("--out") + 1])
Enter fullscreen mode Exit fullscreen mode

2. Mark each path extractable or judgment

Label every inventoried path in a YAML ledger, and require a named owner on judgment rows. Extractable rows still need an owner of the generator, but that owner maintains sources rather than sentences. Leave no unlabeled path, because unlabeled paths become the place models write freely.

3. Bind extractable paths to machine sources

Each extractable path must list files that already exist in the repository. Accept OpenAPI documents, parser fixtures, CLI --help snapshots, and test names, and reject empty source lists. If the sources are missing, the gate should fail before any model is invoked. That ordering prevents fluent pages that cannot be rebuilt after a refactor.

4. Assign a named human owner to every judgment path

Judgment owners should be roles that can change the promise, not a shared “docs” mailbox. Record the owner as a stable identifier your review tool already understands, such as a CODEOWNERS handle. When the owner leaves, the ledger should fail closed until a replacement is written. Ownership rot is a documentation defect, not a staffing footnote.

5. Generate only into a quarantine directory

Model output lands under docs/_generated/ and never writes directly at a judgment path. Human narrative stays in the published tree, and a linker copies extractable sections only after the gate passes. This split makes git blame meaningful again: generated files blame the pipeline, judgment files blame people.

6. Merge through a ledger-aware gate

The merge gate reads the ledger, the quarantine tree, and a short list of forbidden judgment verbs. It fails if a generated file claims a judgment heading, if a source file is absent, or if extractable prose uses obligation language. It also fails if a human file was last touched by the generator identity you assign to drafts. That last check stops “just this once” edits from laundering model text into owned paths.

A sample ownership ledger

The following ledger is a worked example for a small HTTP API guide. It is labeled as a proposal, and the owners are placeholders for roles, not evidence of a specific company. Notice that token lifetime is split from authentication mechanics, because duration is a promise while header names are extractable.

# ledger/ownership.yaml — proposed schema
version: 1
doc: docs/api.md
generator_identity: model-bot
forbidden_judgment_verbs:
  - must
  - guarantee
  - sla
  - recommend
  - deprecate on
  - always
  - never
paths:
  - heading: "/API/Authentication"
    surface: extractable
    drafter: model
    owner: platform-docs
    sources:
      - openapi.yaml
      - src/auth/middleware.py
    allowed_moves: [describe, list, example]
  - heading: "/API/Authentication/Token lifetime"
    surface: judgment
    drafter: human
    owner: security-eng
    sources:
      - adr/0042-token-ttl.md
    allowed_moves: []
  - heading: "/API/Error codes"
    surface: extractable
    drafter: model
    owner: platform-docs
    sources:
      - src/errors/catalog.py
      - tests/test_error_catalog.py
    allowed_moves: [list, example]
  - heading: "/API/Support commitment"
    surface: judgment
    drafter: human
    owner: product-ops
    sources: []
    allowed_moves: []
Enter fullscreen mode Exit fullscreen mode

A validator you can run in CI

The validator is intentionally boring: load YAML, walk generated Markdown, and exit nonzero on the first ownership violation. Boring gates survive better than scoring functions that need weekly recalibration. Run it on every pull request that touches docs/ or ledger/.

# tools/check_ownership.py — proposed gate
from pathlib import Path
import re, sys, yaml

HEADING = re.compile(r"^(#{1,6})\s+(.*)$")

def heading_paths(text):
    stack, found = [], []
    for line in text.splitlines():
        m = HEADING.match(line.strip())
        if not m:
            continue
        depth, title = len(m.group(1)), m.group(2).strip()
        stack = stack[: depth - 1] + [title]
        found.append("/" + "/".join(stack))
    return found

def verbs_present(text, verbs):
    low = text.lower()
    return [v for v in verbs if v in low]

def main(ledger_path, generated_root):
    ledger = yaml.safe_load(Path(ledger_path).read_text(encoding="utf-8"))
    by_heading = {row["heading"]: row for row in ledger["paths"]}
    verbs = ledger.get("forbidden_judgment_verbs", [])
    errors = []
    for md in Path(generated_root).rglob("*.md"):
        text = md.read_text(encoding="utf-8")
        for path in heading_paths(text):
            row = by_heading.get(path)
            if row is None:
                errors.append(f"{md}: unlabeled heading {path}")
                continue
            if row["surface"] == "judgment":
                errors.append(f"{md}: model draft on judgment path {path}")
                continue
            missing = [s for s in row.get("sources", []) if not Path(s).exists()]
            if missing:
                errors.append(f"{md}: missing sources {missing} for {path}")
            leaked = verbs_present(text, verbs)
            if leaked:
                errors.append(f"{md}: judgment verbs {leaked} on extractable {path}")
    if errors:
        print("\n".join(errors))
        return 1
    print("ownership gate passed")
    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode
pip install pyyaml pytest
python3 tools/check_ownership.py ledger/ownership.yaml docs/_generated
Enter fullscreen mode Exit fullscreen mode

Disclosure: This article was prepared as part of MonkeyCode's product outreach. A team that already quarantines drafts can use MonkeyCode's free model access to fill extractable headings only, and can run this gate on the free server option so the check is not a laptop-only habit. Keep the model outside judgment paths even when the server is shared, because hosting does not transfer ownership of promises.

Tests that prove the gate fails closed

Do not trust a green CI run that never saw a violating fixture. Add at least three cases: a judgment heading in quarantine, an extractable heading with a missing source, and an extractable heading that contains an obligation verb. The fourth case should be a clean extractable list that must pass, so a broken parser cannot hide behind blanket failure.

# tests/test_ownership_gate.py — proposed fixtures, unexecuted in this article
from pathlib import Path
import tools.check_ownership as gate

def write(tmp, rel, body):
    p = tmp / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(body, encoding="utf-8")
    return p

def test_rejects_judgment_heading(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    write(tmp_path, "ledger/ownership.yaml", Path("ledger/ownership.yaml").read_text())
    write(tmp_path, "docs/_generated/bad.md", "# API\n## Support commitment\nWe will answer in one hour.\n")
    assert gate.main("ledger/ownership.yaml", "docs/_generated") == 1

def test_rejects_missing_source(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    write(tmp_path, "ledger/ownership.yaml", """
version: 1
paths:
  - heading: "/API/Error codes"
    surface: extractable
    drafter: model
    owner: platform-docs
    sources: [missing_catalog.py]
    allowed_moves: [list]
""")
    write(tmp_path, "docs/_generated/errors.md", "# API\n## Error codes\n- 409 conflict\n")
    assert gate.main("ledger/ownership.yaml", "docs/_generated") == 1
Enter fullscreen mode Exit fullscreen mode

A local loop looks like inventory, label, generate into quarantine, then gate. If the gate fails, delete the generated file rather than hand-editing it into a judgment voice. Hand edits on extractable paths recreate the mixed page that the ledger exists to prevent.

python3 tools/inventory_headings.py --root docs --out ledger/headings.json
python3 tools/check_ownership.py ledger/ownership.yaml docs/_generated
pytest tests/test_ownership_gate.py -q
Enter fullscreen mode Exit fullscreen mode

Limitations

Verb lists miss implicit promises such as “callers can rely on this ordering tomorrow.” Extractable sources can themselves encode policy, especially when OpenAPI descriptions contain SLA language copied from a ticket. The ledger also goes stale when headings are renamed without a redirect in the YAML file. None of these checks prove that an extractable draft is correct; they only prove that the wrong writer did not enter the wrong path.

Named owners do not equal reviewed owners. A role can sit on a judgment path for months without reading the section, and the gate will still pass. Pair the ledger with your existing review rules, such as CODEOWNERS on docs/ subtrees that contain judgment headings. If you cannot name an owner, do not generate the page.

Who should skip this workflow

Skip the ledger if the repository is a single README with no heading inventory worth maintaining. Skip it if every sentence requires counsel review, because a merge gate is not a legal sign-off. Skip it if the team cannot agree on a generator identity, since blame-based blocking then becomes theater. Skip it for exploratory notes that are not published as product truth.

Teams that benefit are those already generating API inventories and already losing arguments about who changed a promise. The extra YAML is cheaper than rediscovering ownership during an incident. The method stays useful if the drafting host changes, because the contract lives in the repository rather than in a chat transcript.

If you already run documentation checks in CI, start with the quarantine directory and the failing fixtures, then decide whether a shared free server is worth adding for the same gate. The valuable part is the judgment line, not the vendor that happens to draft the extractable side.

Top comments (0)