A payments platform exposes POST /v1/exports. The handler returns 202 Accepted, a job_id, and a status_url. A doc generator then writes the page in one pass. The JSON envelope matches the OpenAPI file. The same page also says “poll every five seconds” and “files remain available for 30 days.”
Neither number lives in the schema. Neither number is asserted by a test. Support treats both sentences as contract. Clients poll at 5s. The status endpoint becomes the incident. The docs page still looks finished.
That is the failure this workflow isolates. Shape is extractable. Time is a promise.
Why async docs fail closed-looking
OpenAPI is strong on envelopes. It names fields, enums, and required keys. It is weak on clocks. Polling cadence, client timeouts, result retention, and webhook retry backoffs are operational promises. They are not properties of a JSON Schema object.
A model asked to “finish the page” will fill the silence. It prefers round numbers. It prefers advice that sounds like a runbook. Reviewers skim because the examples look real. The invented clock ships.
The industry pattern is familiar in 2026: generated text makes it cheap to appear to have done the SRE writing. The missing work is not prose. The missing work is who attests duration, retention, and retry.
Two documents, one URL
Keep a single public page if you must. Internally, treat it as two documents.
Envelope (a model may draft)
- Request and response schemas
- Required versus optional fields
- Example payloads copied from fixtures
- Job state names (
queued,running,succeeded,failed) - Error object shape
- Idempotency header names (not retry windows)
Clock (a human must own)
- Recommended poll interval
- Maximum poll duration / client timeout
- Result retention and deletion
- Webhook retry schedule and signing clock skew
- Any “usually completes in…” sentence
- Any SLA-like or billing-like duration
If a sentence contains a duration, a schedule, or a retention claim, it is clock. It does not belong in an unattended generation lane.
Artifact 1: an ownership manifest
Label this as a proposed layout. It is not a vendor format. Put it next to the OpenAPI file.
# docs/async-jobs.ownership.yaml
page: /docs/exports
source_schema: openapi/exports.yaml
fixture_dir: fixtures/exports/
envelope:
owner: docs-generator
may_regenerate: true
sections:
- heading: Submit an export
proof: openapi/exports.yaml#/paths/~1v1~1exports/post
- heading: Job status object
proof: openapi/exports.yaml#/components/schemas/ExportJob
- heading: Example status payload
proof: fixtures/exports/status.succeeded.json
clock:
owner: platform-oncall
may_regenerate: false
sections:
- heading: Polling
attested_by: sre-exports
attested_on: 2026-09-18
- heading: Result retention
attested_by: sre-exports
attested_on: 2026-09-18
- heading: Webhook delivery
attested_by: sre-exports
attested_on: 2026-09-18
forbidden_in_envelope:
- duration_units: [ms, s, sec, second, minute, hour, day, week]
- phrases: ["poll every", "retry after", "kept for", "retained for", "usually completes"]
The manifest does three jobs. It names a proof artifact for every envelope heading. It names a human owner for every clock heading. It lists lexical patterns that must not appear in regenerated prose.
No proof, no regenerate. No attestation date, no publish.
Artifact 2: a time-claim checker
The checker is ordinary Python. Run it in CI against the rendered Markdown, not against the model transcript. Models drift. Files do not.
#!/usr/bin/env python3
"""Fail CI when clock claims leak into envelope sections. Proposed example."""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
DURATION = re.compile(
r"\b(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|secs?|seconds?|"
r"m|mins?|minutes?|h|hrs?|hours?|d|days?|w|weeks?)\b",
re.I,
)
CLOCK_PHRASE = re.compile(
r"poll every|retry after|kept for|retained for|usually completes|within \d",
re.I,
)
HEADING = re.compile(r"^(#{2,3})\s+(.+?)\s*$", re.M)
def load_manifest(path: Path) -> dict:
return yaml.safe_load(path.read_text())
def split_sections(markdown: str) -> list[tuple[str, str]]:
parts = HEADING.split(markdown)
sections = []
# parts: preamble, hashes, title, body, hashes, title, body, ...
i = 1
while i + 2 < len(parts):
title = parts[i + 1].strip()
body = parts[i + 2]
sections.append((title, body))
i += 3
return sections
def envelope_titles(manifest: dict) -> set[str]:
return {row["heading"] for row in manifest["envelope"]["sections"]}
def clock_titles(manifest: dict) -> set[str]:
return {row["heading"] for row in manifest["clock"]["sections"]}
def main(md_path: Path, manifest_path: Path) -> int:
manifest = load_manifest(manifest_path)
md = md_path.read_text()
env = envelope_titles(manifest)
clock = clock_titles(manifest)
failures = []
seen = {title for title, _ in split_sections(md)}
for title in env | clock:
if title not in seen:
failures.append(f"missing heading: {title}")
for title, body in split_sections(md):
if title not in env:
continue
if DURATION.search(body) or CLOCK_PHRASE.search(body):
failures.append(f"clock claim in envelope section '{title}'")
for row in manifest["clock"]["sections"]:
if not row.get("attested_by") or not row.get("attested_on"):
failures.append(f"unattested clock section '{row['heading']}'")
for row in failures:
print(row, file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2])))
Install and run:
pip install pyyaml
python check_async_docs.py docs/exports.md docs/async-jobs.ownership.yaml
A non-zero exit is the contract. Do not parse model confidence. Do not ask the model whether it invented a number. The file either contains a duration in an envelope heading or it does not.
Envelope Markdown the checker will accept
## Submit an export
`POST /v1/exports` returns `202 Accepted` with `job_id` and `status_url`.
Both fields are required. See the schema component `ExportAccepted`.
## Job status object
`GET /v1/exports/{job_id}` returns `state`, `error`, and `result_href`.
`state` is one of `queued`, `running`, `succeeded`, `failed`.
`result_href` is present only when `state` is `succeeded`.
## Example status payload
json
{
"job_id": "exp_08c1",
"state": "succeeded",
"result_href": "/v1/exports/exp_08c1/content"
}
## Polling
<!-- clock: attested_by=sre-exports attested_on=2026-09-18 -->
Poll `status_url` no more than once every 15 seconds.
Stop after 15 minutes and open a support ticket with `job_id`.
## Result retention
<!-- clock: attested_by=sre-exports attested_on=2026-09-18 -->
Successful export files remain downloadable for 72 hours.
After that, `GET` on `result_href` returns `410 Gone`.
markdown
The first three headings can be regenerated from schema plus fixtures. The last two cannot. If a regenerate step rewrites Polling, CI fails on the missing attestation, even when the prose looks nicer.
Drafting protocol (envelope only)
Treat generation as a bounded transform, not as “write the page.”
- Extract the schema slice named in
proof. - Attach one fixture file, not a conversation history.
- Instruct the model to describe fields that exist in those inputs.
- Instruct it to omit intervals, retention, and “typical” latency.
- Write output only under envelope headings.
- Run
check_async_docs.py. - Leave clock headings untouched unless
attested_byis still on the hook.
A prompt that says “don’t invent numbers” is not a control. The checker is the control. The prompt is a courtesy to reduce failed CI runs.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access is enough to draft envelope headings from schema text. A free server option is enough to run the checker on every docs PR. Neither replaces the attestation line on a clock section. Do not feed clock headings to a model and then “see if it looks right.”
Decision table
| Claim in the page | Source of truth | May a model draft it? | Ship gate |
|---|---|---|---|
| Field names and types | OpenAPI / JSON Schema | Yes | Schema diff |
| Example JSON | Checked-in fixture | Yes, copy only | Byte-compare to fixture |
| State enum values | Schema enum
|
Yes | Enum set equality |
| Poll interval | On-call runbook | No | Manifest attestation |
| Client timeout | On-call runbook | No | Manifest attestation |
| Result retention | Data-retention policy | No | Manifest attestation |
| Webhook retry backoff | Delivery config | No | Manifest attestation |
| “Usually takes ~N minutes” | None, unless measured | No | Ban in envelope lane |
Copy the table into the repo. When a writer argues about a sentence, classify the row first. Arguments about tone come after classification.
What this does not catch
The checker is lexical. It will miss “poll frequently” with no unit. It will miss “we keep files around for a while.” It will miss a duration written as PT72H if you never taught it ISO-8601. Extend the regex only with patterns you have seen in your own corpus.
It also does not prove that an attested number is correct. A human can attest “poll every 15 seconds” on a fleet that 429s at that rate. Attestation is ownership, not load-test evidence. Pair this workflow with a status-endpoint budget test if polling advice is customer-facing.
Webhooks need a second proof artifact: the signing secret rotation note is clock-adjacent and legal-adjacent. Keep it out of envelope generation even when the payload schema is draftable.
Who should not use this
Skip the split if your API is synchronous and your docs never mention time. A 200 with a JSON body and no job object does not need a clock ledger.
Skip it if you have no owner who will attest. An empty attested_by field is a false gate. The checker will pass the moment someone types a name. Names without on-call rotation are decoration.
Skip it if legal or compliance text is mixed into the same headings as schema prose. Split those pages first. A model that is allowed to rewrite a retention sentence is a policy incident, not a docs incident.
A short working sequence
# 1. Freeze proofs
ls openapi/exports.yaml fixtures/exports/
# 2. Draft envelope headings only (local script or any model runner)
python draft_envelope.py --schema openapi/exports.yaml --out /tmp/envelope.md
# 3. Splice without touching clock headings
python splice_docs.py --base docs/exports.md --envelope /tmp/envelope.md
# 4. Gate
python check_async_docs.py docs/exports.md docs/async-jobs.ownership.yaml
The splice step is the easy one to get wrong. If your tool reflows the whole Markdown file, clock comments move or vanish. Prefer heading-bounded replacement. Replace by title, not by byte offset.
Envelope prose can be regenerated whenever the schema changes. Clock prose changes when an owner accepts a new promise. Those two frequencies are not the same. Docs pipelines that ignore the difference will keep publishing complete-looking pages with unearned clocks.
If you want a runner for the checker and a free model lane that is limited to envelope inputs, MonkeyCode is one place to try that split. The ownership manifest still has to live in your repo.
Top comments (0)