DEV Community

Avery Lin
Avery Lin

Posted on

Source-Bound Docs: Models Restate Files, Humans Own Promises

Model-drafted documentation is useful only when every generated claim maps to a file the repository already owns. Sentences without a source become unofficial contracts, and those contracts are expensive to walk back after publication. The practical split is simple: models may restate checked-in facts, while humans own promises, versions, and unsupported advice. This article describes a cite-or-drop pipeline that enforces that split before a draft reaches a pull request.

Restatement is cheap; a promise is not

Classifying a heading as draftable still leaves the model free to invent numbers, support windows, and compatibility language. A parameter table copied from OpenAPI is a restatement; a sentence that calls an endpoint production-stable is a promise. Reviewers who only skim markdown diffs tend to miss that second class because it reads like ordinary explanatory prose. Binding each sentence to a path, a symbol, or an ADR identifier makes the missing source visible in continuous integration.

The expensive failure is not purple prose, but a getting-started page that upgrades a beta flag into a supported contract. Support teams then inherit a sentence nobody signed, and revert cost lands after customers have already quoted the line. A source-bound pipeline treats that sentence as a build failure rather than a style comment on the pull request. Heading labels remain useful for navigation, yet they cannot see a promise that sits inside an otherwise draftable section.

A decision table for draft rights

Use this table as a checked-in policy, not as a vibe check that reviewers apply after the model has already written. Rows name documentation surfaces, and columns separate restatable facts from commitments that require a human owner. If a proposed sentence cannot name a row and a column, it does not belong in a model-touched markdown file.

Surface Model may draft Human must own Required source
Getting-started commands Copy from Makefile targets or CLI help text Claims that the flow is production ready Makefile, command entrypoints
API parameter tables Names, types, enums, required flags Stability language, SLA, will-not-change OpenAPI or protobuf
Changelog restatements Bullet text already present in CHANGELOG Safe-to-skip, no-downtime, no-migration CHANGELOG.md plus a git tag
Error catalogs Code, message template, HTTP status Customer-facing severity and workaround Source error types
Architecture narrative Module list from lockfiles or CODEOWNERS Why the design is correct ADRs only; otherwise human
Security and privacy Links to existing policy files Threat claims and encryption guarantees Human only
Pricing and support windows None Entire section Human only
Version and compatibility git describe output and module path Supported-until dates and backport policy Human plus release notes

The table is the working contract for draft jobs, extractors, and tests that later drop unbound sentences. Expand rows only when a new source artifact exists in the repository and a human agrees it may be restated. Do not add a row for conceptual essays that have no cite root, because those pages are human-owned by default.

Four-stage pipeline

The pipeline has four stages that run in order and fail closed when a claim cannot be bound. First, inventory the source artifacts that are allowed to justify prose in any model-touched file. Second, extract candidate claims from a model draft as structured records instead of scoring raw markdown. Third, bind each record to a cite root; fourth, reject the pull request when unbound claims remain.

The following sections treat the implementation as a proposed workflow rather than a report of production results. Commands, fixtures, and tests are labeled as unexecuted examples and need local adaptation before enforcement. Keep generated pages under a dedicated tree so human-owned documents never share a glob with model output.

Step 1: Inventory the files a model is allowed to cite

Create a checked-in bind map so citation roots cannot drift from prompt to prompt across draft jobs. Paths listed in that map are the only files a draft job may read when it composes restatements. Human-only globs are exclusions, not suggestions, and a write into those paths should fail the job.

# docs/bind-map.yml (proposed, unexecuted)
version: 1
cite_roots:
  - path: openapi/openapi.yaml
    kinds: [api_table, error_status]
  - path: CHANGELOG.md
    kinds: [changelog]
  - path: Makefile
    kinds: [command]
  - path: adr/
    kinds: [architecture]
human_only_globs:
  - docs/support/**
  - docs/security/**
  - docs/pricing.md
claim_patterns:
  - name: numeric_guarantee
    regex: '\b(\d+)\s*(ms|seconds|nines|years|months)\b'
    default_owner: human
  - name: modal_promise
    regex: '\b(always|never|guaranteed|production-ready|SLA)\b'
    default_owner: human
Enter fullscreen mode Exit fullscreen mode

Keep the bind map small so it cannot become a hidden dump of the entire repository for the prompt. A large cite-root list recreates unbounded generation under a different filename and defeats the ownership split. Review changes to the map with the same seriousness as CODEOWNERS, because the map grants the model read rights.

Step 2: Normalize a draft into claim records

Split each draft into claim records so continuous integration can fail on one sentence without debating the whole page. The schema below is intentionally boring so a later human edit can still pass the same structural tests. Owner is either model or human; unbound stays true until a deterministic join fills source_path.

# scripts/claim_schema.py (proposed, unexecuted)
from __future__ import annotations

from pydantic import BaseModel, Field


class Claim(BaseModel):
    heading_path: str
    text: str
    kind: str
    source_path: str | None = None
    source_anchor: str | None = None
    owner: str = Field(pattern="^(model|human)$")
    unbound: bool = True
Enter fullscreen mode Exit fullscreen mode

A first extractor can start with sentence splits plus the regexes already declared in the bind map. Richer extractors can wait until the gate is boring, because the ownership rule matters more than linguistic sophistication. Numeric guarantees and modal promises default to human ownership even when they appear under a draftable heading.

# scripts/extract_claims.py (proposed, unexecuted)
import re
from pathlib import Path

from claim_schema import Claim

SENTENCE = re.compile(r"(?<=[.!?])\s+")


def extract_claims(markdown: str, heading_path: str, patterns: list[dict]) -> list[Claim]:
    claims: list[Claim] = []
    for text in SENTENCE.split(markdown.strip()):
        text = text.strip()
        if not text:
            continue
        owner = "model"
        kind = "prose"
        for pattern in patterns:
            if re.search(pattern["regex"], text, re.I):
                owner = pattern["default_owner"]
                kind = pattern["name"]
                break
        claims.append(
            Claim(
                heading_path=heading_path,
                text=text,
                kind=kind,
                owner=owner,
                unbound=True,
            )
        )
    return claims
Enter fullscreen mode Exit fullscreen mode

Step 3: Bind claims to cite roots, then drop the rest

Binding is a deterministic join against cite roots, not a second model call that re-explains the same paragraph. For API tables, join backtick-wrapped names to OpenAPI parameter names and require a full subset match. For changelog restatements, require a verbatim match against a bullet so paraphrase cannot invent downtime language. For commands, require the exact Make target or CLI path to exist before the sentence may remain.

# scripts/bind_claims.py (proposed, unexecuted)
from pathlib import Path

import yaml


def load_openapi_names(path: Path) -> set[str]:
    spec = yaml.safe_load(path.read_text())
    names: set[str] = set()
    for _route, methods in (spec.get("paths") or {}).items():
        for _method, op in (methods or {}).items():
            if not isinstance(op, dict):
                continue
            for param in op.get("parameters") or []:
                if "name" in param:
                    names.add(param["name"])
    return names


def bind(claims, repo: Path):
    openapi = repo / "openapi" / "openapi.yaml"
    names = load_openapi_names(openapi) if openapi.exists() else set()
    changelog_path = repo / "CHANGELOG.md"
    changelog = changelog_path.read_text() if changelog_path.exists() else ""
    makefile = (repo / "Makefile").read_text() if (repo / "Makefile").exists() else ""

    for claim in claims:
        if claim.kind == "api_table":
            tokens = {t.strip("`") for t in claim.text.split() if t.startswith("`")}
            if tokens and tokens <= names:
                claim.source_path = "openapi/openapi.yaml"
                claim.unbound = False
        elif claim.kind == "changelog" and claim.text.lstrip("- ") in changelog:
            claim.source_path = "CHANGELOG.md"
            claim.unbound = False
        elif claim.kind == "command":
            targets = {t.strip("`") for t in claim.text.split() if t.startswith("`")}
            if targets and all(t + ":" in makefile or t in makefile for t in targets):
                claim.source_path = "Makefile"
                claim.unbound = False
        elif claim.owner == "human":
            claim.unbound = True
    return claims
Enter fullscreen mode Exit fullscreen mode

A claim with owner human is not a passing result; it is a signal that the sentence must move. Move that sentence into a human-owned file with a reviewer, or delete it from the model draft entirely. Leaving it in docs/generated with unbound: true is the case the next test is designed to reject.

Step 4: Fail CI when a model file still contains unbound claims

Store accepted claims in a sidecar JSON file so later diffs show new promises as new records, not buried prose. The test below fails closed when a model-touched page contains unbound claims or is missing its sidecar. Run the gate on every pull request that touches docs/generated, the bind map, or the extractor scripts.

# tests/test_doc_bind.py (proposed, unexecuted)
import json
from pathlib import Path


def test_model_pages_have_no_unbound_claims():
    failures = []
    pages = list(Path(".").glob("docs/generated/**/*.md"))
    assert pages, "no generated pages found; check the glob before enforcing"
    for md in pages:
        sidecar = md.with_suffix(".claims.json")
        assert sidecar.exists(), f"missing claim sidecar for {md}"
        records = json.loads(sidecar.read_text())
        for row in records:
            if row.get("unbound"):
                failures.append(f"{md}: {row['text']}")
    assert failures == [], "Unbound claims in model drafts:\n" + "\n".join(failures)
Enter fullscreen mode Exit fullscreen mode
# Makefile (proposed, unexecuted)
.PHONY: docs-bind docs-draft docs-openapi-fixture

docs-bind:
    python scripts/extract_claims.py docs/generated
    python scripts/bind_claims.py docs/bind-map.yml
    pytest tests/test_doc_bind.py -q

docs-draft:
    @echo "Run the draft job only after docs-bind is green on the previous tree."
    @echo "Label the output as a proposal and keep it under docs/generated/."

docs-openapi-fixture:
    @test -f openapi/openapi.yaml || (echo "missing openapi/openapi.yaml" && exit 1)
Enter fullscreen mode Exit fullscreen mode

Example sidecar rows make the policy concrete for reviewers who do not want to read the extractor first. The first row is a restated parameter and may ship; the second row is a promise and must be dropped. Keep both rows in code review discussions so the failure reads as a missing source, not as a style nit.

[
  {
    "heading_path": "reference/widgets#create",
    "text": "`dry_run` is a boolean query parameter.",
    "kind": "api_table",
    "source_path": "openapi/openapi.yaml",
    "owner": "model",
    "unbound": false
  },
  {
    "heading_path": "reference/widgets#create",
    "text": "The endpoint is always production-ready and guaranteed within 50 ms.",
    "kind": "modal_promise",
    "source_path": null,
    "owner": "human",
    "unbound": true
  }
]
Enter fullscreen mode Exit fullscreen mode

A minimal OpenAPI fragment shows what a legal restatement is allowed to see. Anything beyond names, types, and required flags still belongs to a human-owned compatibility page.

# openapi/openapi.yaml (fixture for the proposed join)
paths:
  /widgets:
    post:
      parameters:
        - name: dry_run
          in: query
          required: false
          schema:
            type: boolean
Enter fullscreen mode Exit fullscreen mode

Where a free draft host participates

The draft step is the only stage that needs a model, and it should not share credentials with production deploy jobs. Teams that already use MonkeyCode can run that isolated draft step on its free model access and free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach, and that relationship should be read before any product detail. Treat that access only as an execution venue for drafts, not as a quota, hardware, quality, or permanence claim.

The binder, the OpenAPI join, and the pytest gate should stay in ordinary CI because they are deterministic and cheaper to audit. If the draft host is unavailable, the repository still has a bind map, claim records, and a failing test for new prose. Generation can pause without changing ownership rules, which is the point of keeping the gate independent from the model.

Prompt the draft job with the bind map rather than an open instruction to write documentation for the entire repository. A constrained prompt reduces unbound claims before continuous integration has to drop them in a later failing test. The template below is a proposal and should be versioned next to the bind map so prompt drift is reviewable.

# prompts/source-bound-draft.txt (proposed, unexecuted)
Read docs/bind-map.yml and only the cite_roots listed there.
Draft markdown under docs/generated/ for API tables and changelog restatements.
Do not write files matching human_only_globs.
Every sentence that contains a number, a modal promise, or a stability claim
must be omitted unless it is a verbatim restatement of a cite root.
Leave TODO(human) on any heading that needs a promise, a version policy,
or a workaround that is not in source.
Enter fullscreen mode Exit fullscreen mode

Local dry-run order stays boring on purpose. Inventory, extract, bind, and test should succeed on an empty docs/generated tree before any model write is enabled.

# proposed local sequence (unexecuted)
git ls-files openapi/openapi.yaml CHANGELOG.md Makefile docs/bind-map.yml
python scripts/extract_claims.py docs/generated
python scripts/bind_claims.py docs/bind-map.yml
pytest tests/test_doc_bind.py -q
Enter fullscreen mode Exit fullscreen mode

Limitations

Sentence splitting will mishandle abbreviations, markdown tables, and fenced command blocks until the extractor is tuned on real pages. Near-verbatim changelog matching will reject useful paraphrases, which is intentional because paraphrase is where invented downtime claims appear. OpenAPI joins will not catch semantic lies that use the right parameter names inside the wrong operational sentence. The pipeline does not measure readability, translation quality, or whether human-owned pages are factually correct.

Sidecar files and the bind map can rot if nobody owns docs/bind-map.yml the way CODEOWNERS owns review paths. If the map lists generated files as cite roots, the model will cite itself and the gate becomes empty theater. Re-run the inventory whenever OpenAPI paths move, or the join will fail closed on restatements that are still legitimate.

Who should not use this approach

Do not adopt this pipeline if the project has no OpenAPI, changelog, Makefile, or other checked-in source that could justify restatements. Do not use it to auto-publish customer support pages, security advisories, pricing tables, or legal text, even when a draft looks fluent. Do not wire the draft host into production secrets, and do not treat a green bind test as a substitute for review of human-owned sections. Teams that need marketing narrative rather than reference restatement will find cite-or-drop too strict and should keep generation out of the tree.

If the only goal is a faster first draft of a conceptual essay, skip the model write path and keep a human outline. The pipeline is for repositories that already have specs and need a mechanical stop against unofficial promises. Restate what the files already say, and leave every unsupported guarantee in a human-owned page with a named reviewer.

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

Top comments (1)

Collapse
 
ahmetozel profile image
Ahmet Özel

The restatement-versus-promise line is a sharper version of something I have only ever seen enforced by having the right reviewer happen to read the page. It also explains why this class of error survives review: a promise is grammatically indistinguishable from the sentences around it, so a diff full of accurate parameter tables reads as accurate throughout. Making the missing citation a CI failure moves the check from attention to mechanism, which is the only thing that scales. The failure mode worth guarding next is the citation that resolves but no longer says what it said - a sentence bound to a path stays green while the file changes underneath it, so the binding needs to pin a symbol or a content hash rather than a location. Otherwise you get the RAG problem in documentation form: a real citation supporting a claim that is quietly out of date.