DEV Community

Morgan Sun
Morgan Sun

Posted on

Envelope vs Clock: A Split Workflow for Async API Docs

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"]
Enter fullscreen mode Exit fullscreen mode

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])))
Enter fullscreen mode Exit fullscreen mode

Install and run:

pip install pyyaml
python check_async_docs.py docs/exports.md docs/async-jobs.ownership.yaml
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode


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`.
Enter fullscreen mode Exit fullscreen mode


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.”

  1. Extract the schema slice named in proof.
  2. Attach one fixture file, not a conversation history.
  3. Instruct the model to describe fields that exist in those inputs.
  4. Instruct it to omit intervals, retention, and “typical” latency.
  5. Write output only under envelope headings.
  6. Run check_async_docs.py.
  7. Leave clock headings untouched unless attested_by is 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
Enter fullscreen mode Exit fullscreen mode

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)