Consider a generated reference page that tells callers to retry HTTP 429 for 30 seconds. The OpenAPI file lists 429 as a possible status. It does not define a wait, a budget, or a guarantee. The sentence is fluent, and it is also a contract the service never signed.
That mix is easy to miss once a model writes the whole page. Examples and obligations share one voice. Readers cannot tell which lines illustrate a fixture and which lines bind a client.
This workflow marks the difference before generation starts. A model may draft illustrative text. A human must own every binding sentence.
Two sentence classes, one page
Treat each paragraph as one of two classes before you ask for a draft.
Illustrative text shows a shape. It can include a sample request, a short walkthrough, or a field gloss that restates the schema. It can be regenerated when the fixture changes.
Binding text creates an obligation. Modal verbs such as MUST and SHALL do this. So do invented status rules, retry windows, rate numbers, deprecation dates, auth lifetimes, and availability promises. Those lines survive a regen only if a named owner re-approves them.
Mixing the classes is the actual bug. A polished page can still be wrong when one binding sentence was inferred rather than copied from a source.
What the model may draft
Keep the model inside a marked block. Inside that block it may:
- Restate a field that already exists in the schema, using plain language.
- Expand a recorded HTTP fixture into a walkthrough, without adding headers or statuses the fixture does not contain.
- Propose a shorter introduction for an example.
- Flag awkward phrasing for a human to accept or drop.
It may not invent a status code, a quota, a clock, or a compatibility promise. If the schema is silent, the draft stays silent. Silence is a valid output.
The fixture is the source for the draft, not the model's memory of similar APIs.
{
"request": {"method": "GET", "path": "/orders"},
"response": {
"status": 200,
"body": [{"id": "o_1", "status": "paid"}]
}
}
A faithful draft says that this call returned one paid order. It does not add a 429 branch, a retry loop, or a note about typical latency.
What a human must own
A human owner signs a separate block. That block is the only place for normative claims:
- Required and forbidden client behavior.
- Auth scopes and token lifetimes.
- Error semantics that are not a pure copy of the schema.
- Rate, retry, timeout, and deprecation statements.
- Any number that is not copied from a primary spec or fixture.
Keep the owner block short. If it turns into a second essay, normative claims are hiding in prose again. Move examples back to the model block and leave the rule behind.
Markers the checker can see
HTML comments stay out of the rendered page and remain visible to a script.
<!-- claim-class: model -->
`list_orders` returned one paid order in `orders.fixture.json`.
The `status` field is the enum value `paid` from that fixture.
<!-- /claim-class: model -->
<!-- claim-class: owner -->
Callers MUST send `Idempotency-Key` on POST /orders.
A missing key is rejected with 400. No retry window is defined in this page.
<!-- /claim-class: owner -->
The first block can be rewritten on the next regen. The second block stays frozen until the owner edits it. Deleting the owner block is a failed page, not a cleanup.
A claim-class checker
The script is a proposal. It is not a measured benchmark, and it does not prove the owner block is true. It fails the check when binding language appears inside a model block, or when the owner block is missing.
#!/usr/bin/env python3
"""Fail if a model-owned block contains binding language. Proposal only."""
from __future__ import annotations
import re
import sys
from pathlib import Path
OPEN = "<!-- claim-class: model -->"
CLOSE = "<!-- /claim-class: model -->"
OWNER = "<!-- claim-class: owner -->"
BINDING = re.compile(
r"\b(MUST|SHALL|REQUIRED|MUST NOT|SHALL NOT)\b"
r"|retry (?:for|after) \d+"
r"|rate limit(?:ed)? to \d+"
r"|deprecated (?:on|as of) \d{4}"
r"|expires after \d+"
r"|guaranteed (?:latency|uptime)"
r"|\bSLA\b"
r"|returns? (?:HTTP )?status \d{3}",
re.IGNORECASE,
)
def blocks(text: str) -> list[tuple[int, str]]:
found = []
start = 0
while True:
i = text.find(OPEN, start)
if i < 0:
return found
j = text.find(CLOSE, i)
if j < 0:
raise SystemExit("unclosed model block")
found.append((text[:i].count("\n") + 1, text[i + len(OPEN):j]))
start = j + len(CLOSE)
def main(path: str) -> int:
text = Path(path).read_text(encoding="utf-8")
if OWNER not in text:
print("missing owner block")
return 2
hits = 0
for line, body in blocks(text):
for match in BINDING.finditer(body):
hits += 1
clip = body[max(0, match.start() - 24): match.end() + 24]
print(f"{path}:{line}: binding language in model block: {clip!r}")
return 1 if hits else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: claim_class_gate.py PAGE.md")
raise SystemExit(main(sys.argv[1]))
Run the checker against the page file, not against the model transcript.
python3 claim_class_gate.py docs/orders.md
echo "exit=$?"
Exit codes
Exit 0 means no binding pattern was found in model blocks and an owner block exists. Exit 1 means a binding pattern leaked into a draft. Exit 2 means nobody owns the page. Treat exit 2 as a process failure even when the prose looks finished.
Decision table
Use the table as a review aid, not as a style guide.
| Sentence | Class | Who writes it | On regen |
|---|---|---|---|
| The fixture shows one paid order. | Illustrative | Model, then a skim | Rewrite allowed |
Field status matches the fixture enum. |
Illustrative, if copied | Model | Rewrite only after the fixture changes |
| Callers MUST send Idempotency-Key. | Binding | Human | Frozen until the owner edits |
| Retry 429 for 30 seconds. | Binding | Human, only if a spec says so | Reject when unsourced |
| Typical latency feels low. | Neither | Nobody | Delete |
The last row is the one teams skip. Vague performance talk is not an example and not a rule. Delete it. Do not promote it into the owner block to make the checker quiet.
Where the draft pass runs
The split pays off only if redrafting the illustrative block is cheap. Disclosure: This article was prepared as part of MonkeyCode's product outreach. In this workflow the illustrative block can be drafted with MonkeyCode's free model access, and the checker can run on the free server option so a doc change does not need a separate paid runner.
Both points are availability claims supplied for this article. They are not a named model, a token quota, a hardware profile, or a statement that the offer is permanent. Confirm the current terms before a pipeline depends on them. The checker is ordinary Python. The server is a place to run it, not a source of truth for the API.
If you want a low-cost place to practice the split, start with one endpoint, draft only the model block, and keep the owner block in the same pull request.
Test plan
The cases below are a plan, not a report of an executed run. Apply them to a fixture repo before you trust the gate.
- Illustrative block plus a real owner block: expect exit 0.
- The same page with
MUSTinside the model block: expect exit 1 and a line reference. - No owner comment: expect exit 2, even if every sentence is accurate.
- Owner block contains
MUST: expect exit 0. Binding language is allowed there. - Model block says
returns status 429while the fixture has no 429: expect exit 1. - Model block copies a status line that also appears in the schema: still expect exit 1. Status rules stay with the owner so the checker does not guess intent.
- Unclosed model comment: expect a hard error, not a pass.
- Phrase
expires after 1 hourinside the model block: expect exit 1 after the pattern list includes that shape.
Case 6 is strict on purpose. A duplicated status line looks harmless and still drifts when the schema moves. One home for it is enough.
A failure, then the fix
Put the sample sentence back into the model block: clients MUST retry 429 responses for 30 seconds. The checker returns exit 1. The fix is not to soften MUST into "should". Softening hides the same invention.
Delete the sentence if no spec defines a retry. If a spec does define one, copy that rule into the owner block and name the spec section beside it. The model block should then describe only the recorded call.
A quieter leak looks like this: authentication uses a bearer token that expires after one hour. There is no MUST. A short pattern list can miss it. The hour is still a binding number. Add the pattern once you see the leak, and keep the number in the owner block only when the auth spec states it.
That is the maintenance loop. The regex grows from leaks you observed. It does not begin as a complete grammar of obligation.
Limitations
The regex is a tripwire, not a parser. It misses soft obligations such as "clients should generally wait." It also flags harmless uses of "required" in a field gloss. Review the hits. Do not auto-delete them.
Some publishers strip HTML comments on import. If that happens, store the class in a sidecar and point the checker at the sidecar. A missing marker should fail closed.
A signed owner block can still be false. The gate checks placement, not truth. Pair it with the spec or fixture you already treat as canonical. Do not ask the model to confirm the owner block by rewriting it in a smoother tone.
Free model access will vary with page size and with whatever limits are current. Do not design the loop around an assumed token budget. If a page does not fit, split the illustrative block. Do not ask the model to compress binding rules into the draft to save space.
Who should not use this
Skip the gate when you have no schema and no fixture. The model will fill the silence with plausible obligations, and the checker catches only the loud ones.
Skip it for security advisories, legal terms, and incident reports. Those pages are binding from the first sentence. A model draft is the wrong first move.
Skip it when the same person authors, reviews, and publishes with no second read. The owner comment becomes theater. The gate helps when someone else can reject a binding sentence without rewriting the example.
Top comments (0)