Migration documentation fails in review when a generator writes caller obligations that no schema, test, or changelog entry can support. A practical control is to split every guide into observed-change blocks a model may draft and obligation blocks a human must own. The rest of this article specifies a claim-type taxonomy, a numbered pipeline, and a small Python gate you can run in CI. The method stays useful even if every product mention below is removed from the workflow.
Conceptual tutorials and migration guides are the usual failure surface, not field tables extracted from OpenAPI. A model can restate that a handler signature changed, a fixture moved, or a status code appeared in a contract test. It cannot honestly decide whether callers must migrate this week, whether old payloads remain supported, or whether a deprecation is a promise. Those sentences are product judgments, and they belong in locked lanes.
Claim types that actually change the merge gate
Treat each documentation block as one of four claim types, and refuse mixed types inside a single fenced region. OBSERVED restates repository evidence such as paths, symbols, status codes, and test names. PROCEDURE restates commands or scripts that already exist in the tree and can be executed. OBLIGATION tells a caller what they must, should, or must not do after the change. PROMISE asserts compatibility, support windows, severity, or future behavior that no current file can prove.
A generator may fill OBSERVED blocks, and it may copy PROCEDURE blocks only when every command maps to a checked-in script or Makefile target. It must not create OBLIGATION or PROMISE text, including softened forms such as “you will want to” or “this is safe to ignore.” Reviewers then stop arguing about tone and start arguing about whether a sentence has a cite.
Inputs the pipeline is allowed to see
Build a change inventory from git, not from a chat transcript, before any drafting model runs. The inventory is a JSON list of file-level facts: path, kind, symbol if parseable, and the test or schema pointer that makes the change observable. Anything that cannot be pointed at stays out of the model context, which is how assumed caller impact is kept from entering the draft.
Label the following as a proposed local workflow rather than a production case study. It assumes a conventional docs/migrations/ tree, a main branch, and Python 3.11 available on the runner. Adjust paths if your repository uses a different layout, but keep the claim-type split intact.
# proposed: inventory only committed diffs against main
git fetch origin main
git diff --name-status origin/main...HEAD > /tmp/mig-files.txt
git diff --unified=0 origin/main...HEAD -- '*.py' '*.ts' 'openapi*.yaml' 'tests/**' \
> /tmp/mig-hunks.diff
Numbered workflow
-
Reserve lanes in the markdown file before drafting. Each section opens with an HTML marker the gate can parse, so a model cannot “helpfully” continue into the next heading. Humans pre-create empty
OBLIGATIONandPROMISEsections rather than asking a model to leave them blank. - Extract an observed-change inventory from the diff. A small script lists added or removed symbols and test node ids. The script does not infer migration difficulty, rollout order, or customer impact from hunk size.
-
Hand the model only
OBSERVEDslots plus the inventory JSON. The prompt, if you use one, is a fill-in-the-blank instruction: restate inventory rows as prose, cite the pointer, and stop. No other files are attached. -
Run the claim-type gate on the filled file. The gate fails the job when obligation verbs appear inside
OBSERVEDorPROCEDUREregions, or when aPROMISEregion is non-empty without a human trailer. -
Require a human trailer on every
OBLIGATIONandPROMISEblock. The trailer is a reviewer identity and a date, not a model signature. Merge remains blocked until those blocks are either filled by a person or explicitly markednonewith a reason.
Artifact: markers, inventory, and a CI gate
The markdown contract looks like the following proposed skeleton. Keep the markers ugly and machine-first; readers never need to love the comments.
<!-- LANE:OBSERVED owner=generator -->
## Observed changes
<!-- filled from inventory.json only -->
<!-- /LANE:OBSERVED -->
<!-- LANE:PROCEDURE owner=generator cite=scripts/migrate_widgets.sh -->
## Commands that already exist
<!-- /LANE:PROCEDURE -->
<!-- LANE:OBLIGATION owner=human -->
## Caller obligations
<!-- human fills or writes none -->
<!-- /LANE:OBLIGATION -->
<!-- LANE:PROMISE owner=human -->
## Compatibility statements
<!-- human fills or writes none -->
<!-- /LANE:PROMISE -->
A proposed inventory extractor can stay dumb and still be useful. It records evidence, not narrative, which is the entire point of the OBSERVED lane.
# proposed example: tools/inventory_diff.py
from __future__ import annotations
import json, re, sys
from pathlib import Path
SYMBOL = re.compile(r"^[+-](?:def|class|export (?:async )?function) (\w+)", re.M)
TEST_ID = re.compile(r"^[+-](?:def |it\(|test\()['\"]?(\w+)", re.M)
def inventory(diff_text: str) -> list[dict]:
rows: list[dict] = []
current = None
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
current = line[6:]
continue
if current is None:
continue
for rx, kind in ((SYMBOL, "symbol"), (TEST_ID, "test")):
for match in rx.finditer(line):
rows.append({
"path": current,
"kind": kind,
"name": match.group(1),
"added": line.startswith("+"),
})
return rows
if __name__ == "__main__":
text = Path(sys.argv[1]).read_text(encoding="utf-8")
json.dump(inventory(text), sys.stdout, indent=2)
sys.stdout.write("\n")
The gate below is the merge-relevant artifact. It is deliberately lexical: it does not score “helpfulness,” and it does not try to understand English beyond banned obligation cues inside machine lanes.
# proposed example: tools/claim_gate.py
from __future__ import annotations
import re, sys
from pathlib import Path
LANE = re.compile(
r"<!-- LANE:(OBSERVED|PROCEDURE|OBLIGATION|PROMISE) owner=(generator|human)(.*?)-->"
r"(.*?)<!-- /LANE:\1 -->",
re.S,
)
OBLIGATION_CUES = re.compile(
r"\b(must|must not|required to|you should|callers? (?:must|should)|"
r"breaking for|migrate before|unsupported after|guaranteed|SLA|we will)\b",
re.I,
)
HUMAN_TRAILER = re.compile(r"<!-- human-owned:\s*\S+;\s*\d{4}-\d{2}-\d{2}\s*-->")
def check(markdown: str) -> list[str]:
errors: list[str] = []
found = list(LANE.finditer(markdown))
if not found:
return ["no LANE markers found"]
for match in found:
kind, owner, meta, body = match.group(1), match.group(2), match.group(3), match.group(4)
cues = sorted(set(c.lower() for c in OBLIGATION_CUES.findall(body)))
if kind in {"OBSERVED", "PROCEDURE"} and owner != "generator":
errors.append(f"{kind} must be owner=generator")
if kind in {"OBLIGATION", "PROMISE"} and owner != "human":
errors.append(f"{kind} must be owner=human")
if kind in {"OBSERVED", "PROCEDURE"} and cues:
errors.append(f"{kind} contains obligation cues: {cues}")
if kind == "PROCEDURE" and "cite=" not in meta:
errors.append("PROCEDURE lane missing cite= to a script path")
if kind in {"OBLIGATION", "PROMISE"}:
empty = not body.strip() or "<!-- human fills" in body
if not empty and not HUMAN_TRAILER.search(body):
errors.append(f"{kind} lacks human-owned trailer")
return errors
if __name__ == "__main__":
text = Path(sys.argv[1]).read_text(encoding="utf-8")
problems = check(text)
if problems:
print("claim_gate failed:")
for item in problems:
print(f"- {item}")
sys.exit(1)
print("claim_gate ok")
A proposed unit check keeps the gate honest when someone later “improves” the regex. Put the fixtures next to the tool so a docs-only pull request still runs them.
# proposed example: tools/test_claim_gate.py
from claim_gate import check
CLEAN = """
<!-- LANE:OBSERVED owner=generator -->
`WidgetHandler.fetch` gained argument `cursor` (cite: src/widget.py).
<!-- /LANE:OBSERVED -->
<!-- LANE:OBLIGATION owner=human -->
Callers must pass `cursor` after 2026-10-01.
<!-- human-owned: alex; 2026-09-09 -->
<!-- /LANE:OBLIGATION -->
"""
DIRTY = """
<!-- LANE:OBSERVED owner=generator -->
Callers must migrate before next week or requests are guaranteed to fail.
<!-- /LANE:OBSERVED -->
"""
def test_clean_passes():
assert check(CLEAN) == []
def test_observed_cannot_promise():
errors = check(DIRTY)
assert any("obligation cues" in item for item in errors)
python tools/inventory_diff.py /tmp/mig-hunks.diff > docs/migrations/_inventory.json
python tools/claim_gate.py docs/migrations/2026-09-09-widgets.md
python -m pytest tools/test_claim_gate.py -q
When a team wants a model to fill only the OBSERVED lane from that inventory file, MonkeyCode's free model access and free server option can run that narrow drafting job beside the gate. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The server is relevant here as a place to keep the corpus and the inventory on one machine, not as a substitute for the human trailer on obligation text.
What the model is for, and what it is not for
The model’s job is compression of an already extracted inventory into readable bullets with citations. It is not a product manager, a support lead, or a compatibility oracle. If the inventory is empty, the correct generated section is empty, and the human still owns the decision to publish “no caller action.” Silence in the OBSERVED lane is a successful run, not a defect to be filled with speculation.
Do not compensate for a thin inventory by attaching extra repository files “for context.” Extra context is how obligation language leaks into restatement prose. If a symbol change matters, add a parser rule or a test node id so the inventory can cite it. If it cannot be cited, it is not observed, and it does not belong in a generated block.
Limitations
Lexical gates miss obligations written as pure description, such as “older clients stop receiving events after the deploy.” They also flag legitimate restatements that quote a spec sentence containing the word “must,” which is why quoted RFC lines should live in a human lane or be wrapped as citations without surrounding advice. Regex owner trailers can be forged in a hostile fork; the control assumes ordinary internal review, not an adversarial author.
The inventory script above understands only a few declaration forms and will under-count changes in generated code, macros, or configuration DSLs. Teams that ship binary protocols or hand-written wire docs need a richer extractor before this split is safe. The workflow also does not measure whether human-owned promises are true; it only measures that a model did not write them.
Who should not use this approach
Do not use claim-type lanes as a substitute for legal review on licenses, privacy notices, or security advisories, because those documents are promises even when they describe files. Do not use it on greenfield vision docs where there is no repository evidence and the entire page is judgment. Do not use it if your process requires a single undifferentiated markdown file with no markers, because the gate has nothing to enforce.
Skip the model entirely when the diff is one function and a human can restate it faster than inventory JSON can be reviewed. The split is for recurring migration guides where generated prose otherwise accumulates unverifiable “you should” sentences. If your reviewers already reject uncited obligations, keep their checklist and add the gate; do not add a drafting model just to justify the markers.
A useful next step is to fail CI on unmarked markdown under docs/migrations/ so new files cannot bypass the lanes. After that, teach the extractor one more language in your tree rather than widening the model prompt. The durable output is not friendlier copy. It is a guide whose observed changes can be regenerated, and whose obligations still have a person’s name on them.
Top comments (0)