DEV Community

Avery Lin
Avery Lin

Posted on

Inventory Every Doc Claim Before a Model Writes a Sentence

Documentation generation fails at the sentence rather than the heading, because unfalsifiable promises hide inside otherwise ordinary paragraphs. A model that receives a blank section will often mix command examples with product intent, support scope, and architectural folklore. Reviewers then argue about tone while the dangerous claims are the ones no test could ever contradict. The workable unit is therefore a claim inventory that binds each sentence to a falsifier before any draft is requested.

Headings do not constrain what a sentence asserts

Most documentation pipelines still prompt against a title, a heading tree, or a short ticket summary from planning. That grain is too coarse, because one Getting Started section can mix install steps, platform lists, timeouts, and stability promises. Install steps and platform lists can be checked against scripts, container builds, and CI matrices that already exist. Stability promises cannot be checked against those artifacts, so they should remain human-owned even when the surrounding heading looks mechanical.

The same failure mode appears across generated runbooks, public SDK pages, and internal architecture notes that look complete. The draft reads fluent, the examples often compile, and the unbounded sentences sit between them as if equally sourced. A later incident then traces to a sentence nobody owned, because review was scoped to the file rather than the assertion. Moving the gate down to claims does not replace human writing; it stops models from authoring statements only a human can own.

Define a claim as a falsifiable assertion

For this workflow, a claim is one sentence a reader could treat as true about the product, the interface, or an operational procedure. Running make smoke from the repository root is a claim, because a missing target falsifies it during the next scheduled build. Calling an adapter the right default for regulated workloads is also a claim, yet nothing in the repository can falsify the word right. The inventory therefore records the sentence, a claim class, a source pointer, and a drafting permission that CI can compute without debating taste.

Treat the schema below as a labeled proposal rather than a production standard your organization has already ratified. Teams may keep the inventory beside the page, in a sidecar YAML file, or in a table reconstructed from annotations during CI. The required property is that drafting permission comes from the row, not from anyone's confidence in a particular model. Rows without a source pointer stay human-owned, even when they sit under a heading that looks entirely mechanical.

Decision table: class, falsifier, and drafting permission

Reviewers need a shared table because mixed claim classes inside one paragraph create arguments that sound like style debates. Command claims, interface claims, and runtime defaults can be drafted after a falsifier exists in the tree. Support boundaries, product intent, and compatibility promises stay human-owned even when the model could phrase them more smoothly. Unclassified sentences default to human-owned, which keeps the failure mode conservative when the inventory is incomplete.

Claim class Example sentence Acceptable falsifier Model may draft?
Command or path Run make smoke from the repository root. Makefile target, script, or CI job that executes it Yes, after the falsifier exists
Interface shape POST /v2/widgets returns 201 and a widget_id. OpenAPI file, contract test, or recorded fixture Yes, if the contract test is green
Runtime default The worker retries three times with a 30s backoff. Config file, flag default, or unit test of retry policy Yes, bound to that default
Support boundary Severity-one incidents receive a human reply within fifteen minutes. Published policy owned by support leadership No
Product intent Teams should prefer this adapter for regulated workloads. None in-repo; this is a judgment No
Compatibility promise This field will not break before version 3. Versioning policy plus a human sign-off No

If two people argue about a paragraph, they are usually arguing about mixed classes inside one block. Split the block until each sentence has one class, one falsifier, and one explicit drafting permission. The table then becomes the review surface, and the Markdown file becomes a rendering of already decided rows. Do not grant drafting permission to a row because the sentence looks simple; grant it only because a selector can fail.

Numbered workflow

1. Freeze the page outline without prose

Write headings and the reader task first, and refuse paragraphs until the claim inventory exists in the repository. The outline is a map of questions the page must answer, not permission to generate those answers in fluent prose. Record the intended audience, the environment they start from, and the commands they are expected to run after reading. This constraint keeps the later prompt from inventing a narrative solely in order to sound complete and confident.

2. Extract candidate claims from tests, contracts, and runbooks

Collect sentences people already believe, including comments in tests, OpenAPI descriptions, and existing runbook bullets that operators follow today. Do not ask a model to invent the inventory from a marketing page or from a blank product brief. Preferred sources are files that already fail CI when they drift, because those files are the only honest falsifiers. A short extractor can pull imperative lines from Markdown and test names, then write stub rows for a human to classify.

3. Bind each row to a falsifier or mark it human-owned

For every stub, either point at a file and a selector, or set the drafter field to human before generation starts. Selectors can be a Makefile target, a pytest node id, a YAML key, or an OpenAPI operationId that already exists. If the binder cannot name a selector, the row is not draftable, regardless of how simple the sentence sounds. This is the step that removes folklore from the generation queue and from the later review discussion.

4. Prompt the model with bound rows only

The prompt should receive the heading, the reader task, and the bound inventory rows, not the human-owned rows. Ask for sentences that stay inside those claims, plus code fences that match the bound commands exactly. Require the model to place a claim identifier comment beside each paragraph so reviewers can map prose back to rows. Drop any output sentence that does not cite a bound identifier, even if the sentence looks helpful.

5. Merge human-owned sentences after the draft

A person writes support boundaries, compatibility promises, and product intent only after the bound draft already exists. They paste those sentences into marked blocks that continuous integration treats as human-owned and non-rewritable text. The model must not receive those blocks in a later pass, because polish passes are how unbounded claims re-enter the page. Review then checks two questions only: did every generated paragraph cite a bound row, and did any human-owned block lose its meaning.

6. Gate the pull request on unmarked sentences

Continuous integration should fail the documentation job when rendered prose lacks a claim marker and sits outside a human-owned block. This rule is stricter than linting tone, and it is the control most teams skip after the first successful draft. Without the gate, the inventory becomes a planning document that nobody updates when the page changes. With the gate, a missing falsifier is a broken build rather than a review comment that can be deferred.

Artifact: inventory schema, page markers, and a CI gate

The following files are a labeled, reproducible skeleton, and paths should be adapted to the repository layout you actually maintain. The matcher uses claim identifier comments instead of exact prose matching, so writers can still edit wording inside a bound claim. A second check verifies that each falsifier path exists and that Makefile targets or test names still resolve. Teams that skip the existence check will accumulate inventories that point at history rather than at the current tree.

# docs/claims/getting-started.claims.yaml
page: docs/getting-started.md
reader_task: "Install the CLI and run the smoke command."
claims:
  - id: gs-01
    class: command
    text: "Run make smoke from the repository root after install."
    falsifier:
      kind: make_target
      path: Makefile
      selector: smoke
    drafter: model
  - id: gs-02
    class: interface
    text: "The smoke command exits 0 when /health returns 200."
    falsifier:
      kind: test
      path: tests/test_health.py
      selector: test_health_ok
    drafter: model
  - id: gs-03
    class: support_boundary
    text: "Production incidents are triaged by the on-call human, not by the assistant."
    falsifier: null
    drafter: human
Enter fullscreen mode Exit fullscreen mode
# Getting started

<!-- claim:gs-01 -->
Run `make smoke` from the repository root after you install the CLI.

<!-- claim:gs-02 -->
The smoke command exits 0 when `/health` returns 200.

<!-- human-owned:start -->
Production incidents are triaged by the on-call human, not by the assistant.
<!-- human-owned:end -->
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env python3
"""docs/tools/claim_gate.py

Labeled example: fail a docs page when prose is unmarked or bound
to a missing falsifier. Not a production linter for every Markdown dialect.
"""
from __future__ import annotations

import pathlib
import re
import sys

import yaml

CLAIM_MARK = re.compile(r"<!--\s*claim:([A-Za-z0-9_-]+)\s*-->")
HUMAN_BLOCK = re.compile(
    r"<!--\s*human-owned:start\s*-->(.*?)<!--\s*human-owned:end\s*-->",
    re.S,
)
HEADING = re.compile(r"^\s{0,3}#{1,6}\s", re.M)
FENCE = re.compile(r"```

.*?

```", re.S)
MAKE_TARGET = re.compile(r"^([A-Za-z0-9_.-]+):", re.M)


def strip_ignored(text: str) -> str:
    text = FENCE.sub("", text)
    text = HEADING.sub("", text)
    text = HUMAN_BLOCK.sub("", text)
    return text


def load_inventory(path: pathlib.Path) -> dict:
    data = yaml.safe_load(path.read_text()) or {}
    return {row["id"]: row for row in data.get("claims", [])}


def falsifier_exists(root: pathlib.Path, row: dict) -> bool:
    falsifier = row.get("falsifier") or {}
    rel = falsifier.get("path")
    if not rel:
        return False
    target = root / rel
    if not target.is_file():
        return False
    kind = falsifier.get("kind")
    selector = falsifier.get("selector", "")
    body = target.read_text()
    if kind == "make_target":
        return selector in MAKE_TARGET.findall(body)
    if kind in {"test", "openapi_operation"}:
        return selector in body
    return False


def main(page: pathlib.Path, inventory: pathlib.Path, root: pathlib.Path) -> int:
    rows = load_inventory(inventory)
    text = page.read_text()
    leftover = strip_ignored(text)
    marks = CLAIM_MARK.findall(text)
    errors: list[str] = []

    prose = CLAIM_MARK.sub("", leftover)
    prose = re.sub(r"\s+", " ", prose).strip()
    if re.search(r"[A-Za-z]{4,}", prose):
        errors.append("unmarked prose remains on the page")

    for claim_id in marks:
        row = rows.get(claim_id)
        if row is None:
            errors.append(f"unknown claim id {claim_id}")
            continue
        if row.get("drafter") != "model":
            errors.append(f"{claim_id} is not model-draftable")
            continue
        if not falsifier_exists(root, row):
            errors.append(f"{claim_id} falsifier is missing")

    for item in errors:
        print(f"UNBOUND: {item}")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(
        main(
            pathlib.Path(sys.argv[1]),
            pathlib.Path(sys.argv[2]),
            pathlib.Path(sys.argv[3]),
        )
    )
Enter fullscreen mode Exit fullscreen mode
.PHONY: docs-claim-gate
docs-claim-gate:
    python docs/tools/claim_gate.py docs/getting-started.md docs/claims/getting-started.claims.yaml .
Enter fullscreen mode Exit fullscreen mode

Do not replace the YAML inventory with a model-judged score that a sentence seems sourced, because that reintroduces the original problem inside the checker. Exact identifiers plus file existence are boring, and that boredom is the point of a gate. Keep the tool small enough that a reviewer can audit it in one sitting without trusting another model.

Where a free model and a free server participate

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

The drafting step needs a model that can rewrite bound rows into a page without receiving the human-owned rows in the same prompt. MonkeyCode's free model access is sufficient for that constrained rewrite, because the inventory already decided what may be said. The free server option runs the claim gate and falsifier checks on a schedule, so the control does not depend on one laptop. Neither capability chooses claim classes for you, and neither should be asked to invent falsifiers that are not already in the tree.

If you already draft against a documentation repository, run the inventory and the gate before requesting a full page. The useful output of that pass is the unbound list, not another longer Markdown file from the model. Expanding unbound rows is human work, and it should happen before any model sees the outline again.

Limitations

Exact identifier comments will not save a page if authors paste unmarked paragraphs from chat logs into the file. The extractor will miss claims inside tables, diagrams, and screenshots, which still need a human pass before publication. Contract tests prove interface shape at one commit; they do not prove that a default remains appropriate after a product change. The sentence splitter assumes English punctuation, which fits poorly for reference tables and for languages without that marking.

The method does not estimate factual accuracy beyond confirming that a named test or target still exists in the tree. A green test can document a behavior you already intend to delete in the next release. Ownership of deprecation still sits with humans, and those rows should switch to human drafting once the decision is political.

Who should not use this approach

Skip the inventory if the document is a narrative postmortem, a design rationale, or a one-off workshop outline without runnable checks. Those pages are mostly judgment, and building YAML rows for them is ceremony that will not produce a falsifier. Skip it if the repository has no tests, no OpenAPI file, and no runnable Makefile, because almost every row will be human-owned. Also skip it for legal terms, pricing pages, and security commitment documents, where a fluent wrong sentence is not a failed job.

Teams that want a model to write the docs from a product brief will find this workflow hostile, and that hostility is intentional. The inventory is a compiler for documentation claims, not a style guide for polishing generated paragraphs. Compilers feel inconvenient until the alternative is an unbound sentence sitting on a customer support page.

Top comments (0)