Event documentation stays trustworthy when payload facts compile from source structs and humans alone sign delivery semantics. A model may draft unsigned field summaries from those facts, but it must never invent retry policy or PII class. The docs build should fail when overlay cells are empty, unsigned, or mismatched against the generated catalog. The remainder of this article proposes a reproducible two-lane pipeline that enforces that ownership split.
This pattern is a labeled proposal, not a production case study, and every command below is an unexecuted example. Teams that already emit OpenAPI or error catalogs can reuse the gate without adopting the drafting host. The useful split is mechanical: compile-lane cells come from parsers, while signature-lane cells require a named human. Treat model prose as a disposable draft that never becomes the source of operational truth.
Why event pages drift even when types are correct
Typed event structs usually stay in lockstep with producers because compilers and tests punish silent field changes. The prose around those structs does not enjoy the same pressure, so retry language and PII notes rot independently. Subscriber teams then copy stale delivery claims into runbooks, and on-call pages argue about at-least-once behavior that nobody signed. A catalog that only dumps JSON Schema still leaves the operational contract unsigned.
Generated field tables are cheap and should be cheap. Human sentences about deletion windows, regional residency, and who pages at 02:00 are not cheap, and they should stay scarce. Mixing those lanes in one chat transcript hides the cost until an incident review asks who approved the wording. The pipeline below keeps the cheap lane generated and the scarce lane reviewable.
What a model may draft versus what a human must own
Use a decision table before any drafting host is involved, because the table is the actual policy artifact. Rows marked compile may be regenerated on every commit without a reviewer rewriting them. Rows marked signature must remain empty or fail CI until a human writes a value and a reviewer initials.
| Cell | Lane | Allowed generator | Human must own |
|---|---|---|---|
| Event name, version, producer module | Compile | AST or regex extractor | No, except rename policy |
| Field names, types, required flags | Compile | Type annotations | No |
| Unsigned one-line field gloss | Draft | Model from facts JSON only | Optional edit, never source |
| Delivery guarantee (0/1/N) | Signature | None | Yes |
| Retry budget and dead-letter owner | Signature | None | Yes |
| PII class and retention pointer | Signature | None | Yes |
| Subscriber blast notes | Signature | None | Yes |
| On-call rotation and escalation | Signature | None | Yes |
The draft lane is optional. If a team skips the model, the catalog and overlay still render, which is the point of keeping facts deterministic. When a drafting pass is useful, send only the facts file, never repository secrets, never customer payloads, and never the overlay itself.
Step 1: Keep a tiny typed registry the extractor can read
Proposed source of truth is a Python module the producer already imports, not a Markdown file that someone might forget. The example uses dataclasses so field names and types remain ordinary code. Do not put retry promises or PII labels in this module; those belong in the overlay.
# events/registry.py (proposed example)
from dataclasses import dataclass, field
from typing import Literal
@dataclass(frozen=True)
class FieldSpec:
name: str
type_name: str
required: bool
@dataclass(frozen=True)
class EventSpec:
name: str
version: str
producer: str
fields: tuple[FieldSpec, ...]
REGISTRY: tuple[EventSpec, ...] = (
EventSpec(
name="invoice.finalized",
version="2026-09",
producer="billing.finalize",
fields=(
FieldSpec("invoice_id", "ulid", True),
FieldSpec("account_id", "ulid", True),
FieldSpec("currency", "iso4217", True),
FieldSpec("total_minor", "int64", True),
FieldSpec("issued_at", "rfc3339", True),
),
),
EventSpec(
name="invoice.voided",
version="2026-09",
producer="billing.void",
fields=(
FieldSpec("invoice_id", "ulid", True),
FieldSpec("reason_code", "str", True),
FieldSpec("voided_at", "rfc3339", True),
),
),
)
The registry is deliberately boring. Boring input keeps the extractor small and keeps model drafts from inventing fields that never shipped. If your language is Go or Rust, emit the same JSON shape from struct tags so the later gates stay language-agnostic.
Step 2: Compile a facts file in CI, not in a chat window
The extractor should be a pure function from registry to JSON, with stable key order for clean diffs. Proposed command output lives under generated/ and is not hand-edited. Humans never patch this file; they change the registry or they change the overlay.
# tools/extract_event_catalog.py (proposed example)
import json
from pathlib import Path
from events.registry import REGISTRY
SIGNATURE_KEYS = (
"delivery",
"retry_budget",
"dead_letter_owner",
"pii_class",
"retention_doc",
"subscriber_impact",
"oncall",
"signed_by",
)
def compile_facts():
events = []
for spec in REGISTRY:
events.append({
"key": f"{spec.name}@{spec.version}",
"name": spec.name,
"version": spec.version,
"producer": spec.producer,
"fields": [
{
"name": f.name,
"type": f.type_name,
"required": f.required,
}
for f in spec.fields
],
})
return {
"schema": "event-catalog.facts.v1",
"events": events,
"signature_keys": list(SIGNATURE_KEYS),
}
def main() -> None:
facts = compile_facts()
out = Path("generated/event-catalog.facts.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(facts, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {out} events={len(facts['events'])}")
if __name__ == "__main__":
main()
# proposed Makefile fragment
.PHONY: event-facts event-docs
event-facts:
python tools/extract_event_catalog.py
Run make event-facts on every pull request and fail if the JSON diff is unexpected. Unexpected diffs are documentation bugs with the same severity as an accidental public field. That is the compile lane: mechanical, reviewable, and independent of any model.
Step 3: Confine optional model drafts to unsigned gloss lines
Drafting is a convenience for first-pass field glosses, not a path for retry text. When a team wants that convenience without standing up paid inference, MonkeyCode's free model access and free server option can host the unsigned summary step. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The host is optional, swappable, and must receive the facts JSON rather than the git tree.
# tools/draft_field_gloss.py (proposed example; unexecuted)
import json
from pathlib import Path
PROMPT = """You draft one unsigned gloss per field.
Use only names and types from the JSON.
Do not mention retry, PII, SLA, owners, or delivery.
Return JSON object {event_key: {field_name: gloss}}.
"""
def build_prompt(facts_path: str) -> str:
facts = json.loads(Path(facts_path).read_text(encoding="utf-8"))
return PROMPT + "\n" + json.dumps(facts, sort_keys=True)
# Operator wires build_prompt(...) to whatever local or remote draft host they control.
# Persist output to generated/event-catalog.draft-gloss.json, never to overlay.yaml.
Reject any draft that contains signature vocabulary. A cheap denylist is not a security boundary, but it catches the common failure where a model writes "delivered at least once" beside a field named invoice_id. Keep draft files under generated/ so reviewers can ignore them during incident work.
# tools/reject_signed_language_in_drafts.py (proposed example)
import json
import re
import sys
from pathlib import Path
FORBIDDEN = re.compile(
r"\b(at[- ]least[- ]once|exactly[- ]once|retry|dead[- ]letter|pii|gdpr|on-call|sla)\b",
re.I,
)
def main() -> None:
path = Path("generated/event-catalog.draft-gloss.json")
if not path.exists():
print("no draft file; skipping")
return
blob = path.read_text(encoding="utf-8")
if FORBIDDEN.search(blob):
print("draft contains signature-lane language; refuse to merge")
sys.exit(1)
json.loads(blob) # must be JSON even if unused
print("draft language check passed")
if __name__ == "__main__":
main()
Step 4: Hand-write the overlay and fail the docs build on gaps
The overlay is the only file where delivery, PII, and ownership may appear. Proposed format is YAML keyed by name@version, with an explicit signed_by cell. Empty strings are failures, not placeholders, because placeholders publish as if someone had thought.
# docs/overlays/event-catalog.overlay.yaml (proposed example)
schema: event-catalog.overlay.v1
events:
invoice.finalized@2026-09:
delivery: at_least_once
retry_budget: "6 attempts, exponential, cap 15m"
dead_letter_owner: billing-oncall
pii_class: indirect-identifier
retention_doc: docs/retention/billing.md#invoice-events
subscriber_impact: "Ledger and tax exporters; void does not retract this event"
oncall: billing-oncall
signed_by: "A. Reviewer"
invoice.voided@2026-09:
delivery: at_least_once
retry_budget: "6 attempts, exponential, cap 15m"
dead_letter_owner: billing-oncall
pii_class: none
retention_doc: docs/retention/billing.md#invoice-events
subscriber_impact: "Tax exporters must stop netting the sibling finalized event"
oncall: billing-oncall
signed_by: "A. Reviewer"
# tools/check_event_overlay.py (proposed example)
import json
import sys
from pathlib import Path
import yaml
REQUIRED = (
"delivery",
"retry_budget",
"dead_letter_owner",
"pii_class",
"retention_doc",
"subscriber_impact",
"oncall",
"signed_by",
)
def main() -> int:
facts = json.loads(Path("generated/event-catalog.facts.json").read_text(encoding="utf-8"))
overlay = yaml.safe_load(Path("docs/overlays/event-catalog.overlay.yaml").read_text(encoding="utf-8"))
events = overlay.get("events") or {}
missing = []
unsigned = []
extra = []
fact_keys = {item["key"] for item in facts["events"]}
for key in sorted(fact_keys):
row = events.get(key) or {}
for cell in REQUIRED:
value = str(row.get(cell) or "").strip()
if not value:
missing.append(f"{key}.{cell}")
elif cell == "signed_by" and value.lower() in {"tbd", "todo", "n/a"}:
unsigned.append(key)
for key in sorted(set(events) - fact_keys):
extra.append(key)
if missing or unsigned or extra:
print("overlay gate failed")
for item in missing:
print(f" missing {item}")
for item in unsigned:
print(f" unsigned {item}")
for item in extra:
print(f" extra {item}")
return 1
print(f"overlay gate passed events={len(fact_keys)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Wire the gate next to unit tests so documentation drift blocks merge the same way a broken import blocks merge. Extra overlay keys are failures too, because leftover signatures on deleted events are how zombie retry claims survive refactors.
Step 5: Render pages that show the lane boundary on the page
Readers should see which sentences were compiled and which sentences were signed. A single renderer can emit Markdown that labels those bands instead of blending them into marketing prose. Proposed output is checked in or published by CI, but never edited as the source.
# tools/render_event_docs.py (proposed example)
import json
from pathlib import Path
import yaml
def main() -> None:
facts = json.loads(Path("generated/event-catalog.facts.json").read_text(encoding="utf-8"))
overlay = yaml.safe_load(Path("docs/overlays/event-catalog.overlay.yaml").read_text(encoding="utf-8"))
gloss = {}
gloss_path = Path("generated/event-catalog.draft-gloss.json")
if gloss_path.exists():
gloss = json.loads(gloss_path.read_text(encoding="utf-8"))
lines = ["# Event catalog", "", "Compile-lane tables are generated. Signature-lane contracts are human-owned.", ""]
for item in facts["events"]:
key = item["key"]
row = overlay["events"][key]
lines.append(f"## `{item['name']}` ({item['version']})")
lines.append("")
lines.append(f"- Producer (compiled): `{item['producer']}`")
lines.append(f"- Delivery (signed): `{row['delivery']}`")
lines.append(f"- Retry budget (signed): {row['retry_budget']}")
lines.append(f"- Dead-letter owner (signed): `{row['dead_letter_owner']}`")
lines.append(f"- PII class (signed): `{row['pii_class']}`")
lines.append(f"- Retention pointer (signed): {row['retention_doc']}")
lines.append(f"- Subscriber impact (signed): {row['subscriber_impact']}")
lines.append(f"- On-call (signed): `{row['oncall']}`")
lines.append(f"- Signed by: {row['signed_by']}")
lines.append("")
lines.append("| Field | Type | Required | Draft gloss (unsigned) |")
lines.append("| --- | --- | --- | --- |")
g = gloss.get(key) or {}
for field in item["fields"]:
draft = g.get(field["name"], "")
lines.append(
f"| `{field['name']}` | `{field['type']}` | {field['required']} | {draft} |"
)
lines.append("")
out = Path("docs/generated/event-catalog.md")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8")
print(f"wrote {out}")
if __name__ == "__main__":
main()
event-docs: event-facts
python tools/reject_signed_language_in_drafts.py
python tools/check_event_overlay.py
python tools/render_event_docs.py
The rendered page is allowed to include draft glosses because they are labeled unsigned. If the gloss file is absent, the table still publishes, which keeps the model off the critical path. That property matters more than any drafting host, because documentation CI must run when inference is down.
Limitations and who should not use this
This workflow assumes event names and fields can be extracted without executing production code. Dynamic payloads assembled only at runtime, or events whose shape depends on customer configuration, will compile an incomplete catalog and then look authoritative. Do not use the overlay gate as a substitute for a data-protection review when events carry credentials, health data, or payment instruments.
Do not send raw payload samples to any drafting host, including a free server, because samples are often production-shaped. Do not let the model fill signed_by, and do not treat denylists as sufficient isolation. Teams that cannot fail the merge on missing overlay cells will watch the signature lane decay into TBD within a quarter.
Skip this approach when a single operator both generates the facts and rubber-stamps every overlay row without a second reviewer. The lane split is a review protocol, not a file format. If your events are internal debug telemetry with no subscribers, a generated field list without an overlay is enough and the extra YAML is ceremony.
The durable conclusion is narrow. Compile payload indexes from types, optionally draft unsigned glosses on a swappable host, and keep retry, PII, and subscriber impact in a human overlay that can fail the build. If you already extract catalogs in CI, the free drafting host is optional and easy to leave unplugged.
Top comments (0)